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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any