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.
@@ -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,3 @@
1
+ """Optional framework integrations for authweave-workload."""
2
+
3
+ __all__: tuple[str, ...] = ()
@@ -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
+ )