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.
@@ -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}"