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 +58 -0
- authmate/errors.py +75 -0
- authmate/fastapi.py +110 -0
- authmate/models.py +336 -0
- authmate/protocols.py +78 -0
- authmate/py.typed +0 -0
- authmate/secrets.py +57 -0
- authmate/service.py +256 -0
- authmate-0.1.0.dist-info/METADATA +128 -0
- authmate-0.1.0.dist-info/RECORD +12 -0
- authmate-0.1.0.dist-info/WHEEL +4 -0
- authmate-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|