reqlo-sdk 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.
- reqlo_sdk-0.1.0/.gitignore +50 -0
- reqlo_sdk-0.1.0/LICENSE +21 -0
- reqlo_sdk-0.1.0/PKG-INFO +52 -0
- reqlo_sdk-0.1.0/README.md +37 -0
- reqlo_sdk-0.1.0/pyproject.toml +26 -0
- reqlo_sdk-0.1.0/src/reqlo/__init__.py +10 -0
- reqlo_sdk-0.1.0/src/reqlo/_client.py +225 -0
- reqlo_sdk-0.1.0/src/reqlo/_errors.py +47 -0
- reqlo_sdk-0.1.0/src/reqlo/_validate.py +85 -0
- reqlo_sdk-0.1.0/src/reqlo/_version.py +1 -0
- reqlo_sdk-0.1.0/src/reqlo/py.typed +0 -0
- reqlo_sdk-0.1.0/src/reqlo/webhooks.py +39 -0
- reqlo_sdk-0.1.0/tests/test_client.py +199 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
.next/
|
|
18
|
+
.next-dev/
|
|
19
|
+
out/
|
|
20
|
+
|
|
21
|
+
# production
|
|
22
|
+
build
|
|
23
|
+
dist/
|
|
24
|
+
|
|
25
|
+
# misc
|
|
26
|
+
.DS_Store
|
|
27
|
+
*.pem
|
|
28
|
+
|
|
29
|
+
# debug
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
.pnpm-debug.log*
|
|
34
|
+
|
|
35
|
+
# env files (can opt-in for committing if needed)
|
|
36
|
+
.env*
|
|
37
|
+
|
|
38
|
+
# vercel
|
|
39
|
+
.vercel
|
|
40
|
+
|
|
41
|
+
# typescript
|
|
42
|
+
*.tsbuildinfo
|
|
43
|
+
next-env.d.ts
|
|
44
|
+
!.env.example
|
|
45
|
+
|
|
46
|
+
# turborepo
|
|
47
|
+
.turbo
|
|
48
|
+
|
|
49
|
+
# fumadocs generated content index
|
|
50
|
+
.source
|
reqlo_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ReQlo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
reqlo_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: reqlo-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side Python client for ReQlo: authorization for AI agents.
|
|
5
|
+
Project-URL: Homepage, https://reqlo.io
|
|
6
|
+
Project-URL: Documentation, https://reqlo-web.vercel.app/docs/sdks/python
|
|
7
|
+
Author: ReQlo
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# ReQlo Python SDK
|
|
17
|
+
|
|
18
|
+
Server-side client for [ReQlo](https://reqlo.io), authorization for AI agents (`pip install reqlo-sdk`, `import reqlo`). Python 3.10+, standard library only,
|
|
19
|
+
sync and asyncio. Keep your project API key on your server.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install reqlo-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
from reqlo import Reqlo, ReqloDeniedError
|
|
28
|
+
|
|
29
|
+
reqlo = Reqlo(api_key=os.environ["REQLO_API_KEY"])
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
reqlo.enforce(
|
|
33
|
+
agent={"id": "agt_7Hq2mK9xR4vTq8Lp"},
|
|
34
|
+
subject={"id": session.user_id, "kind": "user"}, # from your verified session
|
|
35
|
+
action="documents.read",
|
|
36
|
+
resource={"type": "document", "id": doc.id, "attributes": {"owner_id": doc.owner_id}},
|
|
37
|
+
context={"document_classification": doc.classification},
|
|
38
|
+
)
|
|
39
|
+
except ReqloDeniedError as denied:
|
|
40
|
+
return {"error": "Not allowed", "reason": denied.decision["reason_code"]}
|
|
41
|
+
# Any other ReqloError (timeout, network, HTTP error) propagates: the action does not run.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- `authorize(...)` returns the decision dict; act only when `decision["decision"] == "allow"`.
|
|
45
|
+
- `enforce(...)` returns only on allow and raises `ReqloDeniedError` on deny or `approval_required`.
|
|
46
|
+
- `AsyncReqlo` has the same methods for asyncio.
|
|
47
|
+
- `approvals.list(status=)`, `approvals.retrieve(id)`.
|
|
48
|
+
- `delegations.create(agent_id, subject, actions, expires_at)`, `list`, `retrieve`, `revoke`. Create is never retried.
|
|
49
|
+
- `reqlo.webhooks.verify(body, signature_header, secret)` and `construct_event(...)` check webhook signatures.
|
|
50
|
+
|
|
51
|
+
Retries `429` and `5xx` twice with backoff (3-second timeout). Timeouts and network errors are never retried and
|
|
52
|
+
never mean allow. Documentation: <https://reqlo-web.vercel.app/docs/sdks/python>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ReQlo Python SDK
|
|
2
|
+
|
|
3
|
+
Server-side client for [ReQlo](https://reqlo.io), authorization for AI agents (`pip install reqlo-sdk`, `import reqlo`). Python 3.10+, standard library only,
|
|
4
|
+
sync and asyncio. Keep your project API key on your server.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install reqlo-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import os
|
|
12
|
+
from reqlo import Reqlo, ReqloDeniedError
|
|
13
|
+
|
|
14
|
+
reqlo = Reqlo(api_key=os.environ["REQLO_API_KEY"])
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
reqlo.enforce(
|
|
18
|
+
agent={"id": "agt_7Hq2mK9xR4vTq8Lp"},
|
|
19
|
+
subject={"id": session.user_id, "kind": "user"}, # from your verified session
|
|
20
|
+
action="documents.read",
|
|
21
|
+
resource={"type": "document", "id": doc.id, "attributes": {"owner_id": doc.owner_id}},
|
|
22
|
+
context={"document_classification": doc.classification},
|
|
23
|
+
)
|
|
24
|
+
except ReqloDeniedError as denied:
|
|
25
|
+
return {"error": "Not allowed", "reason": denied.decision["reason_code"]}
|
|
26
|
+
# Any other ReqloError (timeout, network, HTTP error) propagates: the action does not run.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `authorize(...)` returns the decision dict; act only when `decision["decision"] == "allow"`.
|
|
30
|
+
- `enforce(...)` returns only on allow and raises `ReqloDeniedError` on deny or `approval_required`.
|
|
31
|
+
- `AsyncReqlo` has the same methods for asyncio.
|
|
32
|
+
- `approvals.list(status=)`, `approvals.retrieve(id)`.
|
|
33
|
+
- `delegations.create(agent_id, subject, actions, expires_at)`, `list`, `retrieve`, `revoke`. Create is never retried.
|
|
34
|
+
- `reqlo.webhooks.verify(body, signature_header, secret)` and `construct_event(...)` check webhook signatures.
|
|
35
|
+
|
|
36
|
+
Retries `429` and `5xx` twice with backoff (3-second timeout). Timeouts and network errors are never retried and
|
|
37
|
+
never mean allow. Documentation: <https://reqlo-web.vercel.app/docs/sdks/python>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "reqlo-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Server-side Python client for ReQlo: authorization for AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
authors = [{ name = "ReQlo" }]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://reqlo.io"
|
|
23
|
+
Documentation = "https://reqlo-web.vercel.app/docs/sdks/python"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/reqlo"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""ReQlo: authorization for AI agents. Server-side client; keep your project API key on your server."""
|
|
2
|
+
from . import webhooks
|
|
3
|
+
from ._client import AsyncReqlo, Reqlo
|
|
4
|
+
from ._errors import (ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloError, ReqloNetworkError,
|
|
5
|
+
ReqloResponseError, ReqloSignatureError, ReqloTimeoutError)
|
|
6
|
+
from ._version import VERSION
|
|
7
|
+
|
|
8
|
+
__version__ = VERSION
|
|
9
|
+
__all__ = ["Reqlo", "AsyncReqlo", "webhooks", "ReqloError", "ReqloConfigurationError", "ReqloAPIError", "ReqloTimeoutError",
|
|
10
|
+
"ReqloNetworkError", "ReqloResponseError", "ReqloDeniedError", "ReqloSignatureError", "__version__"]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import urlencode, urlsplit
|
|
11
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
12
|
+
|
|
13
|
+
from ._errors import (ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloError, ReqloNetworkError,
|
|
14
|
+
ReqloResponseError, ReqloTimeoutError)
|
|
15
|
+
from ._validate import approval, approval_list, decision, delegation, delegation_list
|
|
16
|
+
from ._version import VERSION
|
|
17
|
+
|
|
18
|
+
# Only these codes are surfaced; anything else becomes None so response bodies never leak into logs.
|
|
19
|
+
_CODES = {"method_not_allowed", "parameter_invalid", "attribute_invalid", "action_unknown", "request_too_large",
|
|
20
|
+
"invalid_json", "invalid_request", "api_key_required", "api_key_invalid", "api_key_revoked", "scope_missing",
|
|
21
|
+
"authentication_required", "user_session_required", "resource_missing", "rate_limited", "internal_error",
|
|
22
|
+
"service_unavailable"}
|
|
23
|
+
_STATUSES = ("pending", "approved", "rejected", "expired", "consumed")
|
|
24
|
+
_APPROVAL_ID = re.compile(r"^APR_[A-Za-z0-9]{16}$")
|
|
25
|
+
_DELEGATION_ID = re.compile(r"^DEL_[A-Za-z0-9]{16}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
29
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None: # never follow redirects with a credential
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _base_url(value: str) -> str:
|
|
34
|
+
parts = urlsplit(value)
|
|
35
|
+
local = parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")
|
|
36
|
+
if (parts.scheme != "https" and not local) or parts.username or parts.password or parts.query or parts.fragment \
|
|
37
|
+
or not re.match(r"^/v1/?$", parts.path or ""):
|
|
38
|
+
raise ReqloConfigurationError("base_url must be HTTPS with path /v1 and no credentials, query or fragment. "
|
|
39
|
+
"Loopback HTTP is allowed for development.")
|
|
40
|
+
return "%s://%s/v1" % (parts.scheme, parts.netloc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _retry_delay(headers: Any, attempt: int) -> Optional[float]:
|
|
44
|
+
delay = min(5.0, 0.25 * 2 ** attempt * (0.75 + random.random() * 0.5))
|
|
45
|
+
header = headers.get("Retry-After") if headers is not None else None
|
|
46
|
+
if header:
|
|
47
|
+
header = header.strip()
|
|
48
|
+
try:
|
|
49
|
+
delay = max(0.0, float(header)) if re.match(r"^\d+(\.\d+)?$", header) else max(0.0, parsedate_to_datetime(header).timestamp() - time.time())
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
pass
|
|
52
|
+
# Surface the error rather than wait a long, server-directed time.
|
|
53
|
+
return delay if delay <= 10.0 else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Approvals:
|
|
57
|
+
def __init__(self, client: "Reqlo") -> None:
|
|
58
|
+
self._client = client
|
|
59
|
+
|
|
60
|
+
def list(self, status: Optional[str] = None) -> Dict[str, Any]:
|
|
61
|
+
"""Approvals in the key's environment, newest first: {"data": [...], "environment_id": ...}."""
|
|
62
|
+
if status is not None and status not in _STATUSES:
|
|
63
|
+
raise ReqloConfigurationError("status must be one of: %s." % ", ".join(_STATUSES))
|
|
64
|
+
return approval_list(self._client._request("GET", "/approvals" + ("?status=" + status if status else "")))
|
|
65
|
+
|
|
66
|
+
def retrieve(self, approval_id: str) -> Dict[str, Any]:
|
|
67
|
+
if not isinstance(approval_id, str) or not _APPROVAL_ID.match(approval_id):
|
|
68
|
+
raise ReqloConfigurationError("Use an approval ID returned by ReQlo.")
|
|
69
|
+
return approval(self._client._request("GET", "/approvals/" + approval_id))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Delegations:
|
|
73
|
+
def __init__(self, client: "Reqlo") -> None:
|
|
74
|
+
self._client = client
|
|
75
|
+
|
|
76
|
+
def create(self, agent_id: str, subject: Dict[str, str], actions: List[str], expires_at: str) -> Dict[str, Any]:
|
|
77
|
+
"""Record a person's grant. Call it from your backend after they consent. Never retried automatically."""
|
|
78
|
+
if not isinstance(agent_id, str) or not isinstance(subject, dict) or not isinstance(actions, list) or not isinstance(expires_at, str):
|
|
79
|
+
raise ReqloConfigurationError("Delegations need agent_id, subject, actions and expires_at.")
|
|
80
|
+
body = {"agent_id": agent_id, "subject": subject, "actions": actions, "expires_at": expires_at}
|
|
81
|
+
return delegation(self._client._request("POST", "/delegations", body, retry=False))
|
|
82
|
+
|
|
83
|
+
def list(self, agent_id: Optional[str] = None, subject_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
84
|
+
query = {k: v for k, v in (("agent_id", agent_id), ("subject_id", subject_id)) if v is not None}
|
|
85
|
+
return delegation_list(self._client._request("GET", "/delegations" + ("?" + urlencode(query) if query else "")))
|
|
86
|
+
|
|
87
|
+
def retrieve(self, delegation_id: str) -> Dict[str, Any]:
|
|
88
|
+
if not isinstance(delegation_id, str) or not _DELEGATION_ID.match(delegation_id):
|
|
89
|
+
raise ReqloConfigurationError("Use a delegation ID returned by ReQlo.")
|
|
90
|
+
return delegation(self._client._request("GET", "/delegations/" + delegation_id))
|
|
91
|
+
|
|
92
|
+
def revoke(self, delegation_id: str) -> Dict[str, Any]:
|
|
93
|
+
if not isinstance(delegation_id, str) or not _DELEGATION_ID.match(delegation_id):
|
|
94
|
+
raise ReqloConfigurationError("Use a delegation ID returned by ReQlo.")
|
|
95
|
+
return delegation(self._client._request("POST", "/delegations/%s/revoke" % delegation_id, {}))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Reqlo:
|
|
99
|
+
"""Server-side ReQlo client. Keep the project API key on your server; never ship it to a browser or a prompt."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, api_key: str, base_url: str = "https://api.reqlo.io/v1", timeout: float = 3.0, max_retries: int = 2) -> None:
|
|
102
|
+
if not isinstance(api_key, str) or not re.match(r"^rq_\S+$", api_key):
|
|
103
|
+
raise ReqloConfigurationError("A secret project API key (rq_...) is required.")
|
|
104
|
+
if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or not 0 < timeout <= 120:
|
|
105
|
+
raise ReqloConfigurationError("timeout must be between 0 and 120 seconds.")
|
|
106
|
+
if not isinstance(max_retries, int) or isinstance(max_retries, bool) or not 0 <= max_retries <= 5:
|
|
107
|
+
raise ReqloConfigurationError("max_retries must be between 0 and 5.")
|
|
108
|
+
self._api_key = api_key
|
|
109
|
+
self._base_url = _base_url(base_url)
|
|
110
|
+
self._timeout = float(timeout)
|
|
111
|
+
self._max_retries = max_retries
|
|
112
|
+
self._opener = build_opener(_NoRedirect())
|
|
113
|
+
self.approvals = _Approvals(self)
|
|
114
|
+
self.delegations = _Delegations(self)
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str: # never print the key
|
|
117
|
+
return "Reqlo(base_url=%r)" % self._base_url
|
|
118
|
+
|
|
119
|
+
def authorize(self, *, agent: Dict[str, str], action: str, resource: Dict[str, Any], subject: Optional[Dict[str, str]] = None,
|
|
120
|
+
context: Optional[Dict[str, Any]] = None, delegation_id: Optional[str] = None, approval_id: Optional[str] = None) -> Dict[str, Any]:
|
|
121
|
+
"""Returns the decision. Only act when decision["decision"] == "allow"."""
|
|
122
|
+
body: Dict[str, Any] = {"agent": agent, "action": action, "resource": resource}
|
|
123
|
+
for key, value in (("subject", subject), ("context", context), ("delegation_id", delegation_id), ("approval_id", approval_id)):
|
|
124
|
+
if value is not None:
|
|
125
|
+
body[key] = value
|
|
126
|
+
return decision(self._request("POST", "/authorize", body))
|
|
127
|
+
|
|
128
|
+
def enforce(self, **request: Any) -> Dict[str, Any]:
|
|
129
|
+
"""Returns the decision only on allow. Raises ReqloDeniedError otherwise, and ReqloError on any failure."""
|
|
130
|
+
result = self.authorize(**request)
|
|
131
|
+
if result["decision"] != "allow":
|
|
132
|
+
raise ReqloDeniedError(result)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
def _request(self, method: str, path: str, body: Any = None, retry: bool = True) -> Any:
|
|
136
|
+
try:
|
|
137
|
+
data = None if body is None else json.dumps(body, allow_nan=False).encode("utf-8")
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
raise ReqloConfigurationError("The request must be JSON serializable.") from None
|
|
140
|
+
headers = {"Authorization": "Bearer " + self._api_key, "Accept": "application/json", "User-Agent": "reqlo-python/" + VERSION}
|
|
141
|
+
if data is not None:
|
|
142
|
+
headers["Content-Type"] = "application/json"
|
|
143
|
+
attempt = 0
|
|
144
|
+
while True:
|
|
145
|
+
request = Request(self._base_url + path, data=data, method=method, headers=headers)
|
|
146
|
+
try:
|
|
147
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
148
|
+
raw = response.read()
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(raw)
|
|
151
|
+
except ValueError:
|
|
152
|
+
raise ReqloResponseError() from None
|
|
153
|
+
except HTTPError as error:
|
|
154
|
+
status = error.code
|
|
155
|
+
try:
|
|
156
|
+
payload = json.loads(error.read())
|
|
157
|
+
except Exception:
|
|
158
|
+
payload = None
|
|
159
|
+
finally:
|
|
160
|
+
error.close()
|
|
161
|
+
err = payload.get("error") if isinstance(payload, dict) and isinstance(payload.get("error"), dict) else {}
|
|
162
|
+
code = err.get("code") if err.get("code") in _CODES else None
|
|
163
|
+
rid = err.get("request_id") or (error.headers.get("Reqlo-Request-Id") if error.headers else None)
|
|
164
|
+
rid = rid if isinstance(rid, str) and re.match(r"^[A-Za-z0-9_-]{1,128}$", rid) else None
|
|
165
|
+
delay = _retry_delay(error.headers, attempt) if retry and (status == 429 or 500 <= status <= 599) and attempt < self._max_retries else None
|
|
166
|
+
if delay is None:
|
|
167
|
+
raise ReqloAPIError(status, code, rid) from None
|
|
168
|
+
time.sleep(delay)
|
|
169
|
+
attempt += 1
|
|
170
|
+
except ReqloError:
|
|
171
|
+
raise
|
|
172
|
+
except (socket.timeout, TimeoutError):
|
|
173
|
+
raise ReqloTimeoutError() from None
|
|
174
|
+
except URLError as error:
|
|
175
|
+
if isinstance(error.reason, (socket.timeout, TimeoutError)):
|
|
176
|
+
raise ReqloTimeoutError() from None
|
|
177
|
+
raise ReqloNetworkError() from None
|
|
178
|
+
except OSError:
|
|
179
|
+
raise ReqloNetworkError() from None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _AsyncApprovals:
|
|
183
|
+
def __init__(self, sync: _Approvals) -> None:
|
|
184
|
+
self._sync = sync
|
|
185
|
+
|
|
186
|
+
async def list(self, status: Optional[str] = None) -> Dict[str, Any]:
|
|
187
|
+
return await asyncio.to_thread(self._sync.list, status)
|
|
188
|
+
|
|
189
|
+
async def retrieve(self, approval_id: str) -> Dict[str, Any]:
|
|
190
|
+
return await asyncio.to_thread(self._sync.retrieve, approval_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _AsyncDelegations:
|
|
194
|
+
def __init__(self, sync: _Delegations) -> None:
|
|
195
|
+
self._sync = sync
|
|
196
|
+
|
|
197
|
+
async def create(self, agent_id: str, subject: Dict[str, str], actions: List[str], expires_at: str) -> Dict[str, Any]:
|
|
198
|
+
return await asyncio.to_thread(self._sync.create, agent_id, subject, actions, expires_at)
|
|
199
|
+
|
|
200
|
+
async def list(self, agent_id: Optional[str] = None, subject_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
201
|
+
return await asyncio.to_thread(self._sync.list, agent_id, subject_id)
|
|
202
|
+
|
|
203
|
+
async def retrieve(self, delegation_id: str) -> Dict[str, Any]:
|
|
204
|
+
return await asyncio.to_thread(self._sync.retrieve, delegation_id)
|
|
205
|
+
|
|
206
|
+
async def revoke(self, delegation_id: str) -> Dict[str, Any]:
|
|
207
|
+
return await asyncio.to_thread(self._sync.revoke, delegation_id)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class AsyncReqlo:
|
|
211
|
+
"""The same client for asyncio (FastAPI, agent frameworks). Requests run in a worker thread."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, api_key: str, base_url: str = "https://api.reqlo.io/v1", timeout: float = 3.0, max_retries: int = 2) -> None:
|
|
214
|
+
self._sync = Reqlo(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
|
|
215
|
+
self.approvals = _AsyncApprovals(self._sync.approvals)
|
|
216
|
+
self.delegations = _AsyncDelegations(self._sync.delegations)
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
return "AsyncReqlo(base_url=%r)" % self._sync._base_url
|
|
220
|
+
|
|
221
|
+
async def authorize(self, **request: Any) -> Dict[str, Any]:
|
|
222
|
+
return await asyncio.to_thread(lambda: self._sync.authorize(**request))
|
|
223
|
+
|
|
224
|
+
async def enforce(self, **request: Any) -> Dict[str, Any]:
|
|
225
|
+
return await asyncio.to_thread(lambda: self._sync.enforce(**request))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Every error means the protected action must stay stopped. None of them is ever an allow."""
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ReqloError(Exception):
|
|
6
|
+
"""Base class for every ReQlo error."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReqloConfigurationError(ReqloError):
|
|
10
|
+
"""Invalid client options or arguments. Fix your code or configuration."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReqloAPIError(ReqloError):
|
|
14
|
+
"""ReQlo returned an HTTP error. `code` is a stable error code when ReQlo sent a known one."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status: int, code: Optional[str], request_id: Optional[str]) -> None:
|
|
17
|
+
super().__init__("ReQlo returned HTTP %d. The protected action must remain stopped." % status)
|
|
18
|
+
self.status = status
|
|
19
|
+
self.code = code
|
|
20
|
+
self.request_id = request_id
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReqloTimeoutError(ReqloError):
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__("The ReQlo request timed out. The protected action must remain stopped.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReqloNetworkError(ReqloError):
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
super().__init__("The ReQlo request could not be completed. The protected action must remain stopped.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ReqloResponseError(ReqloError):
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
super().__init__("ReQlo returned an invalid response. The protected action must remain stopped.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReqloDeniedError(ReqloError):
|
|
39
|
+
"""`enforce` got deny or approval_required. `decision` holds the full decision."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, decision: Dict[str, Any]) -> None:
|
|
42
|
+
super().__init__("This action requires human approval." if decision.get("decision") == "approval_required" else "This action was denied.")
|
|
43
|
+
self.decision = decision
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ReqloSignatureError(ReqloError):
|
|
47
|
+
"""A webhook's signature is missing, invalid or too old."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Checks every response shape, so a malformed or unexpected response is an error, never an allow."""
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
from ._errors import ReqloResponseError
|
|
7
|
+
|
|
8
|
+
_STATUSES = ("pending", "approved", "rejected", "expired", "consumed")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _text(value: Any) -> bool:
|
|
12
|
+
return isinstance(value, str) and len(value) > 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _nullable_text(value: Any) -> bool:
|
|
16
|
+
return value is None or isinstance(value, str)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _date(value: Any) -> bool:
|
|
20
|
+
"""An ISO 8601 timestamp with date and time, as ReQlo returns (with or without fractions and offset)."""
|
|
21
|
+
if not isinstance(value, str) or not re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
|
|
25
|
+
return True
|
|
26
|
+
except ValueError:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _approval_id(value: Any) -> bool:
|
|
31
|
+
return isinstance(value, str) and re.match(r"^APR_[A-Za-z0-9]{16}$", value) is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decision(value: Any) -> Dict[str, Any]:
|
|
35
|
+
if not isinstance(value, dict) or not _text(value.get("id")) or value.get("object") != "decision" or not _text(value.get("reason_code")) \
|
|
36
|
+
or not isinstance(value.get("matched_rules"), list) or not all(_text(r) for r in value["matched_rules"]) \
|
|
37
|
+
or not _nullable_text(value.get("policy_version")) or not _text(value.get("environment")) \
|
|
38
|
+
or not isinstance(value.get("livemode"), bool) or not _date(value.get("created_at")) or not _text(value.get("request_id")):
|
|
39
|
+
raise ReqloResponseError()
|
|
40
|
+
if value.get("decision") == "approval_required":
|
|
41
|
+
a = value.get("approval")
|
|
42
|
+
if value.get("allowed") is not False or not isinstance(a, dict) or not _approval_id(a.get("id")) or a.get("status") != "pending" or not _date(a.get("expires_at")):
|
|
43
|
+
raise ReqloResponseError()
|
|
44
|
+
elif value.get("decision") not in ("allow", "deny") or value.get("allowed") is not (value["decision"] == "allow") or value.get("approval") is not None:
|
|
45
|
+
raise ReqloResponseError()
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def approval(value: Any) -> Dict[str, Any]:
|
|
50
|
+
summary = value.get("summary") if isinstance(value, dict) else None
|
|
51
|
+
if not isinstance(value, dict) or not _approval_id(value.get("id")) or value.get("object") != "approval" or value.get("status") not in _STATUSES \
|
|
52
|
+
or not _nullable_text(value.get("decision_id")) or not isinstance(value.get("request_digest"), str) \
|
|
53
|
+
or not re.match(r"^sha256:[a-f0-9]{64}$", value["request_digest"]) \
|
|
54
|
+
or not isinstance(summary, dict) or not all(summary.get(k) is None or _text(summary.get(k)) for k in ("agent_id", "subject_id", "action", "resource")) \
|
|
55
|
+
or not _nullable_text(value.get("channel_id")) or not _date(value.get("expires_at")) or not _nullable_text(value.get("resolved_by")) \
|
|
56
|
+
or not (value.get("resolved_at") is None or _date(value.get("resolved_at"))) or not _nullable_text(value.get("note")) \
|
|
57
|
+
or not _date(value.get("created_at")):
|
|
58
|
+
raise ReqloResponseError()
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def approval_list(value: Any) -> Dict[str, Any]:
|
|
63
|
+
if not isinstance(value, dict) or not isinstance(value.get("data"), list) or not _text(value.get("environment_id")):
|
|
64
|
+
raise ReqloResponseError()
|
|
65
|
+
return {"data": [approval(item) for item in value["data"]], "environment_id": value["environment_id"]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def delegation(value: Any) -> Dict[str, Any]:
|
|
69
|
+
subject = value.get("subject") if isinstance(value, dict) else None
|
|
70
|
+
verification = value.get("verification") if isinstance(value, dict) else None
|
|
71
|
+
if not isinstance(value, dict) or not isinstance(value.get("id"), str) or not re.match(r"^DEL_[A-Za-z0-9]{16}$", value["id"]) \
|
|
72
|
+
or value.get("object") != "delegation" or not _text(value.get("agent_id")) \
|
|
73
|
+
or not isinstance(subject, dict) or not _text(subject.get("id")) or not _text(subject.get("kind")) \
|
|
74
|
+
or not isinstance(value.get("actions"), list) or not all(_text(a) for a in value["actions"]) \
|
|
75
|
+
or not isinstance(verification, dict) or verification.get("method") != "server_session" \
|
|
76
|
+
or value.get("status") not in ("active", "revoked", "expired") or not _date(value.get("expires_at")) \
|
|
77
|
+
or not _date(value.get("created_at")) or not (value.get("revoked_at") is None or _date(value.get("revoked_at"))):
|
|
78
|
+
raise ReqloResponseError()
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def delegation_list(value: Any) -> List[Dict[str, Any]]:
|
|
83
|
+
if not isinstance(value, dict) or not isinstance(value.get("data"), list):
|
|
84
|
+
raise ReqloResponseError()
|
|
85
|
+
return [delegation(item) for item in value["data"]]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Verify ReQlo webhook signatures. An event never authorizes an action; it only tells you to look."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ._errors import ReqloSignatureError
|
|
10
|
+
|
|
11
|
+
TOLERANCE_SECONDS = 300
|
|
12
|
+
_HEADER = re.compile(r"^t=(\d{1,12}),v1=([a-f0-9]{64})$")
|
|
13
|
+
_SECRET = re.compile(r"^whsec_[a-f0-9]{64}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify(payload: Union[bytes, str], header: Optional[str], secret: str, tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> bool:
|
|
17
|
+
"""True only if `header` (Reqlo-Signature) signs the exact raw body with `secret`, within the tolerance."""
|
|
18
|
+
match = _HEADER.match(header or "")
|
|
19
|
+
if not match or not isinstance(secret, str) or not _SECRET.match(secret):
|
|
20
|
+
return False
|
|
21
|
+
current = int(time.time()) if now is None else now
|
|
22
|
+
if abs(current - int(match.group(1))) > tolerance:
|
|
23
|
+
return False
|
|
24
|
+
body = payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
25
|
+
expected = hmac.new(secret.encode("utf-8"), match.group(1).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
|
26
|
+
return hmac.compare_digest(expected, match.group(2))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def construct_event(payload: Union[bytes, str], header: Optional[str], secret: str, tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> Dict[str, Any]:
|
|
30
|
+
"""Verifies the signature and returns the parsed event. Raises ReqloSignatureError if it isn't valid."""
|
|
31
|
+
if not verify(payload, header, secret, tolerance, now):
|
|
32
|
+
raise ReqloSignatureError("The webhook signature is missing, invalid or too old.")
|
|
33
|
+
try:
|
|
34
|
+
event = json.loads(payload)
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise ReqloSignatureError("The webhook body isn't valid JSON.") from None
|
|
37
|
+
if not isinstance(event, dict) or event.get("object") != "event" or not isinstance(event.get("type"), str):
|
|
38
|
+
raise ReqloSignatureError("The webhook body isn't a ReQlo event.")
|
|
39
|
+
return event
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
|
|
10
|
+
import reqlo
|
|
11
|
+
from reqlo import (AsyncReqlo, Reqlo, ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloNetworkError,
|
|
12
|
+
ReqloResponseError, ReqloSignatureError, ReqloTimeoutError, webhooks)
|
|
13
|
+
|
|
14
|
+
NOW = "2026-11-09T10:42:07.123Z"
|
|
15
|
+
APPROVAL = {"id": "APR_7Hq2mK9xR4vTq8Lp", "object": "approval", "status": "pending", "decision_id": None,
|
|
16
|
+
"request_digest": "sha256:" + "a" * 64, "summary": {"agent_id": "agt_x", "action": "documents.read"},
|
|
17
|
+
"channel_id": None, "expires_at": NOW, "resolved_by": None, "resolved_at": None, "note": None, "created_at": NOW}
|
|
18
|
+
DELEGATION = {"id": "DEL_5Hq2mK9xR4vTq8Lp", "object": "delegation", "agent_id": "agt_x", "subject": {"id": "usr_94", "kind": "user"},
|
|
19
|
+
"actions": ["calendar.schedule"], "verification": {"method": "server_session"}, "status": "active",
|
|
20
|
+
"expires_at": NOW, "created_at": NOW, "revoked_at": None}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decision(kind):
|
|
24
|
+
base = {"id": "dec_1", "object": "decision", "decision": kind, "allowed": kind == "allow", "reason_code": "policy_allowed" if kind == "allow" else "policy_denied",
|
|
25
|
+
"matched_rules": [], "policy_version": "2026-11-09.1", "approval": None, "environment": "sandbox", "livemode": False, "created_at": NOW, "request_id": "r1"}
|
|
26
|
+
if kind == "approval_required":
|
|
27
|
+
base.update(reason_code="approval_required", approval={"id": APPROVAL["id"], "status": "pending", "expires_at": NOW})
|
|
28
|
+
return base
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Server:
|
|
32
|
+
"""Queue of (status, body, headers) responses; records every request."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.queue, self.requests = [], []
|
|
36
|
+
server = self
|
|
37
|
+
|
|
38
|
+
class Handler(BaseHTTPRequestHandler):
|
|
39
|
+
def log_message(self, *args):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def handle_any(self):
|
|
43
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
44
|
+
body = json.loads(self.rfile.read(length)) if length else None
|
|
45
|
+
server.requests.append((self.command, self.path, dict(self.headers), body))
|
|
46
|
+
status, payload, headers = server.queue.pop(0) if server.queue else (500, {}, {})
|
|
47
|
+
if status == "sleep":
|
|
48
|
+
time.sleep(payload)
|
|
49
|
+
status, payload, headers = 200, decision("allow"), {}
|
|
50
|
+
raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
|
|
51
|
+
self.send_response(status)
|
|
52
|
+
for key, value in headers.items():
|
|
53
|
+
self.send_header(key, value)
|
|
54
|
+
self.send_header("Content-Type", "application/json")
|
|
55
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
56
|
+
self.end_headers()
|
|
57
|
+
self.wfile.write(raw)
|
|
58
|
+
|
|
59
|
+
do_GET = do_POST = handle_any
|
|
60
|
+
|
|
61
|
+
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
62
|
+
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
63
|
+
self.url = "http://127.0.0.1:%d/v1" % self.httpd.server_port
|
|
64
|
+
|
|
65
|
+
def close(self):
|
|
66
|
+
self.httpd.shutdown()
|
|
67
|
+
self.httpd.server_close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
REQUEST = dict(agent={"id": "agt_x"}, action="documents.read", resource={"type": "document", "id": "doc_1"})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ClientTest(unittest.TestCase):
|
|
74
|
+
def setUp(self):
|
|
75
|
+
self.server = Server()
|
|
76
|
+
self.client = Reqlo("rq_test_secret", base_url=self.server.url, max_retries=2)
|
|
77
|
+
|
|
78
|
+
def tearDown(self):
|
|
79
|
+
self.server.close()
|
|
80
|
+
|
|
81
|
+
def test_authorize_sends_the_request_and_returns_the_decision(self):
|
|
82
|
+
self.server.queue.append((200, decision("allow"), {}))
|
|
83
|
+
result = self.client.authorize(subject={"id": "usr_1", "kind": "user"}, context={"level": 2}, **REQUEST)
|
|
84
|
+
self.assertEqual(result["decision"], "allow")
|
|
85
|
+
method, path, headers, body = self.server.requests[0]
|
|
86
|
+
self.assertEqual((method, path), ("POST", "/v1/authorize"))
|
|
87
|
+
self.assertEqual(headers["Authorization"], "Bearer rq_test_secret")
|
|
88
|
+
self.assertEqual(body, {**REQUEST, "subject": {"id": "usr_1", "kind": "user"}, "context": {"level": 2}})
|
|
89
|
+
|
|
90
|
+
def test_enforce_returns_on_allow_and_raises_on_deny_or_approval(self):
|
|
91
|
+
self.server.queue += [(200, decision("allow"), {}), (200, decision("deny"), {}), (200, decision("approval_required"), {})]
|
|
92
|
+
self.assertEqual(self.client.enforce(**REQUEST)["decision"], "allow")
|
|
93
|
+
with self.assertRaises(ReqloDeniedError) as denied:
|
|
94
|
+
self.client.enforce(**REQUEST)
|
|
95
|
+
self.assertEqual(denied.exception.decision["reason_code"], "policy_denied")
|
|
96
|
+
with self.assertRaises(ReqloDeniedError) as held:
|
|
97
|
+
self.client.enforce(**REQUEST)
|
|
98
|
+
self.assertEqual(held.exception.decision["approval"]["id"], APPROVAL["id"])
|
|
99
|
+
|
|
100
|
+
def test_malformed_or_contradictory_responses_are_errors_not_allows(self):
|
|
101
|
+
bad = [{"decision": "allow"}, {**decision("allow"), "allowed": False}, {**decision("deny"), "allowed": True},
|
|
102
|
+
{**decision("allow"), "decision": "maybe"}, b"not json"]
|
|
103
|
+
for body in bad:
|
|
104
|
+
self.server.queue.append((200, body, {}))
|
|
105
|
+
with self.assertRaises(ReqloResponseError):
|
|
106
|
+
self.client.enforce(**REQUEST)
|
|
107
|
+
|
|
108
|
+
def test_retries_429_and_5xx_then_raises_with_code_and_request_id(self):
|
|
109
|
+
self.server.queue += [(503, {"error": {"code": "service_unavailable", "request_id": "r9"}}, {"Retry-After": "0"})] * 3
|
|
110
|
+
with self.assertRaises(ReqloAPIError) as error:
|
|
111
|
+
self.client.authorize(**REQUEST)
|
|
112
|
+
self.assertEqual((error.exception.status, error.exception.code, error.exception.request_id), (503, "service_unavailable", "r9"))
|
|
113
|
+
self.assertEqual(len(self.server.requests), 3)
|
|
114
|
+
|
|
115
|
+
def test_4xx_is_not_retried_and_unknown_codes_are_hidden(self):
|
|
116
|
+
self.server.queue.append((422, {"error": {"code": "something_secret", "message": "secret detail"}}, {}))
|
|
117
|
+
with self.assertRaises(ReqloAPIError) as error:
|
|
118
|
+
self.client.authorize(**REQUEST)
|
|
119
|
+
self.assertIsNone(error.exception.code)
|
|
120
|
+
self.assertNotIn("secret", str(error.exception))
|
|
121
|
+
self.assertEqual(len(self.server.requests), 1)
|
|
122
|
+
|
|
123
|
+
def test_timeouts_and_network_failures_raise_and_are_not_retried(self):
|
|
124
|
+
slow = Reqlo("rq_test_secret", base_url=self.server.url, timeout=0.2)
|
|
125
|
+
self.server.queue.append(("sleep", 1, {}))
|
|
126
|
+
with self.assertRaises(ReqloTimeoutError):
|
|
127
|
+
slow.enforce(**REQUEST)
|
|
128
|
+
self.assertEqual(len(self.server.requests), 1)
|
|
129
|
+
with self.assertRaises(ReqloNetworkError):
|
|
130
|
+
Reqlo("rq_test_secret", base_url="http://127.0.0.1:9/v1", max_retries=0).enforce(**REQUEST)
|
|
131
|
+
|
|
132
|
+
def test_delegation_create_is_never_retried(self):
|
|
133
|
+
self.server.queue.append((503, {"error": {"code": "service_unavailable"}}, {"Retry-After": "0"}))
|
|
134
|
+
with self.assertRaises(ReqloAPIError):
|
|
135
|
+
self.client.delegations.create("agt_x", {"id": "usr_94", "kind": "user"}, ["calendar.schedule"], NOW)
|
|
136
|
+
self.assertEqual(len(self.server.requests), 1)
|
|
137
|
+
|
|
138
|
+
def test_approvals_and_delegations(self):
|
|
139
|
+
self.server.queue += [(200, {"data": [APPROVAL], "environment_id": "env"}, {}), (200, APPROVAL, {}),
|
|
140
|
+
(201, DELEGATION, {}), (200, {"data": [DELEGATION]}, {}), (200, DELEGATION, {}), (200, {**DELEGATION, "status": "revoked", "revoked_at": NOW}, {})]
|
|
141
|
+
self.assertEqual(self.client.approvals.list(status="pending")["data"][0]["id"], APPROVAL["id"])
|
|
142
|
+
self.assertEqual(self.client.approvals.retrieve(APPROVAL["id"])["status"], "pending")
|
|
143
|
+
self.assertEqual(self.client.delegations.create("agt_x", {"id": "usr_94", "kind": "user"}, ["calendar.schedule"], NOW)["status"], "active")
|
|
144
|
+
self.assertEqual(len(self.client.delegations.list(subject_id="usr_94")), 1)
|
|
145
|
+
self.assertEqual(self.client.delegations.retrieve(DELEGATION["id"])["id"], DELEGATION["id"])
|
|
146
|
+
self.assertEqual(self.client.delegations.revoke(DELEGATION["id"])["status"], "revoked")
|
|
147
|
+
paths = [(m, p) for m, p, _, _ in self.server.requests]
|
|
148
|
+
self.assertEqual(paths, [("GET", "/v1/approvals?status=pending"), ("GET", "/v1/approvals/" + APPROVAL["id"]), ("POST", "/v1/delegations"),
|
|
149
|
+
("GET", "/v1/delegations?subject_id=usr_94"), ("GET", "/v1/delegations/" + DELEGATION["id"]), ("POST", "/v1/delegations/%s/revoke" % DELEGATION["id"])])
|
|
150
|
+
|
|
151
|
+
def test_async_client(self):
|
|
152
|
+
self.server.queue += [(200, decision("allow"), {}), (200, decision("deny"), {})]
|
|
153
|
+
client = AsyncReqlo("rq_test_secret", base_url=self.server.url)
|
|
154
|
+
self.assertEqual(asyncio.run(client.enforce(**REQUEST))["decision"], "allow")
|
|
155
|
+
with self.assertRaises(ReqloDeniedError):
|
|
156
|
+
asyncio.run(client.enforce(**REQUEST))
|
|
157
|
+
|
|
158
|
+
def test_configuration_is_checked_and_the_key_never_printed(self):
|
|
159
|
+
for kwargs in [dict(api_key="sk_nope"), dict(api_key="rq_test_x", base_url="http://api.reqlo.io/v1"), dict(api_key="rq_test_x", base_url="https://api.reqlo.io/v2"),
|
|
160
|
+
dict(api_key="rq_test_x", timeout=0), dict(api_key="rq_test_x", max_retries=9)]:
|
|
161
|
+
with self.assertRaises(ReqloConfigurationError):
|
|
162
|
+
Reqlo(**kwargs)
|
|
163
|
+
self.assertNotIn("rq_test_secret", repr(self.client))
|
|
164
|
+
with self.assertRaises(ReqloConfigurationError):
|
|
165
|
+
self.client.approvals.retrieve("not-an-id")
|
|
166
|
+
with self.assertRaises(ReqloConfigurationError):
|
|
167
|
+
self.client.approvals.list(status="maybe")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class WebhookTest(unittest.TestCase):
|
|
171
|
+
secret = "whsec_" + "b" * 64
|
|
172
|
+
body = json.dumps({"id": "EVT_1", "object": "event", "type": "approval.requested", "data": {"object": {"id": APPROVAL["id"]}}})
|
|
173
|
+
|
|
174
|
+
def sign(self, t, body=None, secret=None):
|
|
175
|
+
digest = hmac.new((secret or self.secret).encode(), ("%d." % t).encode() + (body or self.body).encode(), hashlib.sha256).hexdigest()
|
|
176
|
+
return "t=%d,v1=%s" % (t, digest)
|
|
177
|
+
|
|
178
|
+
def test_verify(self):
|
|
179
|
+
now = 1_800_000_000
|
|
180
|
+
self.assertTrue(webhooks.verify(self.body, self.sign(now), self.secret, now=now))
|
|
181
|
+
self.assertTrue(webhooks.verify(self.body.encode(), self.sign(now - 299), self.secret, now=now))
|
|
182
|
+
self.assertFalse(webhooks.verify(self.body, self.sign(now - 301), self.secret, now=now))
|
|
183
|
+
self.assertFalse(webhooks.verify(self.body + " ", self.sign(now), self.secret, now=now))
|
|
184
|
+
self.assertFalse(webhooks.verify(self.body, self.sign(now, secret="whsec_" + "c" * 64), self.secret, now=now))
|
|
185
|
+
self.assertFalse(webhooks.verify(self.body, None, self.secret, now=now))
|
|
186
|
+
self.assertFalse(webhooks.verify(self.body, self.sign(now), "not-a-secret", now=now))
|
|
187
|
+
|
|
188
|
+
def test_construct_event(self):
|
|
189
|
+
now = 1_800_000_000
|
|
190
|
+
self.assertEqual(webhooks.construct_event(self.body, self.sign(now), self.secret, now=now)["type"], "approval.requested")
|
|
191
|
+
with self.assertRaises(ReqloSignatureError):
|
|
192
|
+
webhooks.construct_event(self.body, "t=1,v1=" + "0" * 64, self.secret, now=now)
|
|
193
|
+
|
|
194
|
+
def test_version(self):
|
|
195
|
+
self.assertEqual(reqlo.__version__, "0.1.0")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
unittest.main()
|