seampact-hooks 1.0.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.
- seampact_hooks-1.0.0/.gitignore +6 -0
- seampact_hooks-1.0.0/LICENSE +21 -0
- seampact_hooks-1.0.0/PKG-INFO +13 -0
- seampact_hooks-1.0.0/README.md +1 -0
- seampact_hooks-1.0.0/pyproject.toml +25 -0
- seampact_hooks-1.0.0/src/seampact/hooks/__init__.py +71 -0
- seampact_hooks-1.0.0/src/seampact/hooks/_redaction.py +39 -0
- seampact_hooks-1.0.0/src/seampact/hooks/authenticate_hook.py +11 -0
- seampact_hooks-1.0.0/src/seampact/hooks/authenticate_request.py +25 -0
- seampact_hooks-1.0.0/src/seampact/hooks/authenticate_result.py +18 -0
- seampact_hooks-1.0.0/src/seampact/hooks/decide_hook.py +11 -0
- seampact_hooks-1.0.0/src/seampact/hooks/decision.py +18 -0
- seampact_hooks-1.0.0/src/seampact/hooks/error_code_with_params.py +10 -0
- seampact_hooks-1.0.0/src/seampact/hooks/field.py +13 -0
- seampact_hooks-1.0.0/src/seampact/hooks/field_direction.py +8 -0
- seampact_hooks-1.0.0/src/seampact/hooks/idempotency_begin.py +28 -0
- seampact_hooks-1.0.0/src/seampact/hooks/idempotency_claim.py +13 -0
- seampact_hooks-1.0.0/src/seampact/hooks/idempotency_store.py +22 -0
- seampact_hooks-1.0.0/src/seampact/hooks/operation_ref.py +9 -0
- seampact_hooks-1.0.0/src/seampact/hooks/purpose.py +26 -0
- seampact_hooks-1.0.0/src/seampact/hooks/py.typed +0 -0
- seampact_hooks-1.0.0/src/seampact/hooks/request_view.py +19 -0
- seampact_hooks-1.0.0/src/seampact/hooks/requirement_kind.py +8 -0
- seampact_hooks-1.0.0/src/seampact/hooks/requirement_request.py +43 -0
- seampact_hooks-1.0.0/src/seampact/hooks/resolve_scope_hook.py +11 -0
- seampact_hooks-1.0.0/src/seampact/hooks/resolve_scope_request.py +25 -0
- seampact_hooks-1.0.0/src/seampact/hooks/resolved_scope.py +10 -0
- seampact_hooks-1.0.0/src/seampact/hooks/scope_id_source.py +18 -0
- seampact_hooks-1.0.0/src/seampact/hooks/scope_resolution.py +19 -0
- seampact_hooks-1.0.0/src/seampact/hooks/signature_encoding.py +8 -0
- seampact_hooks-1.0.0/src/seampact/hooks/signature_template.py +15 -0
- seampact_hooks-1.0.0/src/seampact/hooks/signed_application_request.py +19 -0
- seampact_hooks-1.0.0/src/seampact/hooks/signed_request.py +6 -0
- seampact_hooks-1.0.0/src/seampact/hooks/signed_template_request.py +21 -0
- seampact_hooks-1.0.0/src/seampact/hooks/stored_response.py +22 -0
- seampact_hooks-1.0.0/src/seampact/hooks/subject_ref.py +10 -0
- seampact_hooks-1.0.0/src/seampact/hooks/throttle_bucket.py +14 -0
- seampact_hooks-1.0.0/src/seampact/hooks/unexpected_error_listener.py +13 -0
- seampact_hooks-1.0.0/src/seampact/hooks/unexpected_failure.py +12 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julien Metral
|
|
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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: seampact-hooks
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The seampact hook protocols and the values they exchange, for Python apps and generated code.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
The hook protocols, the values they exchange and `Field` for Python apps, checked against the shared hooks shape.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
The hook protocols, the values they exchange and `Field` for Python apps, checked against the shared hooks shape.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "seampact-hooks"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "The seampact hook protocols and the values they exchange, for Python apps and generated code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# PEP 420: `seampact` is a namespace shared by every seampact distribution, so it has no `__init__.py`.
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/seampact"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.sdist]
|
|
25
|
+
include = ["src/seampact", "LICENSE"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The hook protocols an app implements and the values they exchange, with the shape every hooks package shares.
|
|
2
|
+
|
|
3
|
+
A union's arms live in the union's module: `seampact.hooks.decision.Allowed`, `seampact.hooks.purpose.Field`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from seampact.hooks.authenticate_hook import AuthenticateHook
|
|
7
|
+
from seampact.hooks.authenticate_request import AuthenticateRequest
|
|
8
|
+
from seampact.hooks.authenticate_result import AuthenticateResult
|
|
9
|
+
from seampact.hooks.decide_hook import DecideHook
|
|
10
|
+
from seampact.hooks.decision import Decision
|
|
11
|
+
from seampact.hooks.error_code_with_params import ErrorCodeWithParams
|
|
12
|
+
from seampact.hooks.field import Absent, Field
|
|
13
|
+
from seampact.hooks.field_direction import FieldDirection
|
|
14
|
+
from seampact.hooks.idempotency_begin import IdempotencyBegin
|
|
15
|
+
from seampact.hooks.idempotency_claim import IdempotencyClaim
|
|
16
|
+
from seampact.hooks.idempotency_store import IdempotencyStore
|
|
17
|
+
from seampact.hooks.operation_ref import OperationRef
|
|
18
|
+
from seampact.hooks.purpose import Purpose
|
|
19
|
+
from seampact.hooks.request_view import RequestView
|
|
20
|
+
from seampact.hooks.requirement_kind import RequirementKind
|
|
21
|
+
from seampact.hooks.requirement_request import RequirementRequest
|
|
22
|
+
from seampact.hooks.resolve_scope_hook import ResolveScopeHook
|
|
23
|
+
from seampact.hooks.resolve_scope_request import ResolveScopeRequest
|
|
24
|
+
from seampact.hooks.resolved_scope import ResolvedScope
|
|
25
|
+
from seampact.hooks.scope_id_source import ScopeIdSource
|
|
26
|
+
from seampact.hooks.scope_resolution import ScopeResolution
|
|
27
|
+
from seampact.hooks.signature_encoding import SignatureEncoding
|
|
28
|
+
from seampact.hooks.signature_template import SignatureTemplate
|
|
29
|
+
from seampact.hooks.signed_application_request import SignedApplicationRequest
|
|
30
|
+
from seampact.hooks.signed_request import SignedRequest
|
|
31
|
+
from seampact.hooks.signed_template_request import SignedTemplateRequest
|
|
32
|
+
from seampact.hooks.stored_response import StoredResponse
|
|
33
|
+
from seampact.hooks.subject_ref import SubjectRef
|
|
34
|
+
from seampact.hooks.throttle_bucket import ThrottleBucket
|
|
35
|
+
from seampact.hooks.unexpected_error_listener import UnexpectedErrorListener
|
|
36
|
+
from seampact.hooks.unexpected_failure import UnexpectedFailure
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Absent",
|
|
40
|
+
"AuthenticateHook",
|
|
41
|
+
"AuthenticateRequest",
|
|
42
|
+
"AuthenticateResult",
|
|
43
|
+
"DecideHook",
|
|
44
|
+
"Decision",
|
|
45
|
+
"ErrorCodeWithParams",
|
|
46
|
+
"Field",
|
|
47
|
+
"FieldDirection",
|
|
48
|
+
"IdempotencyBegin",
|
|
49
|
+
"IdempotencyClaim",
|
|
50
|
+
"IdempotencyStore",
|
|
51
|
+
"OperationRef",
|
|
52
|
+
"Purpose",
|
|
53
|
+
"RequestView",
|
|
54
|
+
"RequirementKind",
|
|
55
|
+
"RequirementRequest",
|
|
56
|
+
"ResolveScopeHook",
|
|
57
|
+
"ResolveScopeRequest",
|
|
58
|
+
"ResolvedScope",
|
|
59
|
+
"ScopeIdSource",
|
|
60
|
+
"ScopeResolution",
|
|
61
|
+
"SignatureEncoding",
|
|
62
|
+
"SignatureTemplate",
|
|
63
|
+
"SignedApplicationRequest",
|
|
64
|
+
"SignedRequest",
|
|
65
|
+
"SignedTemplateRequest",
|
|
66
|
+
"StoredResponse",
|
|
67
|
+
"SubjectRef",
|
|
68
|
+
"ThrottleBucket",
|
|
69
|
+
"UnexpectedErrorListener",
|
|
70
|
+
"UnexpectedFailure",
|
|
71
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The redacted rendering of a value that may carry a secret, so a logged or captured object never leaks it."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Any, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
REDACTED: Final = "[redacted]"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedactedRepr:
|
|
11
|
+
"""A base for the frozen dataclasses holding a credential or raw bytes: `repr`, `str`, `pprint` and `rich` print
|
|
12
|
+
`[redacted]` in place of each field named in `_REDACTED_FIELDS`. A subclass sets `repr=False` on its dataclass."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ()
|
|
15
|
+
|
|
16
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]]
|
|
17
|
+
# Set by the subclass's dataclass decorator.
|
|
18
|
+
__dataclass_fields__: ClassVar[dict[str, dataclasses.Field[Any]]]
|
|
19
|
+
|
|
20
|
+
def _rendered_fields(self) -> Iterator[tuple[str, object]]:
|
|
21
|
+
for field in dataclasses.fields(self):
|
|
22
|
+
value = getattr(self, field.name)
|
|
23
|
+
|
|
24
|
+
yield field.name, _Redacted() if field.name in self._REDACTED_FIELDS else value
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
rendered = ", ".join(f"{name}={value!r}" for name, value in self._rendered_fields())
|
|
28
|
+
|
|
29
|
+
return f"{type(self).__qualname__}({rendered})"
|
|
30
|
+
|
|
31
|
+
def __rich_repr__(self) -> Iterator[tuple[str, object]]:
|
|
32
|
+
yield from self._rendered_fields()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Redacted:
|
|
36
|
+
__slots__ = ()
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return REDACTED
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The authenticate hook: a plain `def` or an `async def` method satisfies it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from seampact.hooks.authenticate_request import AuthenticateRequest
|
|
7
|
+
from seampact.hooks.authenticate_result import AuthenticateResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticateHook(Protocol):
|
|
11
|
+
def authenticate(self, request: AuthenticateRequest, /) -> AuthenticateResult | Awaitable[AuthenticateResult]: ...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""What the authenticate hook gets: the identity, its credentials and the request; credentials never show."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
8
|
+
from seampact.hooks.operation_ref import OperationRef
|
|
9
|
+
from seampact.hooks.request_view import RequestView
|
|
10
|
+
from seampact.hooks.signed_request import SignedRequest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
14
|
+
class AuthenticateRequest(RedactedRepr):
|
|
15
|
+
"""`credentials` holds what the identity's schemes found in the request; `signature` is set for a signed one."""
|
|
16
|
+
|
|
17
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"credentials", "csrf_token"})
|
|
18
|
+
|
|
19
|
+
identity: str
|
|
20
|
+
credentials: object
|
|
21
|
+
csrf_token: str | None = None
|
|
22
|
+
signature: SignedRequest | None = None
|
|
23
|
+
request: RequestView
|
|
24
|
+
operation: OperationRef
|
|
25
|
+
extensions: Mapping[str, object]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The authenticate hook's answer: the authenticated principal, or a denial with its declared error."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.error_code_with_params import ErrorCodeWithParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class Authenticated:
|
|
10
|
+
principal: object
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class Denied:
|
|
15
|
+
deny: ErrorCodeWithParams
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
type AuthenticateResult = Authenticated | Denied
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The decide hook, which answers each right and rule: a plain `def` or an `async def` method satisfies it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from seampact.hooks.decision import Decision
|
|
7
|
+
from seampact.hooks.requirement_request import RequirementRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DecideHook(Protocol):
|
|
11
|
+
def decide(self, request: RequirementRequest, /) -> Decision | Awaitable[Decision]: ...
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The decide hook's answer: allowed, or a denial with its declared error."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.error_code_with_params import ErrorCodeWithParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class Allowed:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class Denied:
|
|
15
|
+
deny: ErrorCodeWithParams
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
type Decision = Allowed | Denied
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""A declared error code and its parameters, the answer of a hook that denies."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
8
|
+
class ErrorCodeWithParams:
|
|
9
|
+
code: str
|
|
10
|
+
params: Mapping[str, object] | None = None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""`Field`: a value that may be absent, told apart from an explicit null (`None`) by the `Absent` sentinel."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Absent(Enum):
|
|
8
|
+
"""The one value of an absent field."""
|
|
9
|
+
|
|
10
|
+
ABSENT = "absent"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
type Field[T] = T | Literal[Absent.ABSENT]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The idempotency store's answer to a claim: started, still running, a fingerprint mismatch, or a replay."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.stored_response import StoredResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class Started:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class Running:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
19
|
+
class Mismatch:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
24
|
+
class Replay:
|
|
25
|
+
response: StoredResponse
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
type IdempotencyBegin = Started | Running | Mismatch | Replay
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The claim an idempotent request makes on its key: the key, the request's fingerprint and the key's lifetime."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.operation_ref import OperationRef
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class IdempotencyClaim:
|
|
10
|
+
key: str
|
|
11
|
+
fingerprint: str
|
|
12
|
+
lifetime_ms: int
|
|
13
|
+
operation: OperationRef
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The store of idempotent requests: plain `def` or `async def` methods satisfy it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from seampact.hooks.idempotency_begin import IdempotencyBegin
|
|
7
|
+
from seampact.hooks.idempotency_claim import IdempotencyClaim
|
|
8
|
+
from seampact.hooks.stored_response import StoredResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IdempotencyStore(Protocol):
|
|
12
|
+
def begin(self, claim: IdempotencyClaim, /) -> IdempotencyBegin | Awaitable[IdempotencyBegin]:
|
|
13
|
+
"""Claims the key: started for a new key, or running, mismatch or replay for a known one."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def complete(self, key: str, response: StoredResponse, /) -> Awaitable[None] | None:
|
|
17
|
+
"""Keeps the response for the key's replays."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def abandon(self, key: str, /) -> Awaitable[None] | None:
|
|
21
|
+
"""Releases the key of a request that failed, so a retry starts again."""
|
|
22
|
+
...
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Why a requirement is checked: for the route, for one field, or for a sort key."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.field_direction import FieldDirection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class Route:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class Field:
|
|
15
|
+
"""`pointer` is the JSON pointer of the field in the body."""
|
|
16
|
+
|
|
17
|
+
pointer: str
|
|
18
|
+
direction: FieldDirection
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class SortKey:
|
|
23
|
+
key: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
type Purpose = Route | Field | SortKey
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""What a hook sees of the HTTP request; its headers never show in a repr."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
11
|
+
class RequestView(RedactedRepr):
|
|
12
|
+
"""`headers` holds every header by lower-case name, each with its values in order."""
|
|
13
|
+
|
|
14
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"headers"})
|
|
15
|
+
|
|
16
|
+
method: str
|
|
17
|
+
path: str
|
|
18
|
+
headers: Mapping[str, tuple[str, ...]]
|
|
19
|
+
client_address: str | None = None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What the decide hook gets: the requirement, the principal, the subject, the scopes and the request."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
8
|
+
from seampact.hooks.operation_ref import OperationRef
|
|
9
|
+
from seampact.hooks.purpose import Purpose
|
|
10
|
+
from seampact.hooks.request_view import RequestView
|
|
11
|
+
from seampact.hooks.requirement_kind import RequirementKind
|
|
12
|
+
from seampact.hooks.resolved_scope import ResolvedScope
|
|
13
|
+
from seampact.hooks.subject_ref import SubjectRef
|
|
14
|
+
from seampact.hooks.throttle_bucket import ThrottleBucket
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
18
|
+
class RequirementRequest(RedactedRepr):
|
|
19
|
+
"""`params` holds the use site's params as an instance of the right's or rule's generated params class, None when
|
|
20
|
+
it declares none; `scopes` are the resolved scopes, outermost first; `principal` is None for an anonymous request.
|
|
21
|
+
The rule proof is sensitive by construction, so it renders redacted."""
|
|
22
|
+
|
|
23
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"proof"})
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
26
|
+
class Extensions:
|
|
27
|
+
"""The extension values of the requirement and of the operation."""
|
|
28
|
+
|
|
29
|
+
requirement: Mapping[str, object]
|
|
30
|
+
operation: Mapping[str, object]
|
|
31
|
+
|
|
32
|
+
kind: RequirementKind
|
|
33
|
+
name: str
|
|
34
|
+
params: object | None = None
|
|
35
|
+
principal: object | None
|
|
36
|
+
subject: SubjectRef | None = None
|
|
37
|
+
scopes: tuple[ResolvedScope, ...]
|
|
38
|
+
proof: object | None = None
|
|
39
|
+
bucket: ThrottleBucket | None = None
|
|
40
|
+
purpose: Purpose
|
|
41
|
+
operation: OperationRef
|
|
42
|
+
request: RequestView
|
|
43
|
+
extensions: Extensions
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The resolve-scope hook: a plain `def` or an `async def` method satisfies it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from seampact.hooks.resolve_scope_request import ResolveScopeRequest
|
|
7
|
+
from seampact.hooks.scope_resolution import ScopeResolution
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResolveScopeHook(Protocol):
|
|
11
|
+
def resolve_scope(self, request: ResolveScopeRequest, /) -> ScopeResolution | Awaitable[ScopeResolution]: ...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""What the resolve-scope hook gets: the scope, where its id comes from, the principal and the outer scopes."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from seampact.hooks.resolved_scope import ResolvedScope
|
|
7
|
+
from seampact.hooks.scope_id_source import ScopeIdSource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
11
|
+
class ResolveScopeRequest:
|
|
12
|
+
"""`outer_scopes` are the scopes already resolved, outermost first."""
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
15
|
+
class Extensions:
|
|
16
|
+
"""The extension values of the scope and of the operation."""
|
|
17
|
+
|
|
18
|
+
scope: Mapping[str, object]
|
|
19
|
+
operation: Mapping[str, object]
|
|
20
|
+
|
|
21
|
+
scope: str
|
|
22
|
+
source: ScopeIdSource
|
|
23
|
+
principal: object | None
|
|
24
|
+
outer_scopes: tuple[ResolvedScope, ...]
|
|
25
|
+
extensions: Extensions
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Where a scope's id comes from: declared in the contract, or resolved by the application from the request."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.request_view import RequestView
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class Declared:
|
|
10
|
+
id: object
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class Custom:
|
|
15
|
+
request: RequestView
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
type ScopeIdSource = Declared | Custom
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The resolve-scope hook's answer: the resolved scope, or a denial with its declared error."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.error_code_with_params import ErrorCodeWithParams
|
|
6
|
+
from seampact.hooks.resolved_scope import ResolvedScope
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
10
|
+
class Resolved:
|
|
11
|
+
resolved: ResolvedScope
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
15
|
+
class Denied:
|
|
16
|
+
deny: ErrorCodeWithParams
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
type ScopeResolution = Resolved | Denied
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""A template signature scheme of the IR (ADR 0013): algorithm, signed payload, encoding and tolerance."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from seampact.hooks.signature_encoding import SignatureEncoding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class SignatureTemplate:
|
|
10
|
+
"""`payload` holds the `{ts}` and `{rawBody}` placeholders; `tolerance_ms` is set when it holds `{ts}`."""
|
|
11
|
+
|
|
12
|
+
algorithm: str
|
|
13
|
+
payload: str
|
|
14
|
+
encoding: SignatureEncoding
|
|
15
|
+
tolerance_ms: int | None = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""A request signed by a scheme the application verifies itself; its body, signature and headers never show."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
11
|
+
class SignedApplicationRequest(RedactedRepr):
|
|
12
|
+
"""The decoded body bytes exactly as received, the signature header, and the headers the scheme declares."""
|
|
13
|
+
|
|
14
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"raw_body", "signature_header", "headers"})
|
|
15
|
+
|
|
16
|
+
raw_body: bytes
|
|
17
|
+
signature_header: str
|
|
18
|
+
headers: Mapping[str, str]
|
|
19
|
+
key_id: str | None = None
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""A signed request: by a template scheme the verifier checks, or by one the application checks."""
|
|
2
|
+
|
|
3
|
+
from seampact.hooks.signed_application_request import SignedApplicationRequest
|
|
4
|
+
from seampact.hooks.signed_template_request import SignedTemplateRequest
|
|
5
|
+
|
|
6
|
+
type SignedRequest = SignedTemplateRequest | SignedApplicationRequest
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""A request signed by a template scheme, for the verifier; its body and signatures never show in a repr."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
7
|
+
from seampact.hooks.signature_template import SignatureTemplate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
11
|
+
class SignedTemplateRequest(RedactedRepr):
|
|
12
|
+
"""The decoded body bytes exactly as received, the signed timestamp text when the format has one, and every
|
|
13
|
+
signature the header carries, still encoded."""
|
|
14
|
+
|
|
15
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"raw_body", "candidate_signatures"})
|
|
16
|
+
|
|
17
|
+
raw_body: bytes
|
|
18
|
+
timestamp: str | None = None
|
|
19
|
+
candidate_signatures: tuple[str, ...]
|
|
20
|
+
key_id: str
|
|
21
|
+
template: SignatureTemplate
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The response an idempotency store keeps for a replay; its body and header values never show in a repr."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
10
|
+
class StoredResponse(RedactedRepr):
|
|
11
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"body"})
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True, repr=False)
|
|
14
|
+
class Header(RedactedRepr):
|
|
15
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"value"})
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
value: str
|
|
19
|
+
|
|
20
|
+
status: int
|
|
21
|
+
headers: tuple[Header, ...]
|
|
22
|
+
body: bytes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The subject a requirement is checked against: its type, its id and, when loaded, its record."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
7
|
+
class SubjectRef:
|
|
8
|
+
type: str
|
|
9
|
+
id: object
|
|
10
|
+
record: object | None = None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The throttle bucket a requirement counts against."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
7
|
+
class ThrottleBucket:
|
|
8
|
+
"""`key` is a fixed-size digest of the key values; `values` are the values of the key sources in order."""
|
|
9
|
+
|
|
10
|
+
id: str
|
|
11
|
+
key: str
|
|
12
|
+
values: tuple[object, ...]
|
|
13
|
+
limit: int | None = None
|
|
14
|
+
window_ms: int | None = None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The listener the boundary calls for each failure it answers with the api's 500: a plain `def` or an `async def`
|
|
2
|
+
satisfies it."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Awaitable
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from seampact.hooks.unexpected_failure import UnexpectedFailure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnexpectedErrorListener(Protocol):
|
|
11
|
+
def on_unexpected_error(self, failure: UnexpectedFailure, /) -> Awaitable[None] | None:
|
|
12
|
+
"""Hears of the failure, to report it; the reply is the api's 500 whatever it does."""
|
|
13
|
+
...
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""One failure the boundary answered with the api's 500: the api, the operation when one matched, a reason that holds
|
|
2
|
+
no input value, and the exception when one was raised."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
8
|
+
class UnexpectedFailure:
|
|
9
|
+
api: str
|
|
10
|
+
operation: str | None
|
|
11
|
+
reason: str
|
|
12
|
+
cause: BaseException | None = None
|