authweave-workload 7.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.
- authweave_workload/__init__.py +51 -0
- authweave_workload/delegation.py +76 -0
- authweave_workload/events.py +70 -0
- authweave_workload/integrations/__init__.py +3 -0
- authweave_workload/integrations/litestar.py +375 -0
- authweave_workload/jwks.py +215 -0
- authweave_workload/jwt.py +379 -0
- authweave_workload/lifecycle.py +344 -0
- authweave_workload/migrations/0001_postgresql.sql +66 -0
- authweave_workload/models.py +246 -0
- authweave_workload/mtls.py +167 -0
- authweave_workload/provider.py +222 -0
- authweave_workload/py.typed +0 -0
- authweave_workload/rate_limit.py +41 -0
- authweave_workload/sqlalchemy.py +483 -0
- authweave_workload/stores.py +96 -0
- authweave_workload-7.0.0.dist-info/METADATA +70 -0
- authweave_workload-7.0.0.dist-info/RECORD +20 -0
- authweave_workload-7.0.0.dist-info/WHEEL +4 -0
- authweave_workload-7.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Framework-neutral workload authentication."""
|
|
2
|
+
|
|
3
|
+
from authweave_workload.events import SecurityEvent, SecurityEventType
|
|
4
|
+
from authweave_workload.lifecycle import EventRecorder, LifecycleConflictError, WorkloadLifecycleService
|
|
5
|
+
from authweave_workload.models import (
|
|
6
|
+
CertificateMetadata,
|
|
7
|
+
CredentialStatus,
|
|
8
|
+
EntityStatus,
|
|
9
|
+
MachineCredential,
|
|
10
|
+
MachinePrincipal,
|
|
11
|
+
ResolvedMachineIdentity,
|
|
12
|
+
ServiceApplication,
|
|
13
|
+
)
|
|
14
|
+
from authweave_workload.provider import DirectMTLSPolicy, DirectMTLSProvider
|
|
15
|
+
from authweave_workload.rate_limit import WorkloadRateLimitIdentity, rate_limit_identity
|
|
16
|
+
from authweave_workload.stores import (
|
|
17
|
+
MachineCredentialStore,
|
|
18
|
+
MachinePrincipalStore,
|
|
19
|
+
ServiceApplicationStore,
|
|
20
|
+
StoreConflictError,
|
|
21
|
+
StoreUnavailableError,
|
|
22
|
+
WorkloadStore,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "7.0.0"
|
|
26
|
+
|
|
27
|
+
__all__ = (
|
|
28
|
+
"CertificateMetadata",
|
|
29
|
+
"CredentialStatus",
|
|
30
|
+
"DirectMTLSPolicy",
|
|
31
|
+
"DirectMTLSProvider",
|
|
32
|
+
"EntityStatus",
|
|
33
|
+
"EventRecorder",
|
|
34
|
+
"LifecycleConflictError",
|
|
35
|
+
"MachineCredential",
|
|
36
|
+
"MachineCredentialStore",
|
|
37
|
+
"MachinePrincipal",
|
|
38
|
+
"MachinePrincipalStore",
|
|
39
|
+
"ResolvedMachineIdentity",
|
|
40
|
+
"SecurityEvent",
|
|
41
|
+
"SecurityEventType",
|
|
42
|
+
"ServiceApplication",
|
|
43
|
+
"ServiceApplicationStore",
|
|
44
|
+
"StoreConflictError",
|
|
45
|
+
"StoreUnavailableError",
|
|
46
|
+
"WorkloadLifecycleService",
|
|
47
|
+
"WorkloadRateLimitIdentity",
|
|
48
|
+
"WorkloadStore",
|
|
49
|
+
"__version__",
|
|
50
|
+
"rate_limit_identity",
|
|
51
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Trusted RFC 8693 actor-claim mapping with bounded delegation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from authweave_core import FailureCode, PrincipalRef
|
|
9
|
+
|
|
10
|
+
_MAX_SCOPE_VALUES = 64
|
|
11
|
+
_ACTOR_KEYS = frozenset({"act", "client_id", "scope", "sub"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Delegation:
|
|
16
|
+
"""Verified actor and bounded actor chain derived from a trusted token."""
|
|
17
|
+
|
|
18
|
+
actor: PrincipalRef
|
|
19
|
+
chain: tuple[PrincipalRef, ...]
|
|
20
|
+
effective_scopes: tuple[str, ...]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def map_rfc8693_actor(
|
|
24
|
+
claim: object,
|
|
25
|
+
*,
|
|
26
|
+
issuer: str,
|
|
27
|
+
subject: PrincipalRef,
|
|
28
|
+
actor_kind: str,
|
|
29
|
+
credential_scopes: tuple[str, ...],
|
|
30
|
+
maximum_depth: int,
|
|
31
|
+
) -> Delegation | FailureCode:
|
|
32
|
+
"""Map a signed actor claim without deriving authority from metadata or kind."""
|
|
33
|
+
if maximum_depth < 1:
|
|
34
|
+
return FailureCode.INTERNAL_INVARIANT
|
|
35
|
+
if not isinstance(claim, Mapping):
|
|
36
|
+
return FailureCode.INVALID
|
|
37
|
+
chain: list[PrincipalRef] = []
|
|
38
|
+
seen = {(subject.issuer, subject.subject)}
|
|
39
|
+
effective_scopes = set(credential_scopes)
|
|
40
|
+
current: object = claim
|
|
41
|
+
while current is not None:
|
|
42
|
+
if len(chain) >= maximum_depth or not isinstance(current, Mapping) or not set(current) <= _ACTOR_KEYS:
|
|
43
|
+
return FailureCode.INVALID
|
|
44
|
+
actor_subject = current.get("sub")
|
|
45
|
+
if not isinstance(actor_subject, str) or not actor_subject:
|
|
46
|
+
return FailureCode.INVALID
|
|
47
|
+
identity = (issuer, actor_subject)
|
|
48
|
+
if identity in seen:
|
|
49
|
+
return FailureCode.INVALID
|
|
50
|
+
seen.add(identity)
|
|
51
|
+
try:
|
|
52
|
+
actor = PrincipalRef(issuer, actor_subject, actor_kind)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return FailureCode.INVALID
|
|
55
|
+
chain.append(actor)
|
|
56
|
+
actor_scopes = current.get("scope")
|
|
57
|
+
if actor_scopes is not None:
|
|
58
|
+
if not isinstance(actor_scopes, str):
|
|
59
|
+
return FailureCode.INVALID
|
|
60
|
+
parsed_scopes = tuple(actor_scopes.split())
|
|
61
|
+
if (
|
|
62
|
+
len(parsed_scopes) > _MAX_SCOPE_VALUES
|
|
63
|
+
or len(parsed_scopes) != len(set(parsed_scopes))
|
|
64
|
+
or any("*" in value for value in parsed_scopes)
|
|
65
|
+
):
|
|
66
|
+
return FailureCode.INVALID
|
|
67
|
+
effective_scopes.intersection_update(parsed_scopes)
|
|
68
|
+
current = current.get("act")
|
|
69
|
+
return Delegation(
|
|
70
|
+
actor=chain[0],
|
|
71
|
+
chain=tuple(chain),
|
|
72
|
+
effective_scopes=tuple(scope for scope in credential_scopes if scope in effective_scopes),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ("Delegation", "map_rfc8693_actor")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Typed secret-free workload security events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from authweave_core import FailureCode, PrincipalRef
|
|
12
|
+
|
|
13
|
+
_MAX_CORRELATION_ID_LENGTH = 512
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SecurityEventType(StrEnum):
|
|
17
|
+
"""Stable event classes emitted by workload lifecycle and providers."""
|
|
18
|
+
|
|
19
|
+
APPLICATION_CREATED = "application_created"
|
|
20
|
+
APPLICATION_ENABLED = "application_enabled"
|
|
21
|
+
APPLICATION_DISABLED = "application_disabled"
|
|
22
|
+
APPLICATION_METADATA_UPDATED = "application_metadata_updated"
|
|
23
|
+
PRINCIPAL_CREATED = "principal_created"
|
|
24
|
+
PRINCIPAL_ENABLED = "principal_enabled"
|
|
25
|
+
PRINCIPAL_DISABLED = "principal_disabled"
|
|
26
|
+
PRINCIPAL_METADATA_UPDATED = "principal_metadata_updated"
|
|
27
|
+
CREDENTIAL_REGISTERED = "credential_registered"
|
|
28
|
+
CREDENTIAL_ROTATION_STARTED = "credential_rotation_started"
|
|
29
|
+
CREDENTIAL_ROTATION_COMPLETED = "credential_rotation_completed"
|
|
30
|
+
CREDENTIAL_REVOKED = "credential_revoked"
|
|
31
|
+
AUTHENTICATION_SUCCEEDED = "authentication_succeeded"
|
|
32
|
+
AUTHENTICATION_FAILED = "authentication_failed"
|
|
33
|
+
PROVIDER_UNAVAILABLE = "provider_unavailable"
|
|
34
|
+
SENDER_CONSTRAINT_REJECTED = "sender_constraint_rejected"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class SecurityEvent:
|
|
39
|
+
"""One bounded event with no raw credential or certificate body."""
|
|
40
|
+
|
|
41
|
+
type: SecurityEventType
|
|
42
|
+
target_application_id: str | None = None
|
|
43
|
+
target_principal: PrincipalRef | None = None
|
|
44
|
+
credential_id: str | None = None
|
|
45
|
+
actor: PrincipalRef | None = None
|
|
46
|
+
provider: str | None = None
|
|
47
|
+
profile: str | None = None
|
|
48
|
+
reason: FailureCode | None = None
|
|
49
|
+
correlation_id: str | None = None
|
|
50
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
"""Require an aware timestamp.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If the timestamp has no timezone.
|
|
57
|
+
"""
|
|
58
|
+
if self.timestamp.utcoffset() is None:
|
|
59
|
+
msg = "event timestamp must be timezone-aware"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
if self.correlation_id is not None and (
|
|
62
|
+
not self.correlation_id
|
|
63
|
+
or self.correlation_id != self.correlation_id.strip()
|
|
64
|
+
or len(self.correlation_id) > _MAX_CORRELATION_ID_LENGTH
|
|
65
|
+
):
|
|
66
|
+
msg = "event correlation_id must be non-empty, trimmed, and at most 512 characters"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ("SecurityEvent", "SecurityEventType")
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Litestar extension for sender-constrained workload authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from importlib import import_module
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Never, cast
|
|
11
|
+
|
|
12
|
+
from authweave_core import AuthenticationContext, TlsPeerEvidence
|
|
13
|
+
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
|
14
|
+
from litestar.openapi.spec import SecurityScheme
|
|
15
|
+
|
|
16
|
+
from authweave_workload.events import SecurityEvent
|
|
17
|
+
from authweave_workload.jwt import MTLSBoundJWTProvider, TrustedIssuer
|
|
18
|
+
from authweave_workload.provider import DirectMTLSPolicy, DirectMTLSProvider
|
|
19
|
+
from litestar_auth.authentication import LitestarProviderBinding
|
|
20
|
+
from litestar_auth.extensions import (
|
|
21
|
+
EXTENSION_API_VERSION,
|
|
22
|
+
AuthExtensionRegistrationContext,
|
|
23
|
+
AuthExtensionValidationContext,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from litestar.connection import ASGIConnection
|
|
28
|
+
from litestar.handlers.base import BaseRouteHandler
|
|
29
|
+
from litestar.types import Scope
|
|
30
|
+
|
|
31
|
+
from authweave_workload.jwt import DelegationPolicy
|
|
32
|
+
|
|
33
|
+
type SecurityEventCallback = Callable[[SecurityEvent], Awaitable[None] | None]
|
|
34
|
+
type TlsPeerEvidenceFactory = Callable[[Scope], TlsPeerEvidence | None]
|
|
35
|
+
type MachineGuard = Callable[[ASGIConnection[Any, Any, Any, Any], BaseRouteHandler], None]
|
|
36
|
+
UNIX_SOCKET_PROXY = "unix"
|
|
37
|
+
_ENVOY_SHA256_HEX_LENGTH = 64
|
|
38
|
+
|
|
39
|
+
_TLS_HEADER_NAMES = (
|
|
40
|
+
b"x-auth-tls-verified",
|
|
41
|
+
b"x-auth-tls-version",
|
|
42
|
+
b"x-auth-client-cert-sha256",
|
|
43
|
+
b"x-auth-client-cert-not-before",
|
|
44
|
+
b"x-auth-client-cert-not-after",
|
|
45
|
+
b"x-auth-client-cert-trust-anchor",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class DirectMTLSProviderConfig:
|
|
51
|
+
"""One direct mTLS provider contribution."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
policy: DirectMTLSPolicy
|
|
55
|
+
event_callback: SecurityEventCallback | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class MTLSBoundJWTProviderConfig:
|
|
60
|
+
"""One external mTLS-bound access-token provider contribution."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
issuer: TrustedIssuer
|
|
64
|
+
tls_policy: DirectMTLSPolicy
|
|
65
|
+
delegation_policy: DelegationPolicy | None = None
|
|
66
|
+
event_callback: SecurityEventCallback | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class WorkloadAuthExtension:
|
|
71
|
+
"""Contribute workload providers to litestar-auth's single middleware."""
|
|
72
|
+
|
|
73
|
+
tls_peer_evidence_factory: TlsPeerEvidenceFactory
|
|
74
|
+
direct_mtls: tuple[DirectMTLSProviderConfig, ...] = ()
|
|
75
|
+
mtls_bound_jwt: tuple[MTLSBoundJWTProviderConfig, ...] = ()
|
|
76
|
+
name: str = "authweave_workload"
|
|
77
|
+
enabled: bool = True
|
|
78
|
+
requires_api: tuple[int, int] = EXTENSION_API_VERSION
|
|
79
|
+
|
|
80
|
+
def validate(self, context: AuthExtensionValidationContext) -> None:
|
|
81
|
+
"""Reject provider inventory conflicts before application wiring.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If the provider inventory is empty, duplicated, or conflicts.
|
|
85
|
+
"""
|
|
86
|
+
providers = (*self.direct_mtls, *self.mtls_bound_jwt)
|
|
87
|
+
if not providers:
|
|
88
|
+
msg = "authweave-workload extension requires at least one provider"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
if len(self.direct_mtls) > 1 or len(self.mtls_bound_jwt) > 1:
|
|
91
|
+
msg = "authweave-workload permits at most one provider for each machine profile"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
names = tuple(provider.name for provider in providers)
|
|
94
|
+
if len(names) != len(set(names)):
|
|
95
|
+
msg = "authweave-workload provider names must be unique"
|
|
96
|
+
raise ValueError(msg)
|
|
97
|
+
if set(names).intersection(context.backend_names):
|
|
98
|
+
msg = "authweave-workload provider names conflict with human providers"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
|
|
101
|
+
def register(self, context: AuthExtensionRegistrationContext) -> None:
|
|
102
|
+
"""Register typed providers, trusted TLS projection, and OpenAPI metadata."""
|
|
103
|
+
context.add_tls_peer_evidence_factory(
|
|
104
|
+
self.name,
|
|
105
|
+
lambda scope: self.tls_peer_evidence_factory(cast("Scope", scope)),
|
|
106
|
+
)
|
|
107
|
+
for settings in self.direct_mtls:
|
|
108
|
+
context.add_authentication_provider(
|
|
109
|
+
self.name,
|
|
110
|
+
name=settings.name,
|
|
111
|
+
profile=DirectMTLSProvider.profile,
|
|
112
|
+
factory=lambda session, settings=settings: _direct_binding(session, settings),
|
|
113
|
+
)
|
|
114
|
+
context.add_openapi_security_scheme(
|
|
115
|
+
self.name,
|
|
116
|
+
settings.name,
|
|
117
|
+
SecurityScheme(
|
|
118
|
+
type="mutualTLS",
|
|
119
|
+
description="TLS 1.3 mutual-TLS client certificate authentication.",
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
for settings in self.mtls_bound_jwt:
|
|
123
|
+
context.add_authentication_provider(
|
|
124
|
+
self.name,
|
|
125
|
+
name=settings.name,
|
|
126
|
+
profile=MTLSBoundJWTProvider.profile,
|
|
127
|
+
factory=lambda _session, settings=settings: _jwt_binding(settings),
|
|
128
|
+
)
|
|
129
|
+
context.add_openapi_security_scheme(
|
|
130
|
+
self.name,
|
|
131
|
+
settings.name,
|
|
132
|
+
SecurityScheme(
|
|
133
|
+
type="http",
|
|
134
|
+
scheme="Bearer",
|
|
135
|
+
bearer_format="JWT",
|
|
136
|
+
description="RFC 8705 certificate-bound access token; trusted mTLS evidence is mandatory.",
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class EnvoyTLSHeaderEvidence:
|
|
143
|
+
"""Project sanitized Envoy headers only from allowlisted proxy connections."""
|
|
144
|
+
|
|
145
|
+
proxy_addresses: frozenset[str]
|
|
146
|
+
trust_anchors: frozenset[str]
|
|
147
|
+
revocation_checked_at: Callable[[], datetime]
|
|
148
|
+
termination_boundary: str = "envoy"
|
|
149
|
+
|
|
150
|
+
def __post_init__(self) -> None:
|
|
151
|
+
"""Require an explicit proxy and trust-anchor allowlist.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError: If either allowlist is empty.
|
|
155
|
+
"""
|
|
156
|
+
if not self.proxy_addresses or not self.trust_anchors or not callable(self.revocation_checked_at):
|
|
157
|
+
msg = "Envoy TLS evidence requires proxy addresses and trust anchors"
|
|
158
|
+
raise ValueError(msg)
|
|
159
|
+
|
|
160
|
+
def __call__(self, scope: Scope) -> TlsPeerEvidence | None:
|
|
161
|
+
"""Return strict TLS evidence or reject forged/incomplete presentations.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
NotAuthorizedException: If presented TLS evidence is untrusted or malformed.
|
|
165
|
+
"""
|
|
166
|
+
values = _tls_headers(scope)
|
|
167
|
+
if not values:
|
|
168
|
+
return None
|
|
169
|
+
client = scope.get("client")
|
|
170
|
+
client_address = (
|
|
171
|
+
client[0]
|
|
172
|
+
if isinstance(client, tuple) and client and isinstance(client[0], str)
|
|
173
|
+
else UNIX_SOCKET_PROXY
|
|
174
|
+
if client is None
|
|
175
|
+
else None
|
|
176
|
+
)
|
|
177
|
+
if client_address not in self.proxy_addresses:
|
|
178
|
+
_reject_evidence("TLS client evidence is not trusted.")
|
|
179
|
+
if set(values) != set(_TLS_HEADER_NAMES) or values[b"x-auth-tls-verified"] != b"SUCCESS":
|
|
180
|
+
_reject_evidence("TLS client evidence is invalid.")
|
|
181
|
+
try:
|
|
182
|
+
trust_anchor = values[b"x-auth-client-cert-trust-anchor"].decode("ascii")
|
|
183
|
+
_require_trust_anchor(trust_anchor, self.trust_anchors)
|
|
184
|
+
return TlsPeerEvidence(
|
|
185
|
+
tls_version=values[b"x-auth-tls-version"].decode("ascii"),
|
|
186
|
+
certificate_thumbprint=_parse_envoy_thumbprint(values[b"x-auth-client-cert-sha256"]),
|
|
187
|
+
certificate_not_before=_parse_time(values[b"x-auth-client-cert-not-before"]),
|
|
188
|
+
certificate_not_after=_parse_time(values[b"x-auth-client-cert-not-after"]),
|
|
189
|
+
revocation_checked_at=self.revocation_checked_at(),
|
|
190
|
+
trust_anchor=trust_anchor,
|
|
191
|
+
termination_boundary=self.termination_boundary,
|
|
192
|
+
)
|
|
193
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
194
|
+
raise NotAuthorizedException(detail="TLS client evidence is invalid.") from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _direct_binding(session: object, settings: DirectMTLSProviderConfig) -> LitestarProviderBinding:
|
|
198
|
+
store_type = import_module("authweave_workload.sqlalchemy").SQLAlchemyWorkloadStore
|
|
199
|
+
provider = DirectMTLSProvider(
|
|
200
|
+
name=settings.name,
|
|
201
|
+
store=store_type(cast("Any", session)),
|
|
202
|
+
policy=settings.policy,
|
|
203
|
+
event_callback=settings.event_callback,
|
|
204
|
+
)
|
|
205
|
+
return LitestarProviderBinding(provider=provider, load_principal=_load_principal)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _jwt_binding(settings: MTLSBoundJWTProviderConfig) -> LitestarProviderBinding:
|
|
209
|
+
provider = MTLSBoundJWTProvider(
|
|
210
|
+
name=settings.name,
|
|
211
|
+
issuer=settings.issuer,
|
|
212
|
+
tls_policy=settings.tls_policy,
|
|
213
|
+
delegation_policy=settings.delegation_policy,
|
|
214
|
+
event_callback=settings.event_callback,
|
|
215
|
+
)
|
|
216
|
+
return LitestarProviderBinding(provider=provider, load_principal=_load_principal)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _load_principal(context: AuthenticationContext) -> object:
|
|
220
|
+
return context.subject
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _tls_headers(scope: Scope) -> dict[bytes, bytes]:
|
|
224
|
+
relevant: dict[bytes, bytes] = {}
|
|
225
|
+
for raw_name, value in scope.get("headers", ()):
|
|
226
|
+
name = raw_name.lower()
|
|
227
|
+
if name not in _TLS_HEADER_NAMES:
|
|
228
|
+
continue
|
|
229
|
+
if name in relevant:
|
|
230
|
+
raise NotAuthorizedException(detail="TLS client evidence is ambiguous.")
|
|
231
|
+
relevant[name] = value
|
|
232
|
+
return relevant
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _parse_time(value: bytes) -> datetime:
|
|
236
|
+
parsed = datetime.fromisoformat(value.decode("ascii"))
|
|
237
|
+
if parsed.utcoffset() is None:
|
|
238
|
+
raise ValueError
|
|
239
|
+
return parsed
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_envoy_thumbprint(value: bytes) -> str:
|
|
243
|
+
encoded = value.decode("ascii")
|
|
244
|
+
if len(encoded) != _ENVOY_SHA256_HEX_LENGTH:
|
|
245
|
+
raise ValueError
|
|
246
|
+
digest = bytes.fromhex(encoded)
|
|
247
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _require_trust_anchor(value: str, allowed: frozenset[str]) -> None:
|
|
251
|
+
if value not in allowed:
|
|
252
|
+
raise ValueError
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _reject_evidence(detail: str) -> Never:
|
|
256
|
+
raise NotAuthorizedException(detail=detail)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def require_machine_kind(*kinds: str) -> MachineGuard:
|
|
260
|
+
"""Build a guard requiring one explicit non-human principal kind.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
A Litestar guard.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
ValueError: If no non-human kind is supplied.
|
|
267
|
+
"""
|
|
268
|
+
required = frozenset(kinds)
|
|
269
|
+
if not required or "human" in required:
|
|
270
|
+
msg = "machine kind guard requires at least one non-human kind"
|
|
271
|
+
raise ValueError(msg)
|
|
272
|
+
|
|
273
|
+
def guard(connection: ASGIConnection[Any, Any, Any, Any], _handler: BaseRouteHandler) -> None:
|
|
274
|
+
context = _machine_context(connection)
|
|
275
|
+
if context.subject.kind not in required:
|
|
276
|
+
raise PermissionDeniedException(detail="Machine principal kind is not permitted.")
|
|
277
|
+
|
|
278
|
+
return guard
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def require_machine_scope(scope: str) -> MachineGuard:
|
|
282
|
+
"""Build a guard requiring one verified credential scope.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
A Litestar guard.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ValueError: If the scope is empty.
|
|
289
|
+
"""
|
|
290
|
+
if not scope:
|
|
291
|
+
raise ValueError
|
|
292
|
+
|
|
293
|
+
def guard(connection: ASGIConnection[Any, Any, Any, Any], _handler: BaseRouteHandler) -> None:
|
|
294
|
+
if scope not in _machine_context(connection).evidence.scopes:
|
|
295
|
+
raise PermissionDeniedException(detail="Machine credential scope is insufficient.")
|
|
296
|
+
|
|
297
|
+
return guard
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def require_machine_audience(audience: str) -> MachineGuard:
|
|
301
|
+
"""Build a guard requiring one verified audience.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
A Litestar guard.
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
ValueError: If the audience is empty.
|
|
308
|
+
"""
|
|
309
|
+
if not audience:
|
|
310
|
+
raise ValueError
|
|
311
|
+
|
|
312
|
+
def guard(connection: ASGIConnection[Any, Any, Any, Any], _handler: BaseRouteHandler) -> None:
|
|
313
|
+
if audience not in _machine_context(connection).evidence.audiences:
|
|
314
|
+
raise PermissionDeniedException(detail="Machine credential audience is not permitted.")
|
|
315
|
+
|
|
316
|
+
return guard
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def require_machine_environment(environment: str) -> MachineGuard:
|
|
320
|
+
"""Build a guard requiring one verified environment.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
A Litestar guard.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ValueError: If the environment is empty.
|
|
327
|
+
"""
|
|
328
|
+
if not environment:
|
|
329
|
+
raise ValueError
|
|
330
|
+
|
|
331
|
+
def guard(connection: ASGIConnection[Any, Any, Any, Any], _handler: BaseRouteHandler) -> None:
|
|
332
|
+
if _machine_context(connection).evidence.environment != environment:
|
|
333
|
+
raise PermissionDeniedException(detail="Machine credential environment is not permitted.")
|
|
334
|
+
|
|
335
|
+
return guard
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def require_maximum_delegation_depth(maximum_depth: int) -> MachineGuard:
|
|
339
|
+
"""Build a guard bounding the verified delegation chain.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
A Litestar guard.
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
ValueError: If the maximum depth is negative.
|
|
346
|
+
"""
|
|
347
|
+
if maximum_depth < 0:
|
|
348
|
+
raise ValueError
|
|
349
|
+
|
|
350
|
+
def guard(connection: ASGIConnection[Any, Any, Any, Any], _handler: BaseRouteHandler) -> None:
|
|
351
|
+
if len(_machine_context(connection).delegation_chain) > maximum_depth:
|
|
352
|
+
raise PermissionDeniedException(detail="Machine delegation depth is not permitted.")
|
|
353
|
+
|
|
354
|
+
return guard
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _machine_context(connection: ASGIConnection[Any, Any, Any, Any]) -> AuthenticationContext:
|
|
358
|
+
context = connection.auth
|
|
359
|
+
if not isinstance(context, AuthenticationContext) or context.subject.kind == "human":
|
|
360
|
+
raise NotAuthorizedException(detail="Machine authentication is required.")
|
|
361
|
+
return context
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
__all__ = (
|
|
365
|
+
"UNIX_SOCKET_PROXY",
|
|
366
|
+
"DirectMTLSProviderConfig",
|
|
367
|
+
"EnvoyTLSHeaderEvidence",
|
|
368
|
+
"MTLSBoundJWTProviderConfig",
|
|
369
|
+
"WorkloadAuthExtension",
|
|
370
|
+
"require_machine_audience",
|
|
371
|
+
"require_machine_environment",
|
|
372
|
+
"require_machine_kind",
|
|
373
|
+
"require_machine_scope",
|
|
374
|
+
"require_maximum_delegation_depth",
|
|
375
|
+
)
|