authmate 0.1.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.
authmate/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """AuthMate's phase 0.1 contract-preview API."""
2
+
3
+ from .errors import (
4
+ AuthMateClosedError,
5
+ AuthMateConfigurationError,
6
+ AuthMateError,
7
+ AuthMateUnavailableError,
8
+ AuthorizationDeniedError,
9
+ InvalidAuthorizationRequestError,
10
+ ProviderContractError,
11
+ ProviderUnavailableError,
12
+ SecretClosedError,
13
+ )
14
+ from .models import (
15
+ AccessContext,
16
+ AuditEvent,
17
+ AuditOutcome,
18
+ AuthorizationDecision,
19
+ CredentialRef,
20
+ DecisionReason,
21
+ PrincipalKind,
22
+ PrincipalRecord,
23
+ PrincipalRef,
24
+ ResourceRef,
25
+ SecretReference,
26
+ validate_action,
27
+ )
28
+ from .secrets import SecretValue
29
+ from .service import AuthMate
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "AccessContext",
35
+ "AuthMate",
36
+ "AuthMateClosedError",
37
+ "AuthMateConfigurationError",
38
+ "AuthMateError",
39
+ "AuthMateUnavailableError",
40
+ "AuditEvent",
41
+ "AuditOutcome",
42
+ "AuthorizationDecision",
43
+ "AuthorizationDeniedError",
44
+ "CredentialRef",
45
+ "DecisionReason",
46
+ "InvalidAuthorizationRequestError",
47
+ "PrincipalKind",
48
+ "PrincipalRecord",
49
+ "PrincipalRef",
50
+ "ProviderContractError",
51
+ "ProviderUnavailableError",
52
+ "ResourceRef",
53
+ "SecretClosedError",
54
+ "SecretReference",
55
+ "SecretValue",
56
+ "validate_action",
57
+ "__version__",
58
+ ]
authmate/errors.py ADDED
@@ -0,0 +1,75 @@
1
+ """Sanitized AuthMate exception types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import AuthorizationDecision
6
+
7
+
8
+ class AuthMateError(Exception):
9
+ """Base exception with a stable code and constant safe message."""
10
+
11
+ code = "authmate_error"
12
+ safe_message = "AuthMate error"
13
+
14
+ def __init__(self, *, decision: AuthorizationDecision | None = None) -> None:
15
+ super().__init__(self.safe_message)
16
+ self.decision = decision
17
+
18
+ def __str__(self) -> str:
19
+ return self.safe_message
20
+
21
+
22
+ class InvalidAuthorizationRequestError(AuthMateError):
23
+ code = "invalid_authorization_request"
24
+ safe_message = "invalid authorization request"
25
+
26
+
27
+ class AuthorizationDeniedError(AuthMateError):
28
+ code = "authorization_denied"
29
+ safe_message = "authorization denied"
30
+
31
+ def __init__(self, decision: AuthorizationDecision) -> None:
32
+ super().__init__(decision=decision)
33
+
34
+
35
+ class AuthMateUnavailableError(AuthMateError):
36
+ code = "authmate_unavailable"
37
+ safe_message = "authorization service unavailable"
38
+
39
+
40
+ class ProviderUnavailableError(AuthMateUnavailableError):
41
+ code = "provider_unavailable"
42
+ safe_message = "provider unavailable"
43
+
44
+
45
+ class ProviderContractError(AuthMateUnavailableError):
46
+ code = "provider_contract_violation"
47
+ safe_message = "provider contract violation"
48
+
49
+
50
+ class AuthMateConfigurationError(AuthMateError):
51
+ code = "authmate_configuration_error"
52
+ safe_message = "invalid AuthMate integration configuration"
53
+
54
+
55
+ class AuthMateClosedError(AuthMateError):
56
+ code = "authmate_closed"
57
+ safe_message = "AuthMate is closed"
58
+
59
+
60
+ class SecretClosedError(AuthMateError):
61
+ code = "secret_closed"
62
+ safe_message = "secret value is closed"
63
+
64
+
65
+ __all__ = [
66
+ "AuthMateClosedError",
67
+ "AuthMateConfigurationError",
68
+ "AuthMateError",
69
+ "AuthMateUnavailableError",
70
+ "AuthorizationDeniedError",
71
+ "InvalidAuthorizationRequestError",
72
+ "ProviderContractError",
73
+ "ProviderUnavailableError",
74
+ "SecretClosedError",
75
+ ]
authmate/fastapi.py ADDED
@@ -0,0 +1,110 @@
1
+ """FastAPI dependency adaptation without owning authentication."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Annotated, Any
5
+
6
+ from fastapi import Depends, HTTPException
7
+
8
+ from .errors import (
9
+ AuthMateConfigurationError,
10
+ AuthMateUnavailableError,
11
+ AuthorizationDeniedError,
12
+ )
13
+ from .models import AccessContext, AuthorizationDecision, ResourceRef, validate_action
14
+ from .service import AuthMate
15
+
16
+ ContextDependency = Callable[..., AccessContext | Awaitable[AccessContext]]
17
+ ResourceDependency = Callable[..., ResourceRef | Awaitable[ResourceRef]]
18
+
19
+
20
+ class AuthMateSecurity:
21
+ """Create FastAPI dependencies backed by an existing AuthMate facade."""
22
+
23
+ def __init__(self, authmate: AuthMate, *, context_dependency: ContextDependency) -> None:
24
+ if not isinstance(authmate, AuthMate) or not callable(context_dependency):
25
+ raise AuthMateConfigurationError()
26
+ self._authmate = authmate
27
+ self._context_dependency = context_dependency
28
+
29
+ def require(
30
+ self,
31
+ action: str,
32
+ *,
33
+ resource_dependency: ResourceDependency | None = None,
34
+ ) -> Callable[..., Awaitable[AuthorizationDecision]]:
35
+ """Build a dependency that delegates to AuthMate.require exactly once."""
36
+
37
+ try:
38
+ validated_action = validate_action(action)
39
+ except (TypeError, ValueError) as exc:
40
+ raise AuthMateConfigurationError() from exc
41
+
42
+ if resource_dependency is not None and not callable(resource_dependency):
43
+ raise AuthMateConfigurationError()
44
+
45
+ if resource_dependency is None:
46
+
47
+ async def dependency(
48
+ context: Annotated[AccessContext, Depends(self._context_dependency)],
49
+ ) -> AuthorizationDecision:
50
+ verified_context = self._require_context(context)
51
+ return await self._enforce(
52
+ context=verified_context,
53
+ action=validated_action,
54
+ resource=None,
55
+ )
56
+
57
+ return dependency
58
+
59
+ async def dependency_with_resource(
60
+ context: Annotated[AccessContext, Depends(self._context_dependency)],
61
+ resource: Annotated[ResourceRef, Depends(resource_dependency)],
62
+ ) -> AuthorizationDecision:
63
+ verified_context = self._require_context(context)
64
+ verified_resource = self._require_resource(resource)
65
+ return await self._enforce(
66
+ context=verified_context,
67
+ action=validated_action,
68
+ resource=verified_resource,
69
+ )
70
+
71
+ return dependency_with_resource
72
+
73
+ @staticmethod
74
+ def _require_context(value: Any) -> AccessContext:
75
+ if not isinstance(value, AccessContext):
76
+ raise AuthMateConfigurationError()
77
+ return value
78
+
79
+ @staticmethod
80
+ def _require_resource(value: Any) -> ResourceRef:
81
+ if not isinstance(value, ResourceRef):
82
+ raise AuthMateConfigurationError()
83
+ return value
84
+
85
+ async def _enforce(
86
+ self,
87
+ *,
88
+ context: AccessContext,
89
+ action: str,
90
+ resource: ResourceRef | None,
91
+ ) -> AuthorizationDecision:
92
+ try:
93
+ return await self._authmate.require(
94
+ context=context,
95
+ action=action,
96
+ resource=resource,
97
+ )
98
+ except AuthorizationDeniedError:
99
+ raise HTTPException(
100
+ status_code=403,
101
+ detail={"code": "authorization_denied"},
102
+ ) from None
103
+ except AuthMateUnavailableError:
104
+ raise HTTPException(
105
+ status_code=503,
106
+ detail={"code": "authmate_unavailable"},
107
+ ) from None
108
+
109
+
110
+ __all__ = ["AuthMateSecurity", "ContextDependency", "ResourceDependency"]
authmate/models.py ADDED
@@ -0,0 +1,336 @@
1
+ """Immutable, validated values shared by AuthMate consumers and providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from enum import StrEnum
8
+ from typing import Annotated, Any
9
+ from uuid import UUID
10
+
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Field,
15
+ StrictBool,
16
+ StrictInt,
17
+ StrictStr,
18
+ StringConstraints,
19
+ TypeAdapter,
20
+ field_validator,
21
+ model_validator,
22
+ )
23
+
24
+ _DOTTED_NAME_PATTERN = r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$"
25
+ _REASON_PATTERN = r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$"
26
+ _CORRELATION_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
27
+ _PROVIDER_PATTERN = r"^[a-z][a-z0-9_-]{0,62}$"
28
+
29
+ ActionName = Annotated[
30
+ StrictStr,
31
+ StringConstraints(pattern=_DOTTED_NAME_PATTERN, min_length=3, max_length=200),
32
+ ]
33
+ ResourceType = Annotated[
34
+ StrictStr,
35
+ StringConstraints(pattern=_DOTTED_NAME_PATTERN, min_length=3, max_length=100),
36
+ ]
37
+ EventType = Annotated[
38
+ StrictStr,
39
+ StringConstraints(pattern=_DOTTED_NAME_PATTERN, min_length=3, max_length=200),
40
+ ]
41
+ ProviderAlias = Annotated[
42
+ StrictStr,
43
+ StringConstraints(pattern=_PROVIDER_PATTERN, min_length=1, max_length=63),
44
+ ]
45
+ CorrelationId = Annotated[
46
+ StrictStr,
47
+ StringConstraints(pattern=_CORRELATION_PATTERN, min_length=1, max_length=128),
48
+ ]
49
+
50
+ ACTION_ADAPTER = TypeAdapter(ActionName)
51
+
52
+
53
+ class AuthMateModel(BaseModel):
54
+ """Base configuration for public immutable contract values."""
55
+
56
+ model_config = ConfigDict(
57
+ extra="forbid",
58
+ frozen=True,
59
+ str_strip_whitespace=False,
60
+ validate_assignment=True,
61
+ )
62
+
63
+
64
+ def _reject_control_or_outer_whitespace(value: str, *, field_name: str) -> str:
65
+ if value != value.strip() or any(ord(char) < 32 or ord(char) == 127 for char in value):
66
+ raise ValueError(f"{field_name} contains invalid whitespace or control characters")
67
+ return value
68
+
69
+
70
+ def _require_non_nil(value: UUID, *, field_name: str) -> UUID:
71
+ if value.int == 0:
72
+ raise ValueError(f"{field_name} must not be nil")
73
+ return value
74
+
75
+
76
+ def _reject_non_json_uuid(value: object, *, field_name: str) -> object:
77
+ if not isinstance(value, (UUID, str)):
78
+ raise ValueError(f"{field_name} must be a UUID or string")
79
+ return value
80
+
81
+
82
+ def _reject_non_json_enum(value: object, *, field_name: str) -> object:
83
+ if not isinstance(value, (str, StrEnum)):
84
+ raise ValueError(f"{field_name} must be a string")
85
+ return value
86
+
87
+
88
+ def _reject_non_json_datetime(value: object, *, field_name: str) -> object:
89
+ if not isinstance(value, (datetime, str)):
90
+ raise ValueError(f"{field_name} must be a datetime or string")
91
+ return value
92
+
93
+
94
+ def _as_utc(value: datetime, *, field_name: str) -> datetime:
95
+ if value.tzinfo is None or value.utcoffset() is None:
96
+ raise ValueError(f"{field_name} must be timezone-aware")
97
+ return value.astimezone(UTC)
98
+
99
+
100
+ class PrincipalKind(StrEnum):
101
+ """Kinds of principals known by the contract preview."""
102
+
103
+ USER = "user"
104
+ SERVICE_ACCOUNT = "service_account"
105
+
106
+
107
+ class PrincipalRef(AuthMateModel):
108
+ """An immutable principal identifier; it is not authentication proof."""
109
+
110
+ id: UUID
111
+ kind: PrincipalKind
112
+
113
+ @field_validator("id", mode="before")
114
+ @classmethod
115
+ def reject_coercive_id(cls, value: object) -> object:
116
+ return _reject_non_json_uuid(value, field_name="id")
117
+
118
+ @field_validator("id")
119
+ @classmethod
120
+ def validate_id(cls, value: UUID) -> UUID:
121
+ return _require_non_nil(value, field_name="id")
122
+
123
+ @field_validator("kind", mode="before")
124
+ @classmethod
125
+ def reject_coercive_kind(cls, value: object) -> object:
126
+ return _reject_non_json_enum(value, field_name="kind")
127
+
128
+
129
+ class PrincipalRecord(AuthMateModel):
130
+ """Current provider-owned principal state used for an authorization check."""
131
+
132
+ ref: PrincipalRef
133
+ display_name: StrictStr = Field(min_length=1, max_length=200)
134
+ enabled: StrictBool
135
+ expires_at: datetime | None = None
136
+ version: StrictInt = Field(default=0, ge=0)
137
+
138
+ @field_validator("display_name")
139
+ @classmethod
140
+ def validate_display_name(cls, value: str) -> str:
141
+ return _reject_control_or_outer_whitespace(value, field_name="display_name")
142
+
143
+ @field_validator("expires_at")
144
+ @classmethod
145
+ def normalize_expiry(cls, value: datetime | None) -> datetime | None:
146
+ return None if value is None else _as_utc(value, field_name="expires_at")
147
+
148
+ @field_validator("expires_at", mode="before")
149
+ @classmethod
150
+ def reject_coercive_expiry(cls, value: object) -> object:
151
+ if value is None:
152
+ return None
153
+ return _reject_non_json_datetime(value, field_name="expires_at")
154
+
155
+
156
+ class ResourceRef(AuthMateModel):
157
+ """Exact consumer resource identity supplied by a trusted consumer lookup."""
158
+
159
+ type: ResourceType
160
+ id: StrictStr = Field(min_length=1, max_length=255)
161
+
162
+ @field_validator("id")
163
+ @classmethod
164
+ def validate_id(cls, value: str) -> str:
165
+ return _reject_control_or_outer_whitespace(value, field_name="resource id")
166
+
167
+
168
+ class AccessContext(AuthMateModel):
169
+ """Verified actor context supplied by a trusted host integration."""
170
+
171
+ actor: PrincipalRef
172
+ effective: PrincipalRef | None = None
173
+ correlation_id: CorrelationId | None = None
174
+
175
+ @model_validator(mode="after")
176
+ def validate_distinct_effective(self) -> AccessContext:
177
+ if self.effective is not None and self.effective == self.actor:
178
+ raise ValueError("effective must be omitted for a direct actor")
179
+ return self
180
+
181
+
182
+ class DecisionReason(StrEnum):
183
+ """Stable reasons attached to authorization decisions."""
184
+
185
+ ALLOWED = "allowed"
186
+ DENIED = "denied"
187
+ PRINCIPAL_NOT_FOUND = "principal_not_found"
188
+ PRINCIPAL_DISABLED = "principal_disabled"
189
+ PRINCIPAL_EXPIRED = "principal_expired"
190
+ DELEGATION_NOT_SUPPORTED = "delegation_not_supported"
191
+ PROVIDER_UNAVAILABLE = "provider_unavailable"
192
+ PROVIDER_CONTRACT_VIOLATION = "provider_contract_violation"
193
+
194
+
195
+ class AuthorizationDecision(AuthMateModel):
196
+ """A single authorization evaluation; never a reusable capability."""
197
+
198
+ allowed: StrictBool
199
+ reason: DecisionReason
200
+ action: ActionName
201
+ resource: ResourceRef | None = None
202
+ policy_revision: StrictStr | None = Field(default=None, min_length=1, max_length=128)
203
+
204
+ @field_validator("reason", mode="before")
205
+ @classmethod
206
+ def reject_coercive_reason(cls, value: object) -> object:
207
+ return _reject_non_json_enum(value, field_name="reason")
208
+
209
+ @field_validator("policy_revision")
210
+ @classmethod
211
+ def validate_policy_revision(cls, value: str | None) -> str | None:
212
+ return (
213
+ None
214
+ if value is None
215
+ else _reject_control_or_outer_whitespace(value, field_name="policy_revision")
216
+ )
217
+
218
+ @model_validator(mode="after")
219
+ def validate_allowed_reason(self) -> AuthorizationDecision:
220
+ if self.allowed and self.reason is not DecisionReason.ALLOWED:
221
+ raise ValueError("allowed decisions must use reason 'allowed'")
222
+ if not self.allowed and self.reason is DecisionReason.ALLOWED:
223
+ raise ValueError("denied decisions cannot use reason 'allowed'")
224
+ return self
225
+
226
+
227
+ class CredentialRef(AuthMateModel):
228
+ """Opaque credential identifier reserved for later resolver integration."""
229
+
230
+ id: UUID
231
+
232
+ @field_validator("id", mode="before")
233
+ @classmethod
234
+ def reject_coercive_id(cls, value: object) -> object:
235
+ return _reject_non_json_uuid(value, field_name="id")
236
+
237
+ @field_validator("id")
238
+ @classmethod
239
+ def validate_id(cls, value: UUID) -> UUID:
240
+ return _require_non_nil(value, field_name="id")
241
+
242
+
243
+ class SecretReference(AuthMateModel):
244
+ """Provider alias and opaque key; it is never resolved in phase 0.1."""
245
+
246
+ provider: ProviderAlias
247
+ key: StrictStr = Field(min_length=1, max_length=255)
248
+
249
+ @field_validator("key")
250
+ @classmethod
251
+ def validate_key(cls, value: str) -> str:
252
+ return _reject_control_or_outer_whitespace(value, field_name="secret key")
253
+
254
+
255
+ class AuditOutcome(StrEnum):
256
+ """Outcome vocabulary for the preview audit protocol."""
257
+
258
+ SUCCEEDED = "succeeded"
259
+ DENIED = "denied"
260
+ FAILED = "failed"
261
+
262
+
263
+ class AuditEvent(AuthMateModel):
264
+ """Bounded event value for the unwired phase 0.1 audit protocol."""
265
+
266
+ id: UUID
267
+ event_type: EventType
268
+ occurred_at: datetime
269
+ outcome: AuditOutcome
270
+ actor: PrincipalRef | None = None
271
+ effective: PrincipalRef | None = None
272
+ action: ActionName | None = None
273
+ resource: ResourceRef | None = None
274
+ reason_code: StrictStr | None = Field(default=None, min_length=1, max_length=100)
275
+ correlation_id: CorrelationId | None = None
276
+
277
+ @field_validator("id", mode="before")
278
+ @classmethod
279
+ def reject_coercive_id(cls, value: object) -> object:
280
+ return _reject_non_json_uuid(value, field_name="id")
281
+
282
+ @field_validator("id")
283
+ @classmethod
284
+ def validate_id(cls, value: UUID) -> UUID:
285
+ return _require_non_nil(value, field_name="id")
286
+
287
+ @field_validator("outcome", mode="before")
288
+ @classmethod
289
+ def reject_coercive_outcome(cls, value: object) -> object:
290
+ return _reject_non_json_enum(value, field_name="outcome")
291
+
292
+ @field_validator("occurred_at", mode="before")
293
+ @classmethod
294
+ def reject_coercive_occurred_at(cls, value: object) -> object:
295
+ return _reject_non_json_datetime(value, field_name="occurred_at")
296
+
297
+ @field_validator("occurred_at")
298
+ @classmethod
299
+ def normalize_occurred_at(cls, value: datetime) -> datetime:
300
+ return _as_utc(value, field_name="occurred_at")
301
+
302
+ @field_validator("reason_code")
303
+ @classmethod
304
+ def validate_reason_code(cls, value: str | None) -> str | None:
305
+ if value is None:
306
+ return None
307
+ _reject_control_or_outer_whitespace(value, field_name="reason_code")
308
+ if not re.fullmatch(_REASON_PATTERN, value):
309
+ raise ValueError("reason_code has an invalid format")
310
+ return value
311
+
312
+
313
+ def validate_action(value: Any) -> str:
314
+ """Validate an action at a service/dependency boundary."""
315
+
316
+ return ACTION_ADAPTER.validate_python(value)
317
+
318
+
319
+ __all__ = [
320
+ "AccessContext",
321
+ "ActionName",
322
+ "AuditEvent",
323
+ "AuditOutcome",
324
+ "AuthorizationDecision",
325
+ "CredentialRef",
326
+ "DecisionReason",
327
+ "EventType",
328
+ "PrincipalKind",
329
+ "PrincipalRecord",
330
+ "PrincipalRef",
331
+ "ProviderAlias",
332
+ "ResourceRef",
333
+ "ResourceType",
334
+ "SecretReference",
335
+ "validate_action",
336
+ ]
authmate/protocols.py ADDED
@@ -0,0 +1,78 @@
1
+ """Provider-neutral extension protocols for AuthMate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import AbstractAsyncContextManager as AsyncContextManager
6
+ from typing import Any, Protocol
7
+
8
+ from .models import (
9
+ AccessContext,
10
+ AuditEvent,
11
+ AuthorizationDecision,
12
+ CredentialRef,
13
+ PrincipalRecord,
14
+ PrincipalRef,
15
+ ResourceRef,
16
+ SecretReference,
17
+ )
18
+ from .secrets import SecretValue
19
+
20
+
21
+ class PrincipalProvider(Protocol):
22
+ """Load current principal state from a host-owned source."""
23
+
24
+ async def get_principal(self, ref: PrincipalRef) -> PrincipalRecord | None: ...
25
+
26
+ async def aclose(self) -> None: ...
27
+
28
+
29
+ class AuthorizationProvider(Protocol):
30
+ """Evaluate one authorization request for a current principal."""
31
+
32
+ async def authorize(
33
+ self,
34
+ *,
35
+ principal: PrincipalRecord,
36
+ action: str,
37
+ resource: ResourceRef | None,
38
+ ) -> AuthorizationDecision: ...
39
+
40
+ async def aclose(self) -> None: ...
41
+
42
+
43
+ class CredentialResolver(Protocol):
44
+ """Future credential-to-secret boundary; unwired in phase 0.1."""
45
+
46
+ def resolve(
47
+ self,
48
+ *,
49
+ context: AccessContext,
50
+ credential: CredentialRef,
51
+ ) -> AsyncContextManager[SecretValue[Any]]: ...
52
+
53
+ async def aclose(self) -> None: ...
54
+
55
+
56
+ class SecretProvider(Protocol):
57
+ """Future provider-specific secret boundary; unwired in phase 0.1."""
58
+
59
+ def resolve(self, reference: SecretReference) -> AsyncContextManager[SecretValue[Any]]: ...
60
+
61
+ async def aclose(self) -> None: ...
62
+
63
+
64
+ class AuditSink(Protocol):
65
+ """Future audit delivery boundary; unwired in phase 0.1."""
66
+
67
+ async def record(self, event: AuditEvent) -> None: ...
68
+
69
+ async def aclose(self) -> None: ...
70
+
71
+
72
+ __all__ = [
73
+ "AuditSink",
74
+ "AuthorizationProvider",
75
+ "CredentialResolver",
76
+ "PrincipalProvider",
77
+ "SecretProvider",
78
+ ]
authmate/py.typed ADDED
File without changes
authmate/secrets.py ADDED
@@ -0,0 +1,57 @@
1
+ """Redacted, explicitly closable secret values for protocol testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Generic, NoReturn, TypeVar, cast
6
+
7
+ from .errors import SecretClosedError
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class SecretValue(Generic[T]):
13
+ """A non-serializable wrapper with deterministic redaction and cleanup."""
14
+
15
+ __slots__ = ("_closed", "_value")
16
+ _REDACTED = "SecretValue(<redacted>)"
17
+
18
+ def __init__(self, value: T) -> None:
19
+ self._value: T | None = value
20
+ self._closed = False
21
+
22
+ def reveal(self) -> T:
23
+ """Return the wrapped value until the lease is closed."""
24
+
25
+ if self._closed:
26
+ raise SecretClosedError()
27
+ return cast(T, self._value)
28
+
29
+ def close(self) -> None:
30
+ """Drop AuthMate's reference; repeated closes are harmless."""
31
+
32
+ self._value = None
33
+ self._closed = True
34
+
35
+ def __enter__(self) -> SecretValue[T]:
36
+ return self
37
+
38
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
39
+ self.close()
40
+
41
+ async def __aenter__(self) -> SecretValue[T]:
42
+ return self
43
+
44
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
45
+ self.close()
46
+
47
+ def __repr__(self) -> str:
48
+ return self._REDACTED
49
+
50
+ def __str__(self) -> str:
51
+ return self._REDACTED
52
+
53
+ def __reduce__(self) -> NoReturn:
54
+ raise TypeError("SecretValue cannot be serialized")
55
+
56
+
57
+ __all__ = ["SecretValue"]
authmate/service.py ADDED
@@ -0,0 +1,256 @@
1
+ """The phase 0.1 authorization service facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ from collections.abc import Awaitable, Callable
8
+ from datetime import UTC, datetime
9
+ from typing import TypeVar
10
+
11
+ from pydantic import ValidationError
12
+
13
+ from .errors import (
14
+ AuthMateClosedError,
15
+ AuthMateUnavailableError,
16
+ AuthorizationDeniedError,
17
+ InvalidAuthorizationRequestError,
18
+ )
19
+ from .models import (
20
+ AccessContext,
21
+ AuthorizationDecision,
22
+ DecisionReason,
23
+ PrincipalRecord,
24
+ ResourceRef,
25
+ validate_action,
26
+ )
27
+ from .protocols import AuthorizationProvider, PrincipalProvider
28
+
29
+ T = TypeVar("T")
30
+
31
+
32
+ class AuthMate:
33
+ """Authorize direct, non-HTTP calls through one provider-neutral service path."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ principal_provider: PrincipalProvider,
39
+ authorization_provider: AuthorizationProvider,
40
+ provider_timeout_seconds: float = 5.0,
41
+ ) -> None:
42
+ if isinstance(provider_timeout_seconds, bool) or not isinstance(
43
+ provider_timeout_seconds, (int, float)
44
+ ):
45
+ raise ValueError("provider timeout must be a finite number")
46
+ if not math.isfinite(float(provider_timeout_seconds)) or not (
47
+ 0.01 <= float(provider_timeout_seconds) <= 60.0
48
+ ):
49
+ raise ValueError("provider timeout must be between 0.01 and 60 seconds")
50
+ self._principal_provider = principal_provider
51
+ self._authorization_provider = authorization_provider
52
+ self._timeout = float(provider_timeout_seconds)
53
+ self._condition = asyncio.Condition()
54
+ self._state = "open"
55
+ self._in_flight = 0
56
+ self._shutdown_task: asyncio.Task[None] | None = None
57
+
58
+ async def authorize(
59
+ self,
60
+ *,
61
+ context: AccessContext,
62
+ action: str,
63
+ resource: ResourceRef | None = None,
64
+ ) -> AuthorizationDecision:
65
+ """Return a single authorization decision and never turn failure into allow."""
66
+
67
+ await self._enter_operation()
68
+ try:
69
+ return await self._authorize_inner(context=context, action=action, resource=resource)
70
+ finally:
71
+ await self._exit_operation()
72
+
73
+ async def can(
74
+ self,
75
+ *,
76
+ context: AccessContext,
77
+ action: str,
78
+ resource: ResourceRef | None = None,
79
+ ) -> bool:
80
+ """Return only whether the current request is allowed."""
81
+
82
+ decision = await self.authorize(context=context, action=action, resource=resource)
83
+ return decision.allowed
84
+
85
+ async def require(
86
+ self,
87
+ *,
88
+ context: AccessContext,
89
+ action: str,
90
+ resource: ResourceRef | None = None,
91
+ ) -> AuthorizationDecision:
92
+ """Return an allow decision or raise a sanitized domain error."""
93
+
94
+ decision = await self.authorize(context=context, action=action, resource=resource)
95
+ if decision.allowed:
96
+ return decision
97
+ if decision.reason in {
98
+ DecisionReason.PROVIDER_UNAVAILABLE,
99
+ DecisionReason.PROVIDER_CONTRACT_VIOLATION,
100
+ }:
101
+ raise AuthMateUnavailableError(decision=decision)
102
+ raise AuthorizationDeniedError(decision)
103
+
104
+ async def aclose(self) -> None:
105
+ """Drain entered calls and close owned providers once in reverse order."""
106
+
107
+ async with self._condition:
108
+ if self._shutdown_task is None:
109
+ self._state = "closing"
110
+ self._shutdown_task = asyncio.create_task(self._shutdown())
111
+ task = self._shutdown_task
112
+ await asyncio.shield(task)
113
+
114
+ async def _authorize_inner(
115
+ self,
116
+ *,
117
+ context: AccessContext,
118
+ action: str,
119
+ resource: ResourceRef | None,
120
+ ) -> AuthorizationDecision:
121
+ if not isinstance(context, AccessContext):
122
+ raise InvalidAuthorizationRequestError()
123
+ if resource is not None and not isinstance(resource, ResourceRef):
124
+ raise InvalidAuthorizationRequestError()
125
+ try:
126
+ valid_action = validate_action(action)
127
+ except (ValidationError, TypeError, ValueError) as exc:
128
+ raise InvalidAuthorizationRequestError() from exc
129
+
130
+ if context.effective is not None:
131
+ return self._deny(valid_action, resource, DecisionReason.DELEGATION_NOT_SUPPORTED)
132
+
133
+ try:
134
+ record = await self._call_provider(
135
+ lambda: self._principal_provider.get_principal(context.actor),
136
+ )
137
+ except _ProviderUnavailable:
138
+ return self._deny(valid_action, resource, DecisionReason.PROVIDER_UNAVAILABLE)
139
+
140
+ if record is None:
141
+ return self._deny(valid_action, resource, DecisionReason.PRINCIPAL_NOT_FOUND)
142
+ if not isinstance(record, PrincipalRecord):
143
+ return self._deny(
144
+ valid_action,
145
+ resource,
146
+ DecisionReason.PROVIDER_CONTRACT_VIOLATION,
147
+ )
148
+ try:
149
+ record = PrincipalRecord.model_validate(record.model_dump(mode="python"))
150
+ except Exception as exc:
151
+ del exc
152
+ return self._deny(valid_action, resource, DecisionReason.PROVIDER_CONTRACT_VIOLATION)
153
+ if record.ref != context.actor:
154
+ return self._deny(
155
+ valid_action,
156
+ resource,
157
+ DecisionReason.PROVIDER_CONTRACT_VIOLATION,
158
+ )
159
+ now = datetime.now(UTC)
160
+ if not record.enabled:
161
+ return self._deny(valid_action, resource, DecisionReason.PRINCIPAL_DISABLED)
162
+ if record.expires_at is not None and record.expires_at <= now:
163
+ return self._deny(valid_action, resource, DecisionReason.PRINCIPAL_EXPIRED)
164
+
165
+ try:
166
+ decision = await self._call_provider(
167
+ lambda: self._authorization_provider.authorize(
168
+ principal=record, action=valid_action, resource=resource
169
+ ),
170
+ )
171
+ except _ProviderUnavailable:
172
+ return self._deny(valid_action, resource, DecisionReason.PROVIDER_UNAVAILABLE)
173
+
174
+ if not isinstance(decision, AuthorizationDecision):
175
+ return self._deny(
176
+ valid_action,
177
+ resource,
178
+ DecisionReason.PROVIDER_CONTRACT_VIOLATION,
179
+ )
180
+ try:
181
+ decision = AuthorizationDecision.model_validate(decision.model_dump(mode="python"))
182
+ except Exception as exc:
183
+ del exc
184
+ return self._deny(valid_action, resource, DecisionReason.PROVIDER_CONTRACT_VIOLATION)
185
+ if decision.action != valid_action or decision.resource != resource:
186
+ return self._deny(
187
+ valid_action,
188
+ resource,
189
+ DecisionReason.PROVIDER_CONTRACT_VIOLATION,
190
+ )
191
+ return decision
192
+
193
+ async def _call_provider(self, operation: Callable[[], Awaitable[T]]) -> T:
194
+ try:
195
+ awaitable = operation()
196
+ async with asyncio.timeout(self._timeout):
197
+ return await awaitable
198
+ except asyncio.CancelledError:
199
+ raise
200
+ except Exception as exc:
201
+ raise _ProviderUnavailable() from exc
202
+
203
+ @staticmethod
204
+ def _deny(
205
+ action: str,
206
+ resource: ResourceRef | None,
207
+ reason: DecisionReason,
208
+ ) -> AuthorizationDecision:
209
+ return AuthorizationDecision(
210
+ allowed=False,
211
+ reason=reason,
212
+ action=action,
213
+ resource=resource,
214
+ )
215
+
216
+ async def _enter_operation(self) -> None:
217
+ async with self._condition:
218
+ if self._state != "open":
219
+ raise AuthMateClosedError()
220
+ self._in_flight += 1
221
+
222
+ async def _exit_operation(self) -> None:
223
+ async with self._condition:
224
+ self._in_flight -= 1
225
+ if self._state == "closing" and self._in_flight == 0:
226
+ self._condition.notify_all()
227
+
228
+ async def _shutdown(self) -> None:
229
+ async with self._condition:
230
+ while self._in_flight:
231
+ await self._condition.wait()
232
+
233
+ errors: list[Exception] = []
234
+ seen: set[int] = set()
235
+ for provider in (self._authorization_provider, self._principal_provider):
236
+ provider_id = id(provider)
237
+ if provider_id in seen:
238
+ continue
239
+ seen.add(provider_id)
240
+ try:
241
+ await provider.aclose()
242
+ except Exception as exc:
243
+ errors.append(exc)
244
+
245
+ async with self._condition:
246
+ self._state = "closed"
247
+ self._condition.notify_all()
248
+ if errors:
249
+ raise ExceptionGroup("AuthMate provider cleanup failed", errors)
250
+
251
+
252
+ class _ProviderUnavailable(Exception):
253
+ """Private sentinel that prevents provider text from reaching public errors."""
254
+
255
+
256
+ __all__ = ["AuthMate"]
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: authmate
3
+ Version: 0.1.0
4
+ Summary: FastAPI-native identity and authorization contracts
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Framework :: FastAPI
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: <3.15,>=3.11
17
+ Requires-Dist: fastapi<1,>=0.140
18
+ Requires-Dist: pydantic<3,>=2.12
19
+ Description-Content-Type: text/markdown
20
+
21
+ # AuthMate
22
+
23
+ AuthMate is a provider-neutral authorization boundary for Python services. Version
24
+ 0.1.0 is a stateless contract preview: it validates shared values, coordinates
25
+ principal and authorization providers, and supplies a small FastAPI adapter. It
26
+ does not authenticate requests, persist data, implement RBAC, manage credentials,
27
+ or provide a production-readiness guarantee.
28
+
29
+ > Independent by default, composable by contract.
30
+
31
+ ## Status
32
+
33
+ The phase 0.1 runtime is implemented under `src/authmate`. Read the
34
+ [quickstart](docs/quickstart.md) and [planning index](docs/plans/README.md) for the
35
+ public contract and release boundary.
36
+
37
+ ## Supported runtime
38
+
39
+ Python 3.11 through 3.14. Install this checkout with `uv sync --group dev`.
40
+ AuthMate is distributed under the [MIT License](LICENSE).
41
+
42
+ ## Quick start
43
+
44
+ Providers are host-owned and implement the protocols in `authmate.protocols`.
45
+ The service calls the principal provider first, then the authorization provider
46
+ only for an enabled, non-expired principal.
47
+
48
+ ```python
49
+ from uuid import uuid4
50
+ from authmate import (
51
+ AccessContext,
52
+ AuthMate,
53
+ AuthorizationDecision,
54
+ PrincipalKind,
55
+ PrincipalRecord,
56
+ PrincipalRef,
57
+ )
58
+
59
+ actor = PrincipalRef(id=uuid4(), kind=PrincipalKind.USER)
60
+
61
+
62
+ class Principals:
63
+ async def get_principal(self, ref):
64
+ return PrincipalRecord(ref=ref, display_name="Ada", enabled=True)
65
+
66
+ async def aclose(self):
67
+ pass
68
+
69
+
70
+ class Policy:
71
+ async def authorize(self, *, principal, action, resource):
72
+ return AuthorizationDecision(
73
+ allowed=True, reason="allowed", action=action, resource=resource
74
+ )
75
+
76
+ async def aclose(self):
77
+ pass
78
+
79
+
80
+ async def check_request():
81
+ service = AuthMate(principal_provider=Principals(), authorization_provider=Policy())
82
+ try:
83
+ return await service.require(context=AccessContext(actor=actor), action="report.read")
84
+ finally:
85
+ await service.aclose()
86
+ ```
87
+
88
+ See [the phase 0.1 quickstart](docs/quickstart.md) for FastAPI integration,
89
+ provider contracts, lifecycle behavior, and deliberate non-goals.
90
+
91
+ ## Development
92
+
93
+ ```sh
94
+ uv sync --frozen --group dev
95
+ uv run ruff format --check .
96
+ uv run ruff check .
97
+ uv run mypy --strict src/authmate tests
98
+ uv run pytest --cov=authmate --cov-branch --cov-fail-under=95
99
+ uv build
100
+ uv run twine check dist/*
101
+ ```
102
+
103
+ ## Planned MVP
104
+
105
+ - Users, operator provisioning, opaque SQL-backed browser sessions, and revocation.
106
+ - Service accounts, revocable API tokens, and generic delegated identity.
107
+ - Exact RBAC scopes, FastAPI dependencies, and Python service APIs.
108
+ - Credential metadata, exact secret-use grants, and approved environment references.
109
+ - Durable SQL audit, rate limiting, CSRF protection, and explicit recovery procedures.
110
+ - Typed public contracts, controlled model/provider extensions, and reviewed migrations.
111
+
112
+ PostgreSQL is the production reference; SQLite supports local development. No Redis,
113
+ broker, external identity service, or external secret manager is required. Encrypted
114
+ SQL secret storage, federation, tenancy, and richer extensions have later release gates.
115
+
116
+ ## Architecture principles
117
+
118
+ - AuthMate owns its security semantics and public contracts; consumers adapt to them.
119
+ - Mandatory service checks remain authoritative with custom providers.
120
+ - Pydantic public contracts are separate from SQLModel/SQLAlchemy persistence.
121
+ - FastAPI composition uses explicit DI, lifespan, security, and OpenAPI integration.
122
+ - Core requires SQL-backed state, not process-local caches or additional services.
123
+
124
+ Hedron, ShuETL, and other applications may build optional adapters against AuthMate.
125
+ Core contains no consumer workflow records, callbacks, domain imports, or release
126
+ dependencies. Consumer-owned compatibility tests establish supported combinations.
127
+ See [Consumer Contracts](docs/plans/CONSUMER_CONTRACTS.md) and
128
+ [MVP gates](docs/plans/MVP.md).
@@ -0,0 +1,12 @@
1
+ authmate/__init__.py,sha256=zL-5PT8tEkKr_V7AgQ0Qk54NaG2kIVyTvG4BdZdTpHE,1275
2
+ authmate/errors.py,sha256=kc1rQxYHBLiNlqLMe2mkZt2w-Wqdxpf1AcA76qqXuUY,1993
3
+ authmate/fastapi.py,sha256=2zO8vt8311EC-oc8__TWDGDTfHyKAnEiKVnzFLh_k4s,3817
4
+ authmate/models.py,sha256=ZV9vEH-xmgvVDedB7ER3ureCBZJqpUUElBNo7j-B9Y0,10497
5
+ authmate/protocols.py,sha256=ky4iv9rgnaCaw-ZqDhKIiwibDcjoTo7zTBfppqnHZ9Q,1865
6
+ authmate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ authmate/secrets.py,sha256=of_SyPj9DQeKEtD3BNFfUqxwhee4Ew0NGwgXqGDyAhk,1471
8
+ authmate/service.py,sha256=RP1EklWWlauMOo-q_WYocXEBdgpmIHCyb5NuyaglxC4,8863
9
+ authmate-0.1.0.dist-info/METADATA,sha256=AtVJ_hkOAe3Y9mcXvjcTJ3Lv8epcikytrbM4QG8Vv2E,4567
10
+ authmate-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ authmate-0.1.0.dist-info/licenses/LICENSE,sha256=p5xydNm1apImfFfPitmKIURubF492JXU5y13fRbq7rE,1078
12
+ authmate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AuthMate contributors
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.