ygo74-agent-runtime-security 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. ygo74_agent_runtime_security-0.1.0/PKG-INFO +7 -0
  2. ygo74_agent_runtime_security-0.1.0/pyproject.toml +37 -0
  3. ygo74_agent_runtime_security-0.1.0/setup.cfg +4 -0
  4. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/agent_principal.py +171 -0
  5. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/apikey_authenticator.py +148 -0
  6. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/auth_context.py +108 -0
  7. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/auth_errors.py +40 -0
  8. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/authentication_policy.py +189 -0
  9. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/authenticator.py +71 -0
  10. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/claims_projection.py +108 -0
  11. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/jwt_authenticator.py +279 -0
  12. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/oidc_discovery.py +94 -0
  13. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/py.typed +0 -0
  14. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/tokens.py +106 -0
  15. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/audit.py +110 -0
  16. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/fencing.py +64 -0
  17. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/floor.py +85 -0
  18. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/operations.py +45 -0
  19. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/permissions.py +78 -0
  20. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/prompt_envelope.py +83 -0
  21. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/py.typed +0 -0
  22. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/security_errors.py +29 -0
  23. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/untrusted.py +101 -0
  24. ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/user_context.py +32 -0
  25. ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/PKG-INFO +7 -0
  26. ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/SOURCES.txt +27 -0
  27. ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/dependency_links.txt +1 -0
  28. ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/requires.txt +2 -0
  29. ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: ygo74-agent-runtime-security
3
+ Version: 0.1.0
4
+ Summary: Security and authentication foundation of the ygo74 agent runtime
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic>=2.7
7
+ Requires-Dist: PyJWT>=2.8
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ygo74-agent-runtime-security"
7
+ version = "0.1.0"
8
+ description = "Security and authentication foundation of the ygo74 agent runtime"
9
+ # 3.12 is the floor the shipped code needs.
10
+ requires-python = ">=3.12"
11
+
12
+ # The foundation of the runtime: the security model (permissions, user context,
13
+ # operation classification, security floor, audit, untrusted content) and the
14
+ # authentication schemes (API key with a pluggable user resolver, JWT against an
15
+ # OIDC issuer, and the `Authenticator` protocol a host extends).
16
+ #
17
+ # It depends on no other domain of this runtime, which is what lets an agent host
18
+ # and an MCP server host share one authentication model instead of growing two.
19
+ dependencies = [
20
+ "pydantic>=2.7",
21
+ # `domains.auth.jwt_authenticator` validates tokens against a published key set.
22
+ "PyJWT>=2.8",
23
+ ]
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["ygo74*"]
27
+ namespaces = true
28
+
29
+ # `ygo74`, `ygo74.agent_runtime` and `ygo74.agent_runtime.domains` are namespace
30
+ # portions shared with the other distributions, so the typing marker cannot sit at
31
+ # any of those levels - two distributions shipping the same path would collide.
32
+ # It goes in each domain this distribution alone owns. Without it a consumer's type
33
+ # checker silently treats every type here as `Any`, which is exactly the wrong
34
+ # place to lose type safety.
35
+ [tool.setuptools.package-data]
36
+ "ygo74.agent_runtime.domains.security" = ["py.typed"]
37
+ "ygo74.agent_runtime.domains.auth" = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)