ygo74-agent-runtime-security 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.
- ygo74/agent_runtime/domains/auth/agent_principal.py +171 -0
- ygo74/agent_runtime/domains/auth/apikey_authenticator.py +148 -0
- ygo74/agent_runtime/domains/auth/auth_context.py +108 -0
- ygo74/agent_runtime/domains/auth/auth_errors.py +40 -0
- ygo74/agent_runtime/domains/auth/authentication_policy.py +189 -0
- ygo74/agent_runtime/domains/auth/authenticator.py +71 -0
- ygo74/agent_runtime/domains/auth/claims_projection.py +108 -0
- ygo74/agent_runtime/domains/auth/jwt_authenticator.py +279 -0
- ygo74/agent_runtime/domains/auth/oidc_discovery.py +94 -0
- ygo74/agent_runtime/domains/auth/py.typed +0 -0
- ygo74/agent_runtime/domains/auth/tokens.py +106 -0
- ygo74/agent_runtime/domains/security/audit.py +110 -0
- ygo74/agent_runtime/domains/security/fencing.py +64 -0
- ygo74/agent_runtime/domains/security/floor.py +85 -0
- ygo74/agent_runtime/domains/security/operations.py +45 -0
- ygo74/agent_runtime/domains/security/permissions.py +78 -0
- ygo74/agent_runtime/domains/security/prompt_envelope.py +83 -0
- ygo74/agent_runtime/domains/security/py.typed +0 -0
- ygo74/agent_runtime/domains/security/security_errors.py +29 -0
- ygo74/agent_runtime/domains/security/untrusted.py +101 -0
- ygo74/agent_runtime/domains/security/user_context.py +32 -0
- ygo74_agent_runtime_security-0.1.0.dist-info/METADATA +7 -0
- ygo74_agent_runtime_security-0.1.0.dist-info/RECORD +25 -0
- ygo74_agent_runtime_security-0.1.0.dist-info/WHEEL +5 -0
- ygo74_agent_runtime_security-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Who the agent is acting for, established outside the application.
|
|
2
|
+
|
|
3
|
+
A serving surface authenticates a caller; the application must be *told* who that
|
|
4
|
+
is rather than configured with it. :class:`AgentPrincipal` is the result of that
|
|
5
|
+
authentication - a subject, an optional address, a display name and the roles the
|
|
6
|
+
identity provider asserted.
|
|
7
|
+
|
|
8
|
+
It is deliberately *not* a token. It carries no credential, so it may reach a log,
|
|
9
|
+
an audit record or a prompt without leaking anything, and it is immutable, so the
|
|
10
|
+
identity a piece of state is keyed by cannot be changed after the fact.
|
|
11
|
+
|
|
12
|
+
Two things this model deliberately does not do:
|
|
13
|
+
|
|
14
|
+
* It does not build an authorisation. Turning roles into permissions is an
|
|
15
|
+
application decision, and an identity that knew how to grant itself rights
|
|
16
|
+
would be the wrong shape.
|
|
17
|
+
* It does not read a claim it has not modelled. What an application receives is
|
|
18
|
+
exactly what is declared here, so it cannot grow a dependency on a token's
|
|
19
|
+
internals by accident.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from typing import Final
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext
|
|
29
|
+
from ygo74.agent_runtime.domains.security.security_errors import SecurityError
|
|
30
|
+
|
|
31
|
+
_IDENTITY: Final = "identity"
|
|
32
|
+
_SUBJECT: Final = "subject"
|
|
33
|
+
_USER_ID: Final = "userId"
|
|
34
|
+
_EMAIL: Final = "email"
|
|
35
|
+
_NAME: Final = "name"
|
|
36
|
+
_USERNAME: Final = "username"
|
|
37
|
+
_ROLES: Final = "roles"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PrincipalError(SecurityError):
|
|
41
|
+
"""Raised when an authenticated caller cannot be turned into a principal.
|
|
42
|
+
|
|
43
|
+
Failing here is a refusal to serve, never a fallback to an anonymous or
|
|
44
|
+
default identity: acting for "somebody" is how one caller's data ends up
|
|
45
|
+
answering for another.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AgentPrincipal(BaseModel):
|
|
50
|
+
"""An authenticated caller, without any credential.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
subject: Stable identifier of the person, as asserted by the identity
|
|
54
|
+
provider. This is what conversation state, ledgers and audit entries
|
|
55
|
+
are partitioned by, so it must never be derived from client-supplied
|
|
56
|
+
data.
|
|
57
|
+
email: Address of the person, when the identity provider asserts one.
|
|
58
|
+
Optional because it is an attribute of an identity rather than a
|
|
59
|
+
requirement of every agent: a mailbox is addressed by e-mail, a wiki
|
|
60
|
+
account is not. An agent that needs one says so itself, through
|
|
61
|
+
``require_email``, and fails loudly when it is absent, rather than
|
|
62
|
+
this model demanding it of agents that do not.
|
|
63
|
+
display_name: Human-readable name, for prompts and confirmations.
|
|
64
|
+
roles: Roles asserted by the identity provider. They are claims about the
|
|
65
|
+
caller, not permissions: mapping them to permissions is a decision of
|
|
66
|
+
the application.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
subject: str = Field(min_length=1)
|
|
72
|
+
email: str = ""
|
|
73
|
+
display_name: str = ""
|
|
74
|
+
roles: frozenset[str] = frozenset()
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_context(
|
|
78
|
+
cls,
|
|
79
|
+
context: AuthenticatedUserContext,
|
|
80
|
+
*,
|
|
81
|
+
require_email: bool = True,
|
|
82
|
+
) -> AgentPrincipal:
|
|
83
|
+
"""Build a principal from the context the authenticator produced.
|
|
84
|
+
|
|
85
|
+
This is the typed path, and the one to prefer: it needs no dictionary and
|
|
86
|
+
cannot misread a key.
|
|
87
|
+
"""
|
|
88
|
+
identity = context.identity
|
|
89
|
+
return cls._build(
|
|
90
|
+
subject=_text(identity.subject) or _text(identity.user_id),
|
|
91
|
+
email=_text(identity.email),
|
|
92
|
+
display_name=_text(identity.name) or _text(identity.username),
|
|
93
|
+
roles=frozenset(_text(role) for role in context.roles if _text(role)),
|
|
94
|
+
require_email=require_email,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_auth_context(
|
|
99
|
+
cls,
|
|
100
|
+
context: Mapping[str, object] | None,
|
|
101
|
+
*,
|
|
102
|
+
require_email: bool = True,
|
|
103
|
+
) -> AgentPrincipal:
|
|
104
|
+
"""Build a principal from the wire-shaped authentication context.
|
|
105
|
+
|
|
106
|
+
This is the shape an endpoint hands to a handler: loosely typed, because
|
|
107
|
+
it crossed a transport boundary. This is the single place allowed to read
|
|
108
|
+
it, so the rest of an application keeps working with a typed model.
|
|
109
|
+
|
|
110
|
+
The subject is taken from the verified authentication context and never
|
|
111
|
+
from the request body: a caller must not be able to name themselves.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
context: The authentication context the transport established.
|
|
115
|
+
require_email: Whether a caller without an address is refused. An
|
|
116
|
+
agent that addresses its subject by e-mail leaves this at its
|
|
117
|
+
default; an agent whose accounts are not e-mail addresses passes
|
|
118
|
+
``False`` rather than inventing an address to satisfy this model.
|
|
119
|
+
"""
|
|
120
|
+
if not context:
|
|
121
|
+
raise PrincipalError("the request carried no authenticated caller")
|
|
122
|
+
|
|
123
|
+
identity = context.get(_IDENTITY)
|
|
124
|
+
identity_map: Mapping[str, object] = identity if isinstance(identity, Mapping) else {}
|
|
125
|
+
|
|
126
|
+
return cls._build(
|
|
127
|
+
subject=(
|
|
128
|
+
_text(identity_map.get(_SUBJECT))
|
|
129
|
+
or _text(identity_map.get(_USER_ID))
|
|
130
|
+
or _text(context.get(_USER_ID))
|
|
131
|
+
),
|
|
132
|
+
email=_text(identity_map.get(_EMAIL)),
|
|
133
|
+
display_name=_text(identity_map.get(_NAME)) or _text(identity_map.get(_USERNAME)),
|
|
134
|
+
roles=_texts(context.get(_ROLES)),
|
|
135
|
+
require_email=require_email,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def _build(
|
|
140
|
+
cls,
|
|
141
|
+
*,
|
|
142
|
+
subject: str,
|
|
143
|
+
email: str,
|
|
144
|
+
display_name: str,
|
|
145
|
+
roles: frozenset[str],
|
|
146
|
+
require_email: bool,
|
|
147
|
+
) -> AgentPrincipal:
|
|
148
|
+
"""Apply the two refusals both entry points share."""
|
|
149
|
+
if not subject:
|
|
150
|
+
raise PrincipalError("the authenticated caller carries no subject")
|
|
151
|
+
if require_email and not email:
|
|
152
|
+
raise PrincipalError(f"the authenticated caller {subject!r} carries no email address")
|
|
153
|
+
return cls(subject=subject, email=email, display_name=display_name, roles=roles)
|
|
154
|
+
|
|
155
|
+
def has_role(self, role: str) -> bool:
|
|
156
|
+
"""Whether the identity provider asserted a role for this caller."""
|
|
157
|
+
return role in self.roles
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _text(value: object) -> str:
|
|
161
|
+
"""Return a non-empty stripped string, or nothing at all."""
|
|
162
|
+
if not isinstance(value, str):
|
|
163
|
+
return ""
|
|
164
|
+
return value.strip()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _texts(value: object) -> frozenset[str]:
|
|
168
|
+
"""Return the non-empty strings of a wire-shaped list, ignoring the rest."""
|
|
169
|
+
if not isinstance(value, (list, tuple, set, frozenset)):
|
|
170
|
+
return frozenset()
|
|
171
|
+
return frozenset(text for item in value if (text := _text(item)))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from ygo74.agent_runtime.domains.auth.auth_context import (
|
|
8
|
+
AuthenticatedUserContext,
|
|
9
|
+
ResolvedUser,
|
|
10
|
+
)
|
|
11
|
+
from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiKeyUserResolver(Protocol):
|
|
15
|
+
"""Contract a developer implements to map an API key to a user.
|
|
16
|
+
|
|
17
|
+
Returning ``None`` means the key is unknown and the request is rejected with
|
|
18
|
+
``api_key_invalid``. The returned :class:`ResolvedUser` defines exactly which
|
|
19
|
+
user information is loaded into the handler's ``auth_context``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def resolve_user(self, api_key: str) -> ResolvedUser | None:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class StaticApiKeyUserResolver:
|
|
28
|
+
"""In-memory resolver, mostly useful for local development and tests."""
|
|
29
|
+
|
|
30
|
+
users_by_key: dict[str, ResolvedUser]
|
|
31
|
+
|
|
32
|
+
def resolve_user(self, api_key: str) -> ResolvedUser | None:
|
|
33
|
+
return self.users_by_key.get(api_key)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiKeyAuthenticator:
|
|
37
|
+
"""Authenticates callers presenting an API key header.
|
|
38
|
+
|
|
39
|
+
The raw key is never propagated into the resulting context: only the user
|
|
40
|
+
information returned by the resolver is exposed to the handler.
|
|
41
|
+
|
|
42
|
+
``scheme`` exists because not every deployment carries its key in a header of
|
|
43
|
+
its own. A server reached at ``Authorization: Bearer <secret>`` is presenting an
|
|
44
|
+
API key wearing a scheme, and reading the raw header value there would resolve
|
|
45
|
+
the literal string ``Bearer <secret>`` as the key. When a scheme is named, a
|
|
46
|
+
header that does not carry it is not claimed at all - so this authenticator
|
|
47
|
+
cannot swallow a credential meant for another one sharing the same header.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
DEFAULT_HEADER_NAME = "x-api-key"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
resolver: ApiKeyUserResolver,
|
|
55
|
+
*,
|
|
56
|
+
header_name: str = DEFAULT_HEADER_NAME,
|
|
57
|
+
scheme: str = "",
|
|
58
|
+
) -> None:
|
|
59
|
+
self._resolver = resolver
|
|
60
|
+
self._header_name = header_name.lower()
|
|
61
|
+
self._scheme = scheme.strip().lower()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def auth_type(self) -> str:
|
|
65
|
+
return "api_key"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def header_name(self) -> str:
|
|
69
|
+
return self._header_name
|
|
70
|
+
|
|
71
|
+
def can_authenticate(self, headers: Mapping[str, Any]) -> bool:
|
|
72
|
+
return bool(self._presented(headers))
|
|
73
|
+
|
|
74
|
+
def missing_credential_error(self) -> AuthenticationError:
|
|
75
|
+
return AuthenticationError(
|
|
76
|
+
code="api_key_header_missing",
|
|
77
|
+
message=f"Missing {self._header_name} header",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _presented(self, headers: Mapping[str, Any]) -> str:
|
|
81
|
+
"""Return the key the request carries, or the empty string.
|
|
82
|
+
|
|
83
|
+
The empty string means "not for me", which is what keeps a chain of
|
|
84
|
+
authenticators sharing one header from stealing each other's requests.
|
|
85
|
+
"""
|
|
86
|
+
header = headers.get(self._header_name)
|
|
87
|
+
if not isinstance(header, str) or not header.strip():
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
value = header.strip()
|
|
91
|
+
if not self._scheme:
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
name, separator, credential = value.partition(" ")
|
|
95
|
+
if not separator or name.lower() != self._scheme:
|
|
96
|
+
return ""
|
|
97
|
+
return credential.strip()
|
|
98
|
+
|
|
99
|
+
def authenticate(self, headers: Mapping[str, Any]) -> AuthenticatedUserContext:
|
|
100
|
+
api_key = self._presented(headers)
|
|
101
|
+
if not api_key:
|
|
102
|
+
raise self.missing_credential_error()
|
|
103
|
+
|
|
104
|
+
return self.authenticate_key(api_key)
|
|
105
|
+
|
|
106
|
+
def authenticate_key(self, api_key: str) -> AuthenticatedUserContext:
|
|
107
|
+
if not api_key:
|
|
108
|
+
raise self.missing_credential_error()
|
|
109
|
+
|
|
110
|
+
user = self._resolve(api_key)
|
|
111
|
+
|
|
112
|
+
return AuthenticatedUserContext(
|
|
113
|
+
auth_type=self.auth_type,
|
|
114
|
+
identity=user.to_identity(),
|
|
115
|
+
roles=list(user.roles),
|
|
116
|
+
groups=list(user.groups),
|
|
117
|
+
scopes=list(user.scopes),
|
|
118
|
+
claims=dict(user.claims),
|
|
119
|
+
tenant_id=user.tenant_id,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _resolve(self, api_key: str) -> ResolvedUser:
|
|
123
|
+
try:
|
|
124
|
+
resolved: object = self._resolver.resolve_user(api_key)
|
|
125
|
+
except AuthenticationError:
|
|
126
|
+
raise
|
|
127
|
+
except Exception as ex:
|
|
128
|
+
raise AuthenticationError(
|
|
129
|
+
code="user_resolution_failed",
|
|
130
|
+
message="API key user-resolution hook raised an error",
|
|
131
|
+
) from ex
|
|
132
|
+
|
|
133
|
+
if resolved is None:
|
|
134
|
+
raise AuthenticationError(code="api_key_invalid", message="API key is not recognized")
|
|
135
|
+
|
|
136
|
+
if not isinstance(resolved, ResolvedUser):
|
|
137
|
+
raise AuthenticationError(
|
|
138
|
+
code="user_context_malformed",
|
|
139
|
+
message="API key user-resolution hook must return a ResolvedUser",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not resolved.user_id or not resolved.user_id.strip():
|
|
143
|
+
raise AuthenticationError(
|
|
144
|
+
code="user_context_malformed",
|
|
145
|
+
message="API key user-resolution hook must provide a user_id",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return resolved
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class UserIdentity:
|
|
9
|
+
"""Normalized identity of the authenticated caller."""
|
|
10
|
+
|
|
11
|
+
user_id: str
|
|
12
|
+
subject: str | None = None
|
|
13
|
+
username: str | None = None
|
|
14
|
+
name: str | None = None
|
|
15
|
+
given_name: str | None = None
|
|
16
|
+
family_name: str | None = None
|
|
17
|
+
email: str | None = None
|
|
18
|
+
email_verified: bool | None = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
"userId": self.user_id,
|
|
23
|
+
"subject": self.subject or self.user_id,
|
|
24
|
+
"username": self.username,
|
|
25
|
+
"name": self.name,
|
|
26
|
+
"givenName": self.given_name,
|
|
27
|
+
"familyName": self.family_name,
|
|
28
|
+
"email": self.email,
|
|
29
|
+
"emailVerified": self.email_verified,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class ResolvedUser:
|
|
35
|
+
"""Return schema of an API key user-resolution hook.
|
|
36
|
+
|
|
37
|
+
This is the contract a developer must satisfy when mapping an API key to a
|
|
38
|
+
user: whatever is populated here is what the handler will find in its
|
|
39
|
+
``auth_context``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
user_id: str
|
|
43
|
+
username: str | None = None
|
|
44
|
+
name: str | None = None
|
|
45
|
+
given_name: str | None = None
|
|
46
|
+
family_name: str | None = None
|
|
47
|
+
email: str | None = None
|
|
48
|
+
email_verified: bool | None = None
|
|
49
|
+
roles: list[str] = field(default_factory=list)
|
|
50
|
+
groups: list[str] = field(default_factory=list)
|
|
51
|
+
scopes: list[str] = field(default_factory=list)
|
|
52
|
+
tenant_id: str | None = None
|
|
53
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
def to_identity(self) -> UserIdentity:
|
|
56
|
+
return UserIdentity(
|
|
57
|
+
user_id=self.user_id,
|
|
58
|
+
subject=self.user_id,
|
|
59
|
+
username=self.username,
|
|
60
|
+
name=self.name,
|
|
61
|
+
given_name=self.given_name,
|
|
62
|
+
family_name=self.family_name,
|
|
63
|
+
email=self.email,
|
|
64
|
+
email_verified=self.email_verified,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class AuthenticatedUserContext:
|
|
70
|
+
"""Normalized authentication context handed to the handler.
|
|
71
|
+
|
|
72
|
+
``to_dict`` produces the wire shape required by the Standard Exchange
|
|
73
|
+
contract (``userId`` and ``authType`` at the top level).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
auth_type: str
|
|
77
|
+
identity: UserIdentity
|
|
78
|
+
roles: list[str] = field(default_factory=list)
|
|
79
|
+
groups: list[str] = field(default_factory=list)
|
|
80
|
+
scopes: list[str] = field(default_factory=list)
|
|
81
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
|
82
|
+
tenant_id: str | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def user_id(self) -> str:
|
|
86
|
+
return self.identity.user_id
|
|
87
|
+
|
|
88
|
+
def has_role(self, role: str) -> bool:
|
|
89
|
+
return role in self.roles
|
|
90
|
+
|
|
91
|
+
def has_scope(self, scope: str) -> bool:
|
|
92
|
+
return scope in self.scopes
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, Any]:
|
|
95
|
+
context: dict[str, Any] = {
|
|
96
|
+
"authType": self.auth_type,
|
|
97
|
+
"userId": self.identity.user_id,
|
|
98
|
+
"identity": self.identity.to_dict(),
|
|
99
|
+
"roles": list(self.roles),
|
|
100
|
+
"groups": list(self.groups),
|
|
101
|
+
"scopes": list(self.scopes),
|
|
102
|
+
"claims": dict(self.claims),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if self.tenant_id is not None:
|
|
106
|
+
context["tenantId"] = self.tenant_id
|
|
107
|
+
|
|
108
|
+
return context
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def auth_error(code: str, message: str, category: str, details: Any | None = None) -> dict[str, Any]:
|
|
8
|
+
err: dict[str, Any] = {"code": code, "category": category, "message": message}
|
|
9
|
+
if details is not None:
|
|
10
|
+
err["details"] = details
|
|
11
|
+
return err
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class AuthenticationError(Exception):
|
|
16
|
+
code: str
|
|
17
|
+
message: str
|
|
18
|
+
category: str = "authentication"
|
|
19
|
+
details: Any | None = None
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
return auth_error(self.code, self.message, self.category, self.details)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class AuthorizationError(Exception):
|
|
27
|
+
"""Raised by developer-owned authorization logic to deny an authenticated request.
|
|
28
|
+
|
|
29
|
+
The runtime never raises this itself: authorization decisions belong to the
|
|
30
|
+
handler. When raised, the runtime short-circuits handler execution and maps
|
|
31
|
+
it to an HTTP 403 with a structured error envelope.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
code: str = "forbidden"
|
|
35
|
+
message: str = "Access denied"
|
|
36
|
+
category: str = "authorization"
|
|
37
|
+
details: Any | None = None
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return auth_error(self.code, self.message, self.category, self.details)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Which authentication schemes a host accepts, said out loud.
|
|
2
|
+
|
|
3
|
+
The schemes themselves already existed: an API key with a user resolver the host
|
|
4
|
+
writes, a JWT validated against an OIDC issuer, and the :class:`Authenticator`
|
|
5
|
+
protocol for anything a deployment actually has - Basic, Kerberos, mutual TLS. What
|
|
6
|
+
was missing was a way to *declare* which of them a host accepts. A host declared it
|
|
7
|
+
by which keyword arguments it happened to pass, and the absence of all of them meant
|
|
8
|
+
"no authentication".
|
|
9
|
+
|
|
10
|
+
That was survivable while agents were the only host, because an agent always needs
|
|
11
|
+
a subject: without one there is nothing to partition conversation state by. It stops
|
|
12
|
+
being survivable the moment an MCP server shares the model, because an MCP server
|
|
13
|
+
over read-only public data may legitimately serve everyone.
|
|
14
|
+
|
|
15
|
+
So anonymity becomes a decision rather than a residue. :meth:`AuthenticationPolicy.anonymous`
|
|
16
|
+
is the only way to reach it, :meth:`AuthenticationPolicy.of` refuses to build a
|
|
17
|
+
policy out of nothing, and :meth:`AuthenticationMode.named` refuses an unset value.
|
|
18
|
+
A deployment that forgot to configure authentication stops; a deployment that chose
|
|
19
|
+
to serve everyone says so, and can be asked about it later.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from typing import Self
|
|
26
|
+
|
|
27
|
+
from ygo74.agent_runtime.domains.auth.apikey_authenticator import (
|
|
28
|
+
ApiKeyAuthenticator,
|
|
29
|
+
ApiKeyUserResolver,
|
|
30
|
+
)
|
|
31
|
+
from ygo74.agent_runtime.domains.auth.authenticator import (
|
|
32
|
+
Authenticator,
|
|
33
|
+
RequestAuthenticator,
|
|
34
|
+
)
|
|
35
|
+
from ygo74.agent_runtime.domains.auth.jwt_authenticator import (
|
|
36
|
+
JwtAuthenticator,
|
|
37
|
+
JwtValidationConfig,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthenticationConfigurationError(RuntimeError):
|
|
42
|
+
"""Raised when a host cannot be served safely as configured.
|
|
43
|
+
|
|
44
|
+
Deliberately fatal, and deliberately not an :class:`AuthenticationError`: this
|
|
45
|
+
is not a caller failing to authenticate, it is a deployment that never said how
|
|
46
|
+
callers would be authenticated at all. Answering a request would mean guessing.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuthenticationMode(StrEnum):
|
|
51
|
+
"""The schemes a host can be configured with.
|
|
52
|
+
|
|
53
|
+
``CUSTOM`` is not something a configuration file names. It is what a policy
|
|
54
|
+
reports when a host supplied its own :class:`Authenticator`, so that a start-up
|
|
55
|
+
log still says something true about a scheme this enumeration never heard of.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
NONE = "none"
|
|
59
|
+
API_KEY = "api_key"
|
|
60
|
+
JWT = "jwt"
|
|
61
|
+
CUSTOM = "custom"
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def named(cls, value: str | None) -> AuthenticationMode:
|
|
65
|
+
"""Read a mode a deployment asked for, refusing silence.
|
|
66
|
+
|
|
67
|
+
An unset value is the case this method exists for. Defaulting it to
|
|
68
|
+
``NONE`` would turn a forgotten environment variable into an open door, and
|
|
69
|
+
the only sign of it would be the absence of a line in a log.
|
|
70
|
+
|
|
71
|
+
``CUSTOM`` is refused too. It is what a policy *reports* when a host supplied
|
|
72
|
+
its own authenticator in code; naming it in a configuration file asks for a
|
|
73
|
+
scheme nothing can build.
|
|
74
|
+
"""
|
|
75
|
+
text = (value or "").strip().lower()
|
|
76
|
+
known = ", ".join(mode.value for mode in cls if mode is not cls.CUSTOM)
|
|
77
|
+
|
|
78
|
+
if not text:
|
|
79
|
+
raise AuthenticationConfigurationError(
|
|
80
|
+
f"no authentication mode was configured: name one of {known}. "
|
|
81
|
+
"Serving without authentication is a decision, so it has to be written down as 'none'"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if text == cls.CUSTOM.value:
|
|
85
|
+
raise AuthenticationConfigurationError(
|
|
86
|
+
f"authentication mode 'custom' cannot be configured: it is what a policy reports "
|
|
87
|
+
f"when a host passes its own authenticator to AuthenticationPolicy.of(). "
|
|
88
|
+
f"Expected one of {known}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
return cls(text)
|
|
93
|
+
except ValueError as error:
|
|
94
|
+
raise AuthenticationConfigurationError(
|
|
95
|
+
f"unknown authentication mode {text!r}: expected one of {known}"
|
|
96
|
+
) from error
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AuthenticationPolicy:
|
|
100
|
+
"""The schemes a host accepts, and whether a credential is required.
|
|
101
|
+
|
|
102
|
+
Built through a named constructor rather than a keyword-argument soup, so that
|
|
103
|
+
reading the composition root tells you the security posture of the service
|
|
104
|
+
without having to work out what an omitted argument meant.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, mode: AuthenticationMode, authenticators: tuple[Authenticator, ...]) -> None:
|
|
108
|
+
self._mode = mode
|
|
109
|
+
self._authenticators = authenticators
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def mode(self) -> AuthenticationMode:
|
|
113
|
+
"""Which scheme family this policy was built for."""
|
|
114
|
+
return self._mode
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def authenticators(self) -> tuple[Authenticator, ...]:
|
|
118
|
+
"""The schemes, in the order they are tried."""
|
|
119
|
+
return self._authenticators
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def requires_authentication(self) -> bool:
|
|
123
|
+
"""Whether a request without a credential is refused."""
|
|
124
|
+
return self._mode is not AuthenticationMode.NONE
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def anonymous(cls) -> Self:
|
|
128
|
+
"""Serve everyone, deliberately.
|
|
129
|
+
|
|
130
|
+
Legitimate for a server exposing public, read-only data. Not legitimate as
|
|
131
|
+
the consequence of an unset variable, which is why it has a name.
|
|
132
|
+
"""
|
|
133
|
+
return cls(AuthenticationMode.NONE, ())
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def api_key(
|
|
137
|
+
cls,
|
|
138
|
+
resolver: ApiKeyUserResolver,
|
|
139
|
+
*,
|
|
140
|
+
header_name: str = ApiKeyAuthenticator.DEFAULT_HEADER_NAME,
|
|
141
|
+
scheme: str = "",
|
|
142
|
+
) -> Self:
|
|
143
|
+
"""Authenticate a key, and let the host decide who that key is.
|
|
144
|
+
|
|
145
|
+
``scheme`` covers the deployments that carry their key in an
|
|
146
|
+
``Authorization`` header - the mail MCP server presents its shared secret
|
|
147
|
+
as ``Bearer <secret>``. Leave it empty and the raw header value is the key.
|
|
148
|
+
"""
|
|
149
|
+
return cls(
|
|
150
|
+
AuthenticationMode.API_KEY,
|
|
151
|
+
(ApiKeyAuthenticator(resolver, header_name=header_name, scheme=scheme),),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def jwt(cls, validation: JwtValidationConfig) -> Self:
|
|
156
|
+
"""Validate a bearer token against an issuer's published keys."""
|
|
157
|
+
return cls(AuthenticationMode.JWT, (JwtAuthenticator(validation),))
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def of(cls, *authenticators: Authenticator) -> Self:
|
|
161
|
+
"""Accept schemes the host supplies itself.
|
|
162
|
+
|
|
163
|
+
The extension point. Basic, Kerberos or mutual TLS are implementations of
|
|
164
|
+
:class:`Authenticator` written outside this package; none of them requires a
|
|
165
|
+
change here.
|
|
166
|
+
|
|
167
|
+
Order is precedence: the first authenticator claiming a request wins.
|
|
168
|
+
"""
|
|
169
|
+
if not authenticators:
|
|
170
|
+
raise AuthenticationConfigurationError(
|
|
171
|
+
"no authentication scheme was supplied: pass at least one authenticator, "
|
|
172
|
+
"or say AuthenticationPolicy.anonymous() if serving everyone is the intent"
|
|
173
|
+
)
|
|
174
|
+
return cls(AuthenticationMode.CUSTOM, tuple(authenticators))
|
|
175
|
+
|
|
176
|
+
def build(self) -> RequestAuthenticator:
|
|
177
|
+
"""Assemble the chain a transport runs against every request."""
|
|
178
|
+
return RequestAuthenticator(
|
|
179
|
+
list(self._authenticators),
|
|
180
|
+
require_authentication=self.requires_authentication,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def describe(self) -> str:
|
|
184
|
+
"""One line for a start-up log, naming the posture rather than implying it."""
|
|
185
|
+
if self._mode is AuthenticationMode.NONE:
|
|
186
|
+
return "anonymous: every caller is served, no credential is checked"
|
|
187
|
+
|
|
188
|
+
schemes = ", ".join(authenticator.auth_type for authenticator in self._authenticators)
|
|
189
|
+
return f"authenticated: {schemes}"
|