allowly 0.2.0__py3-none-any.whl
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.
- allowly/__init__.py +55 -0
- allowly/client.py +792 -0
- allowly/error.py +34 -0
- allowly/identifiers.py +47 -0
- allowly/mcp.py +129 -0
- allowly/types.py +175 -0
- allowly/verify.py +154 -0
- allowly-0.2.0.dist-info/METADATA +120 -0
- allowly-0.2.0.dist-info/RECORD +11 -0
- allowly-0.2.0.dist-info/WHEEL +4 -0
- allowly-0.2.0.dist-info/licenses/LICENSE +21 -0
allowly/error.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class FieldError:
|
|
8
|
+
field: str
|
|
9
|
+
message: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AllowlyAPIError(Exception):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
status: int,
|
|
16
|
+
code: str,
|
|
17
|
+
message: str,
|
|
18
|
+
fields: list[FieldError] | None = None,
|
|
19
|
+
retry_after_seconds: float | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.fields = fields or []
|
|
25
|
+
#: Parsed ``Retry-After`` response header, when the API sent one
|
|
26
|
+
#: (rate limits, contended idempotent replays). Honor it before retrying.
|
|
27
|
+
self.retry_after_seconds = retry_after_seconds
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return f"AllowlyAPIError(status={self.status}, code={self.code!r}, message={str(self)!r})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AllowlyProtocolError(ValueError):
|
|
34
|
+
"""The API returned a response that does not match its wire contract."""
|
allowly/identifiers.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
EMAIL_HMAC_VERSION = "v1"
|
|
8
|
+
EMAIL_HMAC_PREFIX = "email_hmac"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_email(email: str) -> str:
|
|
12
|
+
"""Normalize an email address for Allowly's local identifier helper."""
|
|
13
|
+
normalized = email.strip().lower()
|
|
14
|
+
if not normalized:
|
|
15
|
+
raise ValueError("email must not be empty")
|
|
16
|
+
return normalized
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def from_email(email: str, *, pepper: str | bytes, version: str = EMAIL_HMAC_VERSION) -> str:
|
|
20
|
+
"""Return a stable opaque user_id derived locally from an email address.
|
|
21
|
+
|
|
22
|
+
The raw email and pepper never leave the customer's application.
|
|
23
|
+
"""
|
|
24
|
+
if version != EMAIL_HMAC_VERSION:
|
|
25
|
+
raise ValueError("unsupported email identifier version")
|
|
26
|
+
|
|
27
|
+
if isinstance(pepper, str):
|
|
28
|
+
key = pepper.encode("utf-8")
|
|
29
|
+
elif isinstance(pepper, bytes):
|
|
30
|
+
key = pepper
|
|
31
|
+
else:
|
|
32
|
+
raise TypeError("pepper must be str or bytes")
|
|
33
|
+
if not key:
|
|
34
|
+
raise ValueError("pepper must not be empty")
|
|
35
|
+
|
|
36
|
+
message = normalize_email(email).encode("utf-8")
|
|
37
|
+
digest = hmac.new(key, message, hashlib.sha256).digest()
|
|
38
|
+
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
39
|
+
return f"{EMAIL_HMAC_PREFIX}:{version}:{encoded}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"EMAIL_HMAC_PREFIX",
|
|
44
|
+
"EMAIL_HMAC_VERSION",
|
|
45
|
+
"from_email",
|
|
46
|
+
"normalize_email",
|
|
47
|
+
]
|
allowly/mcp.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Allowly middleware for FastMCP 2.x servers.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP("my-server")
|
|
7
|
+
mcp.add_middleware(AllowlyMCPMiddleware(
|
|
8
|
+
api_key="allowly_l1_s001_...",
|
|
9
|
+
user_id_fn=lambda context: context.fastmcp_context.session.user_id,
|
|
10
|
+
authorization_id_fn=lambda user_id: db.get_authorization_id(user_id),
|
|
11
|
+
))
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Awaitable, Callable, Optional, Union
|
|
18
|
+
|
|
19
|
+
import mcp.types as mt
|
|
20
|
+
from fastmcp.exceptions import ToolError
|
|
21
|
+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
22
|
+
from fastmcp.tools.tool import ToolResult
|
|
23
|
+
|
|
24
|
+
from allowly.client import Allowly
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
AuthorizationIdResult = Optional[str]
|
|
28
|
+
AuthorizationIdFn = Callable[[str], Union[AuthorizationIdResult, Awaitable[AuthorizationIdResult]]]
|
|
29
|
+
UserIdResult = Optional[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class MCPAuthorizationContext:
|
|
34
|
+
tool_name: str
|
|
35
|
+
arguments: dict[str, Any]
|
|
36
|
+
request: Any | None = None
|
|
37
|
+
fastmcp_context: Any | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
UserIdFn = Callable[[MCPAuthorizationContext], Union[UserIdResult, Awaitable[UserIdResult]]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AllowlyMCPMiddleware(Middleware):
|
|
44
|
+
"""Gate every FastMCP tool call through Allowly.
|
|
45
|
+
|
|
46
|
+
``user_id_fn`` must resolve identity from trusted host context, not
|
|
47
|
+
caller-controlled tool arguments. ``authorization_id_fn`` is then called with
|
|
48
|
+
that trusted user ID and must return the corresponding Allowly authorization
|
|
49
|
+
ID. Both callbacks may be sync or async. If either returns ``None`` the check
|
|
50
|
+
is denied immediately.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str,
|
|
56
|
+
authorization_id_fn: AuthorizationIdFn,
|
|
57
|
+
*,
|
|
58
|
+
base_url: Optional[str] = None,
|
|
59
|
+
user_id_fn: UserIdFn | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
kwargs: dict[str, Any] = {}
|
|
62
|
+
if base_url:
|
|
63
|
+
kwargs["base_url"] = base_url
|
|
64
|
+
self.client = Allowly(api_key, **kwargs)
|
|
65
|
+
self.authorization_id_fn = authorization_id_fn
|
|
66
|
+
self.user_id_fn = user_id_fn
|
|
67
|
+
|
|
68
|
+
async def aclose(self) -> None:
|
|
69
|
+
await self.client.aclose()
|
|
70
|
+
|
|
71
|
+
async def _resolve_authorization_id(self, context: MCPAuthorizationContext) -> Optional[str]:
|
|
72
|
+
user_id = await self._resolve_user_id(context)
|
|
73
|
+
if not user_id:
|
|
74
|
+
return None
|
|
75
|
+
result = self.authorization_id_fn(user_id)
|
|
76
|
+
if hasattr(result, "__await__"):
|
|
77
|
+
return await result # type: ignore[return-value]
|
|
78
|
+
return result # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
async def _resolve_user_id(self, context: MCPAuthorizationContext) -> Optional[str]:
|
|
81
|
+
if self.user_id_fn is not None:
|
|
82
|
+
result = self.user_id_fn(context)
|
|
83
|
+
if hasattr(result, "__await__"):
|
|
84
|
+
return await result # type: ignore[return-value]
|
|
85
|
+
return result # type: ignore[return-value]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
async def on_call_tool(
|
|
89
|
+
self,
|
|
90
|
+
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
91
|
+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
92
|
+
) -> ToolResult:
|
|
93
|
+
"""FastMCP hook — called before every tool execution."""
|
|
94
|
+
name = context.message.name
|
|
95
|
+
args = context.message.arguments or {}
|
|
96
|
+
auth_context = MCPAuthorizationContext(
|
|
97
|
+
tool_name=name,
|
|
98
|
+
arguments=args,
|
|
99
|
+
fastmcp_context=context.fastmcp_context,
|
|
100
|
+
)
|
|
101
|
+
authorization_id = await self._resolve_authorization_id(auth_context)
|
|
102
|
+
if authorization_id is None:
|
|
103
|
+
raise ToolError("authorization_not_found")
|
|
104
|
+
|
|
105
|
+
result = await self.client.check(authorization_id=authorization_id, actions=[name])
|
|
106
|
+
action_result = result.results[name]
|
|
107
|
+
if action_result.decision == "allow":
|
|
108
|
+
return await call_next(context)
|
|
109
|
+
raise ToolError(json.dumps(_decision_payload(action_result)))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decision_payload(action: Any) -> dict[str, Any]:
|
|
113
|
+
if action.decision == "confirm":
|
|
114
|
+
return {
|
|
115
|
+
"decision": "confirm",
|
|
116
|
+
"reason": action.reason,
|
|
117
|
+
"confirm_nonce": action.confirm_nonce,
|
|
118
|
+
"confirm_expires_at": action.confirm_expires_at,
|
|
119
|
+
"confirm_prompt_hint": action.confirm_prompt_hint,
|
|
120
|
+
}
|
|
121
|
+
if action.decision == "escalate":
|
|
122
|
+
return {
|
|
123
|
+
"decision": "escalate",
|
|
124
|
+
"reason": action.reason,
|
|
125
|
+
"escalation_id": action.escalation_id,
|
|
126
|
+
"escalation_to": action.escalation_to,
|
|
127
|
+
"escalation_expires_at": action.escalation_expires_at,
|
|
128
|
+
}
|
|
129
|
+
return {"decision": action.decision, "reason": action.reason}
|
allowly/types.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Decision = Literal["allow", "deny", "confirm", "escalate"]
|
|
8
|
+
FallbackMode = Literal["fail_open", "fail_closed"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ReceiptEnvelopePending:
|
|
13
|
+
status: Literal["pending"]
|
|
14
|
+
receipt_id: str
|
|
15
|
+
ready_at_estimate: str | None
|
|
16
|
+
url: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ReceiptEnvelopeSigned:
|
|
21
|
+
status: Literal["signed"]
|
|
22
|
+
receipt: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ReceiptEnvelope = Union[ReceiptEnvelopePending, ReceiptEnvelopeSigned]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class BudgetInfo:
|
|
30
|
+
limit_micros: int
|
|
31
|
+
spent_micros: int
|
|
32
|
+
estimated_cost_micros: int
|
|
33
|
+
spent_after_micros: int | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BudgetSettlementResponse:
|
|
38
|
+
check_receipt_id: str
|
|
39
|
+
authorization_id: str
|
|
40
|
+
estimated_cost_micros: int
|
|
41
|
+
actual_cost_micros: int
|
|
42
|
+
delta_micros: int
|
|
43
|
+
spent_before_micros: int
|
|
44
|
+
spent_after_micros: int
|
|
45
|
+
receipt: ReceiptEnvelope
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class EscalationInfo:
|
|
50
|
+
escalation_id: str
|
|
51
|
+
status: str
|
|
52
|
+
escalation_to: str | None = None
|
|
53
|
+
expires_at: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class PolicyConditionEvidence:
|
|
58
|
+
field: str
|
|
59
|
+
op: str
|
|
60
|
+
value: str | int | bool | None | list[str | int | bool | None]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class PolicyEvalInfo:
|
|
65
|
+
matched_condition: PolicyConditionEvidence | None
|
|
66
|
+
field_value: str | int | bool | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(kw_only=True)
|
|
70
|
+
class ActionCheckResultBase:
|
|
71
|
+
decision: Decision
|
|
72
|
+
reason: str
|
|
73
|
+
receipt: ReceiptEnvelope | None
|
|
74
|
+
is_fallback: bool = False
|
|
75
|
+
fallback_mode: FallbackMode | None = None
|
|
76
|
+
budget: BudgetInfo | None = None
|
|
77
|
+
escalation: EscalationInfo | None = None
|
|
78
|
+
policy_eval: PolicyEvalInfo | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ActionCheckResultAllow(ActionCheckResultBase):
|
|
83
|
+
decision: Literal["allow"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ActionCheckResultDeny(ActionCheckResultBase):
|
|
88
|
+
decision: Literal["deny"]
|
|
89
|
+
superseded_by: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(kw_only=True)
|
|
93
|
+
class ActionCheckResultConfirm(ActionCheckResultBase):
|
|
94
|
+
decision: Literal["confirm"]
|
|
95
|
+
confirm_nonce: str
|
|
96
|
+
confirm_expires_at: str
|
|
97
|
+
confirm_prompt_hint: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(kw_only=True)
|
|
101
|
+
class ActionCheckResultEscalate(ActionCheckResultBase):
|
|
102
|
+
decision: Literal["escalate"]
|
|
103
|
+
escalation_id: str
|
|
104
|
+
escalation_to: str | None = None
|
|
105
|
+
escalation_expires_at: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
ActionCheckResult = Union[
|
|
109
|
+
ActionCheckResultAllow,
|
|
110
|
+
ActionCheckResultDeny,
|
|
111
|
+
ActionCheckResultConfirm,
|
|
112
|
+
ActionCheckResultEscalate,
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class CheckResponse:
|
|
118
|
+
authorization_id: str
|
|
119
|
+
user_id: str | None
|
|
120
|
+
agent_id: str | None
|
|
121
|
+
authorization_expires_at: str | None
|
|
122
|
+
engine_version: str
|
|
123
|
+
results: dict[str, ActionCheckResult]
|
|
124
|
+
#: X-Allowly-Billing-Warning response header, when the workspace is close
|
|
125
|
+
#: to a quota/payment boundary. Surface it to operators.
|
|
126
|
+
billing_warning: str | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class ActionEntry:
|
|
131
|
+
name: str
|
|
132
|
+
constraints: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class AuthorizationCreateResponse:
|
|
137
|
+
authorization_id: str
|
|
138
|
+
created_at: str
|
|
139
|
+
expires_at: str
|
|
140
|
+
receipt: ReceiptEnvelopePending
|
|
141
|
+
requires_confirm_for: list[str]
|
|
142
|
+
requires_escalation_for: list[str]
|
|
143
|
+
requires_deny_for: list[str]
|
|
144
|
+
escalation_targets: dict[str, str]
|
|
145
|
+
policy_id: str | None = None
|
|
146
|
+
budget_limit_micros: int | None = None
|
|
147
|
+
budget_spent_micros: int | None = None
|
|
148
|
+
replaced_authorization_id: str | None = None
|
|
149
|
+
revocation_receipt: ReceiptEnvelopePending | None = None
|
|
150
|
+
#: X-Allowly-Billing-Warning response header, when present.
|
|
151
|
+
billing_warning: str | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class AuthorizationRevokeResponse:
|
|
156
|
+
authorization_id: str
|
|
157
|
+
revoked_at: str
|
|
158
|
+
receipt: ReceiptEnvelopePending
|
|
159
|
+
revoked_confirmations: list[str] = field(default_factory=list)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class ConfirmationApproveResponse:
|
|
164
|
+
decision: Literal["approved", "denied_by_user"]
|
|
165
|
+
authorization_id: str | None = None
|
|
166
|
+
expires_at: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class EscalationResolveResponse:
|
|
171
|
+
escalation_id: str
|
|
172
|
+
status: Literal["approved", "rejected"]
|
|
173
|
+
resolved_by: str | None = None
|
|
174
|
+
resolved_at: str | None = None
|
|
175
|
+
receipt: ReceiptEnvelopePending | None = None
|
allowly/verify.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Offline Ed25519 receipt verification.
|
|
2
|
+
|
|
3
|
+
Wraps the receipt-format reference verifier. No network call needed —
|
|
4
|
+
fetch the workspace public keys once, cache them, verify locally forever.
|
|
5
|
+
|
|
6
|
+
from allowly.verify import fetch_keys_doc, verify_receipt, load_keys_from_json
|
|
7
|
+
|
|
8
|
+
keys_doc = fetch_keys_doc(workspace_id)
|
|
9
|
+
keys = load_keys_from_json(keys_doc)
|
|
10
|
+
verify_receipt(signed_receipt, keys, expected_workspace_id=workspace_id)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import hashlib
|
|
16
|
+
import httpx
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import quote, urlparse
|
|
22
|
+
|
|
23
|
+
# Offline verification is powered by the published reference verifier,
|
|
24
|
+
# allowly-receipt-format 3.x (import path allowly_receipt_format). It ships as an
|
|
25
|
+
# optional extra so the core SDK stays dependency-light:
|
|
26
|
+
# pip install 'allowly[verifier]'
|
|
27
|
+
def _import_verifier():
|
|
28
|
+
try:
|
|
29
|
+
from allowly_receipt_format import (
|
|
30
|
+
verify_receipt,
|
|
31
|
+
load_keys_from_json,
|
|
32
|
+
VerificationError,
|
|
33
|
+
PublicKey,
|
|
34
|
+
)
|
|
35
|
+
return verify_receipt, load_keys_from_json, VerificationError, PublicKey
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"Receipt verification requires allowly-receipt-format>=3.0.0. "
|
|
39
|
+
"Install the verifier extra: pip install 'allowly[verifier]'"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_verify_receipt, _load_keys_from_json, VerificationError, PublicKey = _import_verifier()
|
|
44
|
+
|
|
45
|
+
DEFAULT_BASE_URL = "https://api.allowly.ai"
|
|
46
|
+
DEFAULT_KEYS_DOC_CACHE_TTL_SECONDS = 300
|
|
47
|
+
_keys_doc_cache: dict[tuple[str, str | None, int], tuple[float, dict[str, Any]]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def verify_receipt(
|
|
51
|
+
receipt: dict[str, Any],
|
|
52
|
+
public_keys: list[PublicKey],
|
|
53
|
+
*,
|
|
54
|
+
expected_workspace_id: str,
|
|
55
|
+
now: datetime | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
_verify_receipt(
|
|
58
|
+
receipt,
|
|
59
|
+
public_keys,
|
|
60
|
+
now=now,
|
|
61
|
+
expected_workspace_id=expected_workspace_id,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_keys_from_json(doc: dict[str, Any]) -> list[PublicKey]:
|
|
66
|
+
try:
|
|
67
|
+
return _load_keys_from_json(doc)
|
|
68
|
+
except VerificationError:
|
|
69
|
+
raise
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise VerificationError(str(exc)) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fetch_keys_doc(
|
|
75
|
+
workspace_id: str,
|
|
76
|
+
*,
|
|
77
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
78
|
+
cache_ttl_seconds: int = DEFAULT_KEYS_DOC_CACHE_TTL_SECONDS,
|
|
79
|
+
expected_sha256: str | None = None,
|
|
80
|
+
client: httpx.Client | None = None,
|
|
81
|
+
dangerously_allow_insecure_base_url: bool = False,
|
|
82
|
+
edge_token: str | None = None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
base_url = base_url.rstrip("/")
|
|
85
|
+
url = f"{base_url}/v1/workspaces/{quote(workspace_id, safe='')}/keys"
|
|
86
|
+
parsed = urlparse(url)
|
|
87
|
+
if not parsed.netloc:
|
|
88
|
+
raise VerificationError(f"keys document URL must be valid: {url}")
|
|
89
|
+
if parsed.scheme not in {"http", "https"}:
|
|
90
|
+
raise VerificationError(f"keys document URL must use HTTP or HTTPS: {url}")
|
|
91
|
+
if parsed.scheme != "https" and not dangerously_allow_insecure_base_url:
|
|
92
|
+
raise VerificationError(f"keys document URL must use HTTPS: {url}")
|
|
93
|
+
|
|
94
|
+
cache_key = (url, expected_sha256, cache_ttl_seconds)
|
|
95
|
+
cached = _keys_doc_cache.get(cache_key)
|
|
96
|
+
now = time.time()
|
|
97
|
+
if cached and cached[0] > now:
|
|
98
|
+
return copy.deepcopy(cached[1])
|
|
99
|
+
|
|
100
|
+
owns_client = client is None
|
|
101
|
+
if client is None:
|
|
102
|
+
client = httpx.Client(timeout=10.0)
|
|
103
|
+
try:
|
|
104
|
+
request_options: dict[str, Any] = {"follow_redirects": False}
|
|
105
|
+
if edge_token is not None:
|
|
106
|
+
request_options["headers"] = {"X-Allowly-Edge-Token": edge_token}
|
|
107
|
+
resp = client.get(url, **request_options)
|
|
108
|
+
except httpx.HTTPError as exc:
|
|
109
|
+
raise VerificationError(f"failed to fetch keys document: {exc}") from exc
|
|
110
|
+
finally:
|
|
111
|
+
if owns_client:
|
|
112
|
+
client.close()
|
|
113
|
+
|
|
114
|
+
if resp.status_code != 200:
|
|
115
|
+
raise VerificationError(
|
|
116
|
+
f"failed to fetch keys document: expected HTTP 200, got {resp.status_code}"
|
|
117
|
+
)
|
|
118
|
+
if resp.url != httpx.URL(url):
|
|
119
|
+
raise VerificationError(
|
|
120
|
+
f"keys document final URL changed: got {resp.url}, want {url}"
|
|
121
|
+
)
|
|
122
|
+
body = resp.content
|
|
123
|
+
|
|
124
|
+
if expected_sha256 is not None:
|
|
125
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
126
|
+
if digest.lower() != expected_sha256.lower():
|
|
127
|
+
raise VerificationError("keys document SHA-256 hash did not match expected pin")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
doc = json.loads(body.decode("utf-8"))
|
|
131
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
132
|
+
raise VerificationError("keys document was not valid JSON") from exc
|
|
133
|
+
if isinstance(doc, dict) and doc.get("workspace_id") != workspace_id:
|
|
134
|
+
raise VerificationError(
|
|
135
|
+
f"keys document workspace_id mismatch: got {doc.get('workspace_id')!r}, want {workspace_id!r}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
load_keys_from_json(doc)
|
|
139
|
+
_keys_doc_cache[cache_key] = (now + cache_ttl_seconds, doc)
|
|
140
|
+
return copy.deepcopy(doc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def clear_keys_doc_cache() -> None:
|
|
144
|
+
_keys_doc_cache.clear()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = [
|
|
148
|
+
"verify_receipt",
|
|
149
|
+
"load_keys_from_json",
|
|
150
|
+
"fetch_keys_doc",
|
|
151
|
+
"clear_keys_doc_cache",
|
|
152
|
+
"VerificationError",
|
|
153
|
+
"PublicKey",
|
|
154
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allowly
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for the Allowly API
|
|
5
|
+
Project-URL: Repository, https://github.com/Allowly-AI/allowly-sdk-python
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: allowly-receipt-format<4.0.0,>=3.0.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
13
|
+
Requires-Dist: fastmcp<3,>=2.9; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
17
|
+
Provides-Extra: fastmcp
|
|
18
|
+
Requires-Dist: fastmcp<3,>=2.9; extra == 'fastmcp'
|
|
19
|
+
Provides-Extra: verifier
|
|
20
|
+
Requires-Dist: allowly-receipt-format<4.0.0,>=3.0.0; extra == 'verifier'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Allowly Python SDK
|
|
24
|
+
|
|
25
|
+
Async Python client for the Allowly runtime API.
|
|
26
|
+
|
|
27
|
+
MCP middleware ships inside this SDK: `pip install 'allowly[fastmcp]'`, then `from allowly.mcp import AllowlyMCPMiddleware`. In TypeScript, it lives in the separate `@allowly/mcp` package.
|
|
28
|
+
|
|
29
|
+
## Subject authorization pattern
|
|
30
|
+
|
|
31
|
+
Do not send raw user/customer PII to Allowly receipts unless you intentionally
|
|
32
|
+
want it in your audit trail. Create one authorization per subject, store the
|
|
33
|
+
returned authorization ID in your own app database, and use that ID for later checks.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
from allowly import Allowly
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def main() -> None:
|
|
43
|
+
async with Allowly(
|
|
44
|
+
api_key=os.environ["ALLOWLY_API_KEY"],
|
|
45
|
+
base_url=os.getenv("ALLOWLY_API_URL", "https://api.allowly.ai"),
|
|
46
|
+
) as allowly:
|
|
47
|
+
# Your app creates a stable internal subject ID.
|
|
48
|
+
subject_id = "subject_abc123"
|
|
49
|
+
|
|
50
|
+
# Store this in your app table, for example:
|
|
51
|
+
# allowly_authorizations(subject_id, policy_id, allowly_authorization_id, status)
|
|
52
|
+
authorization = await allowly.authorizations.create(
|
|
53
|
+
user_id=f"subject:{subject_id}",
|
|
54
|
+
policy_id="research_agent",
|
|
55
|
+
metadata={"source": "import"},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Before the agent acts, check whether this action is allowed.
|
|
59
|
+
decision = await allowly.check(
|
|
60
|
+
authorization_id=authorization.authorization_id,
|
|
61
|
+
actions=["web.search"],
|
|
62
|
+
resource=f"subject:{subject_id}",
|
|
63
|
+
context={"stage": "research"},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if decision.results["web.search"].decision != "allow":
|
|
67
|
+
raise RuntimeError("Action is not authorized")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Local development against the documented Caddy endpoint requires the edge
|
|
74
|
+
token that Cloudflare injects for public traffic. Pass it explicitly:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
Allowly(
|
|
78
|
+
api_key=os.environ["ALLOWLY_API_KEY"],
|
|
79
|
+
base_url="http://localhost:8443",
|
|
80
|
+
dangerously_allow_insecure_base_url=True,
|
|
81
|
+
edge_token=os.environ["ALLOWLY_EDGE_TOKEN"],
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The token is only sent when provided; never set it for the public API.
|
|
86
|
+
|
|
87
|
+
Inline authorization creation requires `agent_id`, `actions`, and `expires_at`.
|
|
88
|
+
Policy-based creation uses `policy_id` instead and rejects inline action or
|
|
89
|
+
decision-override fields.
|
|
90
|
+
|
|
91
|
+
Unavailable checks fail closed unless an action is explicitly mapped to
|
|
92
|
+
`"fail_open"` with `fallback_by_action`. Unmapped actions always fail closed.
|
|
93
|
+
|
|
94
|
+
For actions that need third-party approval, define the escalation rule on the
|
|
95
|
+
agent policy, create the authorization from that `policy_id`, and then resolve
|
|
96
|
+
returned escalation results with
|
|
97
|
+
`await allowly.escalations.approve(escalation_id, resolved_by="manager:123")`
|
|
98
|
+
or `reject(...)`, then re-check before running the action.
|
|
99
|
+
|
|
100
|
+
If you need lookup by email later, import `from_email` from
|
|
101
|
+
`allowly.identifiers` and store `from_email(email, pepper=APP_PII_PEPPER)`.
|
|
102
|
+
The helper trims and lowercases only, prefixes the result with `email_hmac:v1`,
|
|
103
|
+
and never sends the raw email or pepper to Allowly. Keep the pepper stable and
|
|
104
|
+
backed up; changing it changes derived user IDs. Keep raw names, emails,
|
|
105
|
+
documents, and profile URLs out of Allowly receipts unless those fields are
|
|
106
|
+
intentionally part of your audit record.
|
|
107
|
+
|
|
108
|
+
Do not add raw HTTP fallbacks in application code for APIs the SDK is missing.
|
|
109
|
+
Patch this SDK first, then use the typed client from the app. That keeps the
|
|
110
|
+
integration examples honest and makes SDK gaps visible early.
|
|
111
|
+
|
|
112
|
+
## Offline receipt verification
|
|
113
|
+
|
|
114
|
+
Install `allowly[verifier]` to verify signed receipts locally. The extra uses
|
|
115
|
+
`allowly-receipt-format>=3.0.0,<4.0.0`, which verifies receipt wire format 3 (the package major equals the wire format). `alg` and
|
|
116
|
+
`key_id` are signed top-level fields, and `signature` is the base64url string.
|
|
117
|
+
|
|
118
|
+
Key-document fetching requires HTTPS by default. For the documented local
|
|
119
|
+
Caddy endpoint only, pass `dangerously_allow_insecure_base_url=True` and its
|
|
120
|
+
`edge_token` to `fetch_keys_doc`, matching the client options above.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
allowly/__init__.py,sha256=B3bjOZjbJk_3Ozs0CQgqutAyD9JXNbjG27A60wBybLc,1353
|
|
2
|
+
allowly/client.py,sha256=j4bFrUodmj36KJfdQs8cO_D-MeHa3AgJf1PqhNmTFO0,29624
|
|
3
|
+
allowly/error.py,sha256=M9hh0JSDpVyEymBYqWG2HB-oAvKcXIhfzRsDvwJZ8CA,959
|
|
4
|
+
allowly/identifiers.py,sha256=c7Hv10F9Cl5S8PeLUHykDYobEZ2m93Z7MP5OpHWPT0M,1358
|
|
5
|
+
allowly/mcp.py,sha256=H9mf5Vl7w-oHygzSiXPFN5-YiB91v1Cg4Jf3Fyy5Kv0,4634
|
|
6
|
+
allowly/types.py,sha256=-3q9uPoihGklWjZhjD2aQsNY9AP1epZ2q7moT1Edju4,4230
|
|
7
|
+
allowly/verify.py,sha256=J8pk146a5GF5C0V9JVodu_l1jZZ8aK4qt4ssdhu-dC8,5134
|
|
8
|
+
allowly-0.2.0.dist-info/METADATA,sha256=KTA8qaMXNoQ0CF6iogkRdcZ5YwRIomDht1Z6VETbyEw,4797
|
|
9
|
+
allowly-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
allowly-0.2.0.dist-info/licenses/LICENSE,sha256=ZQy7j4VFJOfOyVDMPyC2CxlArjT6cU26htbgLC727go,1064
|
|
11
|
+
allowly-0.2.0.dist-info/RECORD,,
|