seampact-codegen 1.0.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.
- seampact/codegen/__init__.py +2 -0
- seampact/codegen/boot_check_hooks.py +38 -0
- seampact/codegen/contract_module.py +146 -0
- seampact/codegen/credentials_classes.py +99 -0
- seampact/codegen/handler_forms.py +132 -0
- seampact/codegen/input_schemas.py +130 -0
- seampact/codegen/kept_forms.py +522 -0
- seampact/codegen/matrix_row.py +23 -0
- seampact/codegen/names.py +146 -0
- seampact/codegen/native_docs_file.py +169 -0
- seampact/codegen/operation_types.py +425 -0
- seampact/codegen/placement_lines.py +79 -0
- seampact/codegen/printer.py +196 -0
- seampact/codegen/py.typed +0 -0
- seampact/codegen/pydantic_forms.py +53 -0
- seampact/codegen/pydantic_layout.py +178 -0
- seampact/codegen/pydantic_models.py +405 -0
- seampact/codegen/redacted_members.py +47 -0
- seampact/codegen/refusals.py +84 -0
- seampact/codegen/request_deadline.py +68 -0
- seampact/codegen/response_header_forms.py +73 -0
- seampact/codegen/restricted_sort_keys.py +18 -0
- seampact/codegen/route_helpers.py +109 -0
- seampact/codegen/routes.py +139 -0
- seampact/codegen/runtime/__init__.py +7 -0
- seampact/codegen/runtime/access_kinds.py +24 -0
- seampact/codegen/runtime/access_steps.py +520 -0
- seampact/codegen/runtime/body_reader.py +64 -0
- seampact/codegen/runtime/cache_headers.py +89 -0
- seampact/codegen/runtime/contract.py +232 -0
- seampact/codegen/runtime/cors_preflight.py +20 -0
- seampact/codegen/runtime/deadline.py +72 -0
- seampact/codegen/runtime/digests.py +98 -0
- seampact/codegen/runtime/failures.py +33 -0
- seampact/codegen/runtime/field_rights.py +193 -0
- seampact/codegen/runtime/framework_routes.py +36 -0
- seampact/codegen/runtime/framework_versions.py +91 -0
- seampact/codegen/runtime/handler_call.py +31 -0
- seampact/codegen/runtime/handler_reply.py +148 -0
- seampact/codegen/runtime/hinted_values.py +48 -0
- seampact/codegen/runtime/hook_check.py +77 -0
- seampact/codegen/runtime/idempotency.py +279 -0
- seampact/codegen/runtime/input_decoding.py +226 -0
- seampact/codegen/runtime/ir_view.py +47 -0
- seampact/codegen/runtime/native_docs.py +130 -0
- seampact/codegen/runtime/page_facts.py +33 -0
- seampact/codegen/runtime/page_reply.py +396 -0
- seampact/codegen/runtime/pattern.py +69 -0
- seampact/codegen/runtime/principal_members.py +41 -0
- seampact/codegen/runtime/public_origin.py +102 -0
- seampact/codegen/runtime/pydantic/__init__.py +165 -0
- seampact/codegen/runtime/pydantic/checked_input.py +52 -0
- seampact/codegen/runtime/pydantic/contract_input.py +183 -0
- seampact/codegen/runtime/pydantic/issues.py +142 -0
- seampact/codegen/runtime/raw_target.py +103 -0
- seampact/codegen/runtime/replies.py +144 -0
- seampact/codegen/runtime/reply_verdict.py +215 -0
- seampact/codegen/runtime/requirement_params.py +170 -0
- seampact/codegen/runtime/response_header_entries.py +226 -0
- seampact/codegen/runtime/response_headers.py +114 -0
- seampact/codegen/runtime/route_match.py +88 -0
- seampact/codegen/runtime/run_settings.py +35 -0
- seampact/codegen/runtime/signature_header.py +92 -0
- seampact/codegen/runtime/sync_driver.py +35 -0
- seampact/codegen/runtime/templates.py +132 -0
- seampact/codegen/runtime/uuid_text.py +7 -0
- seampact/codegen/runtime/value_rules.py +218 -0
- seampact/codegen/runtime/version.py +59 -0
- seampact/codegen/runtime/version_token.py +108 -0
- seampact/codegen/runtime/wire_codec.py +229 -0
- seampact/codegen/runtime/wire_values.py +260 -0
- seampact/codegen/source_lines.py +35 -0
- seampact_codegen-1.0.0.dist-info/METADATA +18 -0
- seampact_codegen-1.0.0.dist-info/RECORD +76 -0
- seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
- seampact_codegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""Steps 2 to 5 of the check order (ADR 0012 "The check order"), written once as coroutines for every Python
|
|
2
|
+
placement: authenticate with the identity's hook (and the CSRF header a cookie-only credential needs, or the signature
|
|
3
|
+
a signed route carries over its raw body, ADR 0013), resolve the
|
|
4
|
+
scopes outermost first, then ask the deciders for each requirement in declared order, a rule's proof read from the
|
|
5
|
+
body first. Each refusal is one of the operation's declared codes; a hook that answers a code the operation does not
|
|
6
|
+
declare, or a missing hook, is a failure of the boundary, answered with the api's 500."""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import ClassVar, Final, Protocol
|
|
12
|
+
|
|
13
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
14
|
+
from seampact.codegen.runtime.digests import hex_of
|
|
15
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError, ContractRefusal
|
|
16
|
+
from seampact.codegen.runtime.input_decoding import Issue, role_refusal, schema_invalid
|
|
17
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_int, ir_map, ir_maps, ir_string, ir_strings
|
|
18
|
+
from seampact.codegen.runtime.principal_members import principal_members
|
|
19
|
+
from seampact.codegen.runtime.requirement_params import requirement_params
|
|
20
|
+
from seampact.codegen.runtime.signature_header import parsed_signature_header
|
|
21
|
+
from seampact.codegen.runtime.wire_values import decoded_segment, path_value, scalar_issue
|
|
22
|
+
from seampact.hooks import (
|
|
23
|
+
AuthenticateHook,
|
|
24
|
+
AuthenticateRequest,
|
|
25
|
+
DecideHook,
|
|
26
|
+
ErrorCodeWithParams,
|
|
27
|
+
OperationRef,
|
|
28
|
+
RequestView,
|
|
29
|
+
RequirementKind,
|
|
30
|
+
RequirementRequest,
|
|
31
|
+
ResolvedScope,
|
|
32
|
+
ResolveScopeHook,
|
|
33
|
+
ResolveScopeRequest,
|
|
34
|
+
SignatureEncoding,
|
|
35
|
+
SignatureTemplate,
|
|
36
|
+
SignedTemplateRequest,
|
|
37
|
+
SubjectRef,
|
|
38
|
+
ThrottleBucket,
|
|
39
|
+
authenticate_result,
|
|
40
|
+
decision,
|
|
41
|
+
purpose,
|
|
42
|
+
scope_id_source,
|
|
43
|
+
scope_resolution,
|
|
44
|
+
)
|
|
45
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
46
|
+
|
|
47
|
+
UNAUTHENTICATED_ROLE: Final = "unauthenticated"
|
|
48
|
+
CSRF_INVALID_ROLE: Final = "csrfInvalid"
|
|
49
|
+
|
|
50
|
+
_BEARER_PREFIX: Final = "bearer "
|
|
51
|
+
_OPTIONAL_ACCESS: Final = "optional"
|
|
52
|
+
_ANONYMOUS: Final = "anonymous"
|
|
53
|
+
_COOKIE: Final = "cookie"
|
|
54
|
+
_HEADER: Final = "header"
|
|
55
|
+
_BEARER: Final = "bearer"
|
|
56
|
+
_RULE: Final = "rule"
|
|
57
|
+
_SIGNATURE: Final = "signature"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HookCaller(Protocol):
|
|
61
|
+
"""How a placement calls a hook method: FastAPI awaits an `async def` and runs a `def` in its threadpool; a sync
|
|
62
|
+
placement refuses a suspension."""
|
|
63
|
+
|
|
64
|
+
def __call__[A, T](self, method: Callable[[A], T | Awaitable[T]], argument: A, /) -> Awaitable[T]: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class CredentialsType:
|
|
69
|
+
"""The generated credentials class of one identity and its attribute per scheme id: the authenticate hook receives
|
|
70
|
+
an instance, each scheme's value None when the request does not present it."""
|
|
71
|
+
|
|
72
|
+
build: Callable[..., object]
|
|
73
|
+
attributes: Mapping[str, str]
|
|
74
|
+
|
|
75
|
+
def of(self, found: Mapping[str, str]) -> object:
|
|
76
|
+
return self.build(**{attribute: found.get(scheme) for scheme, attribute in self.attributes.items()})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
80
|
+
class AccessHooks:
|
|
81
|
+
"""The hooks one request may call, keyed by the contract's names; a placement builds them per request."""
|
|
82
|
+
|
|
83
|
+
authenticate: Mapping[str, AuthenticateHook]
|
|
84
|
+
deciders: Mapping[str, DecideHook]
|
|
85
|
+
scope_resolvers: Mapping[str, ResolveScopeHook]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True, kw_only=True, repr=False)
|
|
89
|
+
class AccessState(RedactedRepr):
|
|
90
|
+
"""What steps 2 to 5 read of one contract request and what they leave for the handler: `raw_parameters` the raw
|
|
91
|
+
path segments by parameter name, `body` the decoded tree, `raw_body` the bytes as received on a signed route,
|
|
92
|
+
`credentials_types` the generated credentials class of each identity, `principal` and `scopes` set as the steps
|
|
93
|
+
admit. `repr` shows none of the request's values."""
|
|
94
|
+
|
|
95
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset(
|
|
96
|
+
{"raw_parameters", "body", "raw_body", "principal", "scopes"}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
contract: ApiContract
|
|
100
|
+
operation_id: str
|
|
101
|
+
credentials_types: Mapping[str, CredentialsType]
|
|
102
|
+
raw_parameters: Mapping[str, str]
|
|
103
|
+
request: RequestView
|
|
104
|
+
body: object
|
|
105
|
+
raw_body: bytes | None = None
|
|
106
|
+
principal: object | None = None
|
|
107
|
+
scopes: list[ResolvedScope] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def operation(self) -> OperationRef:
|
|
111
|
+
return OperationRef(api=self.contract.api, id=self.operation_id)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def run_access_steps(state: AccessState, hooks: AccessHooks, call: HookCaller) -> None:
|
|
115
|
+
"""Raises `ContractRefusal` with the operation's code, or `BoundaryFailureError` for a missing hook or an
|
|
116
|
+
undeclared answer."""
|
|
117
|
+
await _authenticate(state, hooks, call)
|
|
118
|
+
await _resolve_scopes(state, hooks, call)
|
|
119
|
+
await _decide_requirements(state, hooks, call)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def refusal_of(state: AccessState, denial: ErrorCodeWithParams) -> ContractRefusal:
|
|
123
|
+
"""The refusal of a hook's denial, whose code the operation must declare."""
|
|
124
|
+
if not state.contract.declares(state.operation_id, denial.code):
|
|
125
|
+
raise BoundaryFailureError(
|
|
126
|
+
f"seampact: a hook denied operation '{state.operation_id}' with '{denial.code}', which the operation does "
|
|
127
|
+
"not declare; answer one of its declared codes."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
params = None if denial.params is None else dict(denial.params)
|
|
131
|
+
|
|
132
|
+
return ContractRefusal(state.contract.api, denial.code, params)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def headers_view(raw_headers: list[tuple[bytes, bytes]]) -> dict[str, tuple[str, ...]]:
|
|
136
|
+
"""Every header by lower-case name, with its values in order, as `RequestView` holds them."""
|
|
137
|
+
headers: dict[str, list[str]] = {}
|
|
138
|
+
|
|
139
|
+
for name, value in raw_headers:
|
|
140
|
+
headers.setdefault(name.decode("latin-1").lower(), []).append(value.decode("latin-1"))
|
|
141
|
+
|
|
142
|
+
return {name: tuple(values) for name, values in headers.items()}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _authenticate(state: AccessState, hooks: AccessHooks, call: HookCaller) -> None:
|
|
146
|
+
operation = state.contract.operation(state.operation_id)
|
|
147
|
+
access = ir_map(operation, "access")
|
|
148
|
+
identity = ir_string(access, "identity")
|
|
149
|
+
|
|
150
|
+
if identity is None:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
optional = ir_string(access, "kind") == _OPTIONAL_ACCESS
|
|
154
|
+
credentials, only_cookies = _credentials(state, access)
|
|
155
|
+
|
|
156
|
+
if not credentials:
|
|
157
|
+
if optional:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
raise role_refusal(state.contract, state.operation_id, UNAUTHENTICATED_ROLE)
|
|
161
|
+
|
|
162
|
+
csrf_header = ir_string(ir_map(operation, "csrf"), "header")
|
|
163
|
+
csrf_token = None if csrf_header is None else _first(state.request, csrf_header)
|
|
164
|
+
|
|
165
|
+
if csrf_header is not None and only_cookies and not csrf_token:
|
|
166
|
+
raise role_refusal(state.contract, state.operation_id, CSRF_INVALID_ROLE)
|
|
167
|
+
|
|
168
|
+
signed_scheme = ir_string(state.contract.declaration("identities", identity), "signed")
|
|
169
|
+
signature = None if signed_scheme is None else _signed_request(state, signed_scheme, credentials.get(signed_scheme))
|
|
170
|
+
hook = hooks.authenticate.get(identity)
|
|
171
|
+
|
|
172
|
+
if hook is None:
|
|
173
|
+
raise BoundaryFailureError(
|
|
174
|
+
f"seampact: the api '{state.contract.api}' has no authenticate hook for the identity '{identity}'; "
|
|
175
|
+
"register one as the target's guide says."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
credentials_type = state.credentials_types.get(identity)
|
|
179
|
+
|
|
180
|
+
if credentials_type is None:
|
|
181
|
+
raise BoundaryFailureError(
|
|
182
|
+
f"seampact: the api '{state.contract.api}' has no credentials class for the identity '{identity}'; "
|
|
183
|
+
"run seampact generate again."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
request = AuthenticateRequest(
|
|
187
|
+
identity=identity,
|
|
188
|
+
credentials=credentials_type.of(credentials),
|
|
189
|
+
csrf_token=csrf_token,
|
|
190
|
+
signature=signature,
|
|
191
|
+
request=state.request,
|
|
192
|
+
operation=state.operation,
|
|
193
|
+
extensions=extensions_of(state.contract.declaration("identities", identity)),
|
|
194
|
+
)
|
|
195
|
+
result = await call(hook.authenticate, request)
|
|
196
|
+
|
|
197
|
+
if isinstance(result, authenticate_result.Authenticated):
|
|
198
|
+
state.principal = result.principal
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if not isinstance(result, authenticate_result.Denied):
|
|
202
|
+
raise _neither(f"the authenticate hook of '{identity}'", "Authenticated", "Denied", result)
|
|
203
|
+
|
|
204
|
+
if optional and ir_string(access, "onRefused") == _ANONYMOUS:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
raise refusal_of(state, result.deny)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _signed_request(state: AccessState, scheme_id: str, header: str | None) -> SignedTemplateRequest:
|
|
211
|
+
"""A signed route's signature, parsed from its header over the raw body the input step kept; a missing or
|
|
212
|
+
malformed header is the unauthenticated refusal, and the identity's hook verifies the signature (ADR 0013)."""
|
|
213
|
+
signature = ir_map(state.contract.declaration("schemes", scheme_id), _SIGNATURE)
|
|
214
|
+
parsed = parsed_signature_header(ir_string(signature, "format") or "", header)
|
|
215
|
+
|
|
216
|
+
if parsed is None:
|
|
217
|
+
raise role_refusal(state.contract, state.operation_id, UNAUTHENTICATED_ROLE)
|
|
218
|
+
|
|
219
|
+
encoding = ir_string(signature, "encoding")
|
|
220
|
+
|
|
221
|
+
if encoding not in tuple(SignatureEncoding):
|
|
222
|
+
raise BoundaryFailureError(
|
|
223
|
+
f"seampact: the signature scheme '{scheme_id}' names the encoding {encoding!r}, which this seampact "
|
|
224
|
+
"release does not read; regenerate the api."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
template = SignatureTemplate(
|
|
228
|
+
algorithm=ir_string(signature, "algorithm") or "",
|
|
229
|
+
payload=ir_string(signature, "payload") or "",
|
|
230
|
+
encoding=SignatureEncoding(encoding),
|
|
231
|
+
tolerance_ms=ir_int(signature, "toleranceMs"),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return SignedTemplateRequest(
|
|
235
|
+
raw_body=state.raw_body or b"",
|
|
236
|
+
timestamp=parsed.timestamp,
|
|
237
|
+
candidate_signatures=parsed.candidate_signatures,
|
|
238
|
+
key_id=ir_string(ir_map(signature, "key"), "value") or "",
|
|
239
|
+
template=template,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _credentials(state: AccessState, access: IrMap) -> tuple[dict[str, str], bool]:
|
|
244
|
+
"""Each credential the operation's schemes find, by scheme id, and whether every one found is a cookie."""
|
|
245
|
+
credentials: dict[str, str] = {}
|
|
246
|
+
only_cookies = True
|
|
247
|
+
|
|
248
|
+
for scheme_id in ir_strings(access, "schemes"):
|
|
249
|
+
scheme = state.contract.declaration("schemes", scheme_id)
|
|
250
|
+
kind = ir_string(scheme, "kind")
|
|
251
|
+
name = ir_string(scheme, "name") or ""
|
|
252
|
+
|
|
253
|
+
if kind == _BEARER:
|
|
254
|
+
value = _bearer_token(state.request)
|
|
255
|
+
elif kind == _COOKIE:
|
|
256
|
+
value = _cookie(state.request, name)
|
|
257
|
+
elif kind in (_HEADER, _SIGNATURE):
|
|
258
|
+
value = _first(state.request, name)
|
|
259
|
+
else:
|
|
260
|
+
raise BoundaryFailureError(
|
|
261
|
+
f"seampact: the credential scheme kind {kind!r} of '{scheme_id}' is not one this seampact release "
|
|
262
|
+
"reads; leave the operation out with the entry's `operations` filter."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if not value:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
credentials[scheme_id] = value
|
|
269
|
+
|
|
270
|
+
if kind != _COOKIE:
|
|
271
|
+
only_cookies = False
|
|
272
|
+
|
|
273
|
+
return credentials, only_cookies
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _first(request: RequestView, name: str) -> str | None:
|
|
277
|
+
values = request.headers.get(name.lower(), ())
|
|
278
|
+
|
|
279
|
+
return values[0] if values else None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _bearer_token(request: RequestView) -> str | None:
|
|
283
|
+
authorization = _first(request, "authorization") or ""
|
|
284
|
+
|
|
285
|
+
if not authorization.lower().startswith(_BEARER_PREFIX):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
return authorization[len(_BEARER_PREFIX) :].strip() or None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _cookie(request: RequestView, name: str) -> str | None:
|
|
292
|
+
for header in request.headers.get(_COOKIE, ()):
|
|
293
|
+
for pair in header.split(";"):
|
|
294
|
+
key, separator, value = pair.strip().partition("=")
|
|
295
|
+
|
|
296
|
+
if separator and key == name:
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _resolve_scopes(state: AccessState, hooks: AccessHooks, call: HookCaller) -> None:
|
|
303
|
+
operation = state.contract.operation(state.operation_id)
|
|
304
|
+
|
|
305
|
+
for entry in ir_maps(operation, "scopes"):
|
|
306
|
+
scope = ir_string(entry, "scope") or ""
|
|
307
|
+
declared = state.contract.declaration("scopes", scope)
|
|
308
|
+
source_name = ir_string(ir_map(entry, "source"), "name") or ""
|
|
309
|
+
scope_id = _checked_path_value(state, source_name, ir_map(declared, "idSchema"))
|
|
310
|
+
resolver = hooks.scope_resolvers.get(scope)
|
|
311
|
+
|
|
312
|
+
if resolver is None:
|
|
313
|
+
raise BoundaryFailureError(
|
|
314
|
+
f"seampact: the api '{state.contract.api}' has no scope resolver for the scope '{scope}'; register one "
|
|
315
|
+
"as the target's guide says."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
request = ResolveScopeRequest(
|
|
319
|
+
scope=scope,
|
|
320
|
+
source=scope_id_source.Declared(id=scope_id),
|
|
321
|
+
principal=state.principal,
|
|
322
|
+
outer_scopes=tuple(state.scopes),
|
|
323
|
+
extensions=ResolveScopeRequest.Extensions(
|
|
324
|
+
scope=extensions_of(declared), operation=extensions_of(operation)
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
resolution = await call(resolver.resolve_scope, request)
|
|
328
|
+
|
|
329
|
+
if isinstance(resolution, scope_resolution.Denied):
|
|
330
|
+
raise refusal_of(state, resolution.deny)
|
|
331
|
+
|
|
332
|
+
if not isinstance(resolution, scope_resolution.Resolved):
|
|
333
|
+
raise _neither(f"the scope resolver of '{scope}'", "Resolved", "Denied", resolution)
|
|
334
|
+
|
|
335
|
+
if resolution.resolved.scope != scope:
|
|
336
|
+
raise BoundaryFailureError(
|
|
337
|
+
f"seampact: the scope resolver of '{scope}' answered the scope '{resolution.resolved.scope}'; return a "
|
|
338
|
+
f"ResolvedScope named '{scope}'."
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
state.scopes.append(resolution.resolved)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
async def _decide_requirements(state: AccessState, hooks: AccessHooks, call: HookCaller) -> None:
|
|
345
|
+
operation = state.contract.operation(state.operation_id)
|
|
346
|
+
|
|
347
|
+
for requirement in ir_maps(operation, "requires"):
|
|
348
|
+
is_rule = ir_string(requirement, "kind") == _RULE
|
|
349
|
+
name = ir_string(requirement, _RULE if is_rule else "right") or ""
|
|
350
|
+
kind = RequirementKind.RULE if is_rule else RequirementKind.RIGHT
|
|
351
|
+
request = RequirementRequest(
|
|
352
|
+
kind=kind,
|
|
353
|
+
name=name,
|
|
354
|
+
params=requirement_params(state.contract, kind, name, requirement),
|
|
355
|
+
principal=state.principal,
|
|
356
|
+
subject=subject_of(state, ir_map(requirement, "subject"), name),
|
|
357
|
+
scopes=tuple(state.scopes),
|
|
358
|
+
proof=_proof(state, name) if is_rule else None,
|
|
359
|
+
bucket=_throttle_bucket(state, ir_string(requirement, "bucket")),
|
|
360
|
+
purpose=purpose.Route(),
|
|
361
|
+
operation=state.operation,
|
|
362
|
+
request=state.request,
|
|
363
|
+
extensions=RequirementRequest.Extensions(
|
|
364
|
+
requirement=extensions_of(requirement), operation=extensions_of(operation)
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
answer = await decided(hooks, call, request)
|
|
368
|
+
|
|
369
|
+
if isinstance(answer, decision.Denied):
|
|
370
|
+
raise refusal_of(state, answer.deny)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def decided(hooks: AccessHooks, call: HookCaller, request: RequirementRequest) -> decision.Decision:
|
|
374
|
+
"""The decider's answer to `request`; a missing decider or an answer that is neither class fails the boundary."""
|
|
375
|
+
decider = hooks.deciders.get(request.name)
|
|
376
|
+
|
|
377
|
+
if decider is None:
|
|
378
|
+
raise BoundaryFailureError(
|
|
379
|
+
f"seampact: the api '{request.operation.api}' has no decider for '{request.name}'; register one as the "
|
|
380
|
+
"target's guide says."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
answer = await call(decider.decide, request)
|
|
384
|
+
|
|
385
|
+
if not isinstance(answer, decision.Allowed | decision.Denied):
|
|
386
|
+
raise _neither(f"the decider of '{request.name}'", "Allowed", "Denied", answer)
|
|
387
|
+
|
|
388
|
+
return answer
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def subject_type(contract: ApiContract, right: str) -> str:
|
|
392
|
+
"""The subject type a right declares: its scope, or its schema type's dotted name."""
|
|
393
|
+
declared = ir_map(contract.declaration("rights", right), "subject")
|
|
394
|
+
|
|
395
|
+
return ir_string(declared, "scope") or ".".join(ir_strings(ir_map(declared, "type"), "segments"))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def subject_of(state: AccessState, subject: IrMap, right: str) -> SubjectRef | None:
|
|
399
|
+
source = ir_map(subject, "source")
|
|
400
|
+
|
|
401
|
+
if not source:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
source_name = ir_string(source, "name") or ""
|
|
405
|
+
|
|
406
|
+
if ir_string(source, "in") == "scope":
|
|
407
|
+
resolved = next((scope for scope in state.scopes if scope.scope == source_name), None)
|
|
408
|
+
|
|
409
|
+
return None if resolved is None else SubjectRef(type=source_name, id=resolved.id)
|
|
410
|
+
|
|
411
|
+
declared = ir_map(state.contract.declaration("rights", right), "subject")
|
|
412
|
+
|
|
413
|
+
return SubjectRef(
|
|
414
|
+
type=subject_type(state.contract, right),
|
|
415
|
+
id=_checked_path_value(state, source_name, ir_map(declared, "idSchema")),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _proof(state: AccessState, rule: str) -> object:
|
|
420
|
+
"""The body member a rule reads its proof from, checked first: an issue there is the `schemaInvalid` refusal
|
|
421
|
+
before the decider runs. A body that is absent or not an object gives no proof; step 6 refuses it."""
|
|
422
|
+
proof = ir_map(state.contract.declaration("rules", rule), "proof")
|
|
423
|
+
name = ir_string(proof, "name")
|
|
424
|
+
|
|
425
|
+
if name is None or not isinstance(state.body, dict):
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
if name not in state.body:
|
|
429
|
+
raise schema_invalid(state.contract, state.operation_id, [Issue("body", f"/{name}", "required")])
|
|
430
|
+
|
|
431
|
+
value: object = state.body[name]
|
|
432
|
+
rule_name = scalar_issue(ir_map(proof, "schema"), value)
|
|
433
|
+
|
|
434
|
+
if rule_name is not None:
|
|
435
|
+
raise schema_invalid(state.contract, state.operation_id, [Issue("body", f"/{name}", rule_name)])
|
|
436
|
+
|
|
437
|
+
return value
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _throttle_bucket(state: AccessState, bucket_id: str | None) -> ThrottleBucket | None:
|
|
441
|
+
"""The bucket a throttle rule counts in: its key is the SHA-256 hex of `[api, bucket, values]` as every target
|
|
442
|
+
writes it, so a counter shared across targets counts one client once."""
|
|
443
|
+
if bucket_id is None:
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
bucket = state.contract.declaration("buckets", bucket_id)
|
|
447
|
+
values = tuple(_bucket_key_value(state, source) for source in ir_maps(bucket, "key"))
|
|
448
|
+
|
|
449
|
+
return ThrottleBucket(
|
|
450
|
+
id=bucket_id,
|
|
451
|
+
key=hex_of([state.contract.api, bucket_id, list(values)]),
|
|
452
|
+
values=values,
|
|
453
|
+
limit=ir_int(bucket, "limit"),
|
|
454
|
+
window_ms=ir_int(bucket, "windowMs"),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _bucket_key_value(state: AccessState, source: IrMap) -> object:
|
|
459
|
+
name = ir_string(source, "name") or ""
|
|
460
|
+
where = ir_string(source, "in")
|
|
461
|
+
|
|
462
|
+
if where == "path":
|
|
463
|
+
params = ir_maps(ir_map(state.contract.operation(state.operation_id), "params"), "path")
|
|
464
|
+
declared = next((param for param in params if ir_string(param, "name") == name), None)
|
|
465
|
+
|
|
466
|
+
if declared is None:
|
|
467
|
+
raise BoundaryFailureError(
|
|
468
|
+
f"seampact: a bucket of operation '{state.operation_id}' reads the path param '{name}', which the "
|
|
469
|
+
"operation does not declare; regenerate the api."
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
return _checked_path_value(state, name, ir_map(declared, "type"))
|
|
473
|
+
|
|
474
|
+
if where == "scope":
|
|
475
|
+
return next((scope.id for scope in state.scopes if scope.scope == name), None)
|
|
476
|
+
|
|
477
|
+
if where == "principal":
|
|
478
|
+
return _principal_member(state, name)
|
|
479
|
+
|
|
480
|
+
raise BoundaryFailureError(f"seampact: a bucket key read from {where!r} is not one the boundary reads.")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _principal_member(state: AccessState, wire_name: str) -> object:
|
|
484
|
+
"""A principal member by its wire name, read through the principal schema's member table."""
|
|
485
|
+
members = principal_members(state.contract, state.operation_id, state.principal)
|
|
486
|
+
|
|
487
|
+
if members is None:
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
if wire_name not in members:
|
|
491
|
+
raise BoundaryFailureError(
|
|
492
|
+
f"seampact: a bucket of operation '{state.operation_id}' is keyed by the principal member '{wire_name}', "
|
|
493
|
+
"which the identity's principal does not declare; regenerate the api."
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
return members[wire_name]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _checked_path_value(state: AccessState, name: str, type_use: IrMap) -> object:
|
|
500
|
+
"""A path parameter that a scope, a subject or a bucket reads: decoded and checked by its type before any hook
|
|
501
|
+
sees it."""
|
|
502
|
+
raw = state.raw_parameters.get(name, "")
|
|
503
|
+
value = path_value(raw, type_use)
|
|
504
|
+
rule_name = "type" if decoded_segment(raw) is None else scalar_issue(type_use, value)
|
|
505
|
+
|
|
506
|
+
if rule_name is not None:
|
|
507
|
+
raise schema_invalid(state.contract, state.operation_id, [Issue("path", f"/{name}", rule_name)])
|
|
508
|
+
|
|
509
|
+
return value
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _neither(hook: str, admitted: str, denied: str, answer: object) -> BoundaryFailureError:
|
|
513
|
+
return BoundaryFailureError(
|
|
514
|
+
f"seampact: {hook} answered a {type(answer).__qualname__}, neither {admitted} nor {denied}; return one of them."
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def extensions_of(declaration: IrMap) -> Mapping[str, object]:
|
|
519
|
+
"""A deep copy: a hook that changes what it got never changes the contract later requests read."""
|
|
520
|
+
return copy.deepcopy(dict(ir_map(declaration, "extensions")))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Step 1's bounded body read: a declared `Content-Length` above the route's limit is refused before reading; the
|
|
2
|
+
chunks are read as they arrive, `gzip` and `deflate` decoded with `zlib`, and refused as soon as the encoded or the
|
|
3
|
+
decoded bytes pass the limit; another content coding is refused, and a corrupt compressed body is the client's."""
|
|
4
|
+
|
|
5
|
+
import zlib
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
PAYLOAD_TOO_LARGE_ROLE: Final = "payloadTooLarge"
|
|
11
|
+
UNSUPPORTED_MEDIA_TYPE_ROLE: Final = "unsupportedMediaType"
|
|
12
|
+
SCHEMA_INVALID_ROLE: Final = "schemaInvalid"
|
|
13
|
+
|
|
14
|
+
_IDENTITY_CODINGS: Final = frozenset({"", "identity"})
|
|
15
|
+
_WINDOWS: Final = {"gzip": 16 + zlib.MAX_WBITS, "x-gzip": 16 + zlib.MAX_WBITS, "deflate": zlib.MAX_WBITS}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
19
|
+
class BodyRead:
|
|
20
|
+
"""The decoded bytes, or the role of the refusal; `repr` never shows the bytes."""
|
|
21
|
+
|
|
22
|
+
data: bytes
|
|
23
|
+
refused_role: str | None = None
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"BodyRead(data=[redacted], refused_role={self.refused_role!r})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def read_body(
|
|
30
|
+
chunks: AsyncIterator[bytes], declared_length: str | None, content_encoding: str | None, limit_bytes: int
|
|
31
|
+
) -> BodyRead:
|
|
32
|
+
if declared_length is not None and declared_length.isdigit() and int(declared_length) > limit_bytes:
|
|
33
|
+
return BodyRead(b"", PAYLOAD_TOO_LARGE_ROLE)
|
|
34
|
+
|
|
35
|
+
coding = (content_encoding or "").strip().lower()
|
|
36
|
+
|
|
37
|
+
if coding not in _IDENTITY_CODINGS and coding not in _WINDOWS:
|
|
38
|
+
return BodyRead(b"", UNSUPPORTED_MEDIA_TYPE_ROLE)
|
|
39
|
+
|
|
40
|
+
inflater = zlib.decompressobj(_WINDOWS[coding]) if coding in _WINDOWS else None
|
|
41
|
+
received = 0
|
|
42
|
+
data = bytearray()
|
|
43
|
+
|
|
44
|
+
async for chunk in chunks:
|
|
45
|
+
received += len(chunk)
|
|
46
|
+
|
|
47
|
+
if received > limit_bytes:
|
|
48
|
+
return BodyRead(b"", PAYLOAD_TOO_LARGE_ROLE)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
# One byte past the limit is enough to refuse, so a small bomb never inflates in full.
|
|
52
|
+
decoded = chunk if inflater is None else inflater.decompress(chunk, limit_bytes - len(data) + 1)
|
|
53
|
+
except zlib.error:
|
|
54
|
+
return BodyRead(b"", SCHEMA_INVALID_ROLE)
|
|
55
|
+
|
|
56
|
+
data.extend(decoded)
|
|
57
|
+
|
|
58
|
+
if len(data) > limit_bytes:
|
|
59
|
+
return BodyRead(b"", PAYLOAD_TOO_LARGE_ROLE)
|
|
60
|
+
|
|
61
|
+
if inflater is not None and not inflater.eof and received > 0:
|
|
62
|
+
return BodyRead(b"", SCHEMA_INVALID_ROLE)
|
|
63
|
+
|
|
64
|
+
return BodyRead(bytes(data))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The cache headers of a success the boundary writes (ADR 0012 step 10): `Cache-Control` by caller kind, `Vary`, and
|
|
2
|
+
the strong ETag over the body bytes, with its 304 when `If-None-Match` names it. The ETag is the SHA-256 base64url of
|
|
3
|
+
the body as every target writes it, so a client's tag stays valid across them; an item versioned by `If-Match` keeps
|
|
4
|
+
the version's tag instead."""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
11
|
+
from seampact.codegen.runtime.ir_view import ir_map, ir_maps, ir_string, ir_strings, ir_true
|
|
12
|
+
from seampact.codegen.runtime.replies import CACHE_CONTROL, CONTENT_TYPE, NO_STORE, Reply
|
|
13
|
+
|
|
14
|
+
NOT_MODIFIED: Final = 304
|
|
15
|
+
ETAG: Final = "etag"
|
|
16
|
+
VARY: Final = "vary"
|
|
17
|
+
|
|
18
|
+
_WEAK_ETAG_PREFIX: Final = "W/"
|
|
19
|
+
_ANY_TAG: Final = "*"
|
|
20
|
+
_ANONYMOUS: Final = "anonymous"
|
|
21
|
+
_IDENTIFIED: Final = "identified"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def strong_etag(body: bytes) -> str:
|
|
25
|
+
"""The quoted SHA-256 base64url of the body bytes, without padding."""
|
|
26
|
+
digest = base64.urlsafe_b64encode(hashlib.sha256(body).digest()).rstrip(b"=").decode("ascii")
|
|
27
|
+
|
|
28
|
+
return f'"{digest}"'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def with_cache_headers(
|
|
32
|
+
contract: ApiContract, operation_id: str, reply: Reply, principal: object | None, if_none_match: str | None
|
|
33
|
+
) -> Reply:
|
|
34
|
+
"""`reply` with the operation's cache headers, or the ETag's 304, with the same headers and no body, when
|
|
35
|
+
`if_none_match` names it; `reply` itself when the operation declares no cache. An authenticated caller (a
|
|
36
|
+
`principal`) always gets the private directives."""
|
|
37
|
+
cache = ir_map(contract.operation(operation_id), "cache")
|
|
38
|
+
|
|
39
|
+
if not cache:
|
|
40
|
+
return reply
|
|
41
|
+
|
|
42
|
+
added: list[tuple[str, str]] = []
|
|
43
|
+
cache_control = NO_STORE if ir_true(cache, "noStore") else _cache_control(cache, principal)
|
|
44
|
+
|
|
45
|
+
if cache_control is not None:
|
|
46
|
+
added.append((CACHE_CONTROL, cache_control))
|
|
47
|
+
|
|
48
|
+
vary = ir_strings(cache, "vary")
|
|
49
|
+
|
|
50
|
+
if vary:
|
|
51
|
+
added.append((VARY, ", ".join(vary)))
|
|
52
|
+
|
|
53
|
+
# On a route versioned by `If-Match`, `cache.etag` uses the version's tag the reply already carries.
|
|
54
|
+
etag = (reply.header(ETAG) or strong_etag(reply.body)) if ir_true(cache, "etag") else None
|
|
55
|
+
|
|
56
|
+
if etag is not None:
|
|
57
|
+
added.append((ETAG, etag))
|
|
58
|
+
|
|
59
|
+
written = {name for name, _ in added}
|
|
60
|
+
kept = [(name, value) for name, value in reply.headers if name not in written]
|
|
61
|
+
|
|
62
|
+
if etag is None or not _if_none_match_holds(if_none_match, etag):
|
|
63
|
+
return Reply(reply.status, (*kept, *added), reply.body)
|
|
64
|
+
|
|
65
|
+
without_content_type = [(name, value) for name, value in kept if name != CONTENT_TYPE]
|
|
66
|
+
|
|
67
|
+
return Reply(NOT_MODIFIED, (*without_content_type, *added), b"")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cache_control(cache: object, principal: object | None) -> str | None:
|
|
71
|
+
caller = _ANONYMOUS if principal is None else _IDENTIFIED
|
|
72
|
+
|
|
73
|
+
return next(
|
|
74
|
+
(
|
|
75
|
+
ir_string(entry, "cacheControl")
|
|
76
|
+
for entry in ir_maps(cache, "perCallerKind")
|
|
77
|
+
if entry.get("caller") == caller
|
|
78
|
+
),
|
|
79
|
+
None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _if_none_match_holds(if_none_match: str | None, etag: str) -> bool:
|
|
84
|
+
if if_none_match is None:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
tags = (tag.strip() for tag in if_none_match.split(","))
|
|
88
|
+
|
|
89
|
+
return any(tag in (_ANY_TAG, etag, _WEAK_ETAG_PREFIX + etag) for tag in tags)
|