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.
- ygo74_agent_runtime_security-0.1.0/PKG-INFO +7 -0
- ygo74_agent_runtime_security-0.1.0/pyproject.toml +37 -0
- ygo74_agent_runtime_security-0.1.0/setup.cfg +4 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/agent_principal.py +171 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/apikey_authenticator.py +148 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/auth_context.py +108 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/auth_errors.py +40 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/authentication_policy.py +189 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/authenticator.py +71 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/claims_projection.py +108 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/jwt_authenticator.py +279 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/oidc_discovery.py +94 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/py.typed +0 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/auth/tokens.py +106 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/audit.py +110 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/fencing.py +64 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/floor.py +85 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/operations.py +45 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/permissions.py +78 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/prompt_envelope.py +83 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/py.typed +0 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/security_errors.py +29 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/untrusted.py +101 -0
- ygo74_agent_runtime_security-0.1.0/ygo74/agent_runtime/domains/security/user_context.py +32 -0
- ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/PKG-INFO +7 -0
- ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/SOURCES.txt +27 -0
- ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/dependency_links.txt +1 -0
- ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/requires.txt +2 -0
- ygo74_agent_runtime_security-0.1.0/ygo74_agent_runtime_security.egg-info/top_level.txt +1 -0
|
@@ -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,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)
|