ygo74-agent-runtime 0.0.2__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-0.0.2/PKG-INFO +8 -0
- ygo74_agent_runtime-0.0.2/pyproject.toml +14 -0
- ygo74_agent_runtime-0.0.2/setup.cfg +4 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/__init__.py +105 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/apikey_authenticator.py +111 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/auth_context.py +108 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/auth_errors.py +40 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/authenticator.py +70 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/claims_projection.py +107 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/jwt_authenticator.py +248 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/configuration/models.py +54 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/configuration/validator.py +12 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/contracts/error_envelope.py +12 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/contracts/exchange_models.py +22 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/contracts/stream_events.py +12 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/__init__.py +7 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/agent_access_policy.py +59 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/agent_descriptor.py +316 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/anthropic_model_projection.py +53 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/capability_extensions.py +47 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/capability_validator.py +53 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/descriptor_binding.py +32 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/descriptor_defaults.py +76 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/descriptor_registry.py +97 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/dialect_selector.py +72 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/discovery_configuration.py +247 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/discovery_errors.py +142 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/model_route_resolver.py +28 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/openai_model_projection.py +37 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/pagination.py +100 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/endpoints/adapters.py +22 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/endpoints/fastapi_endpoints.py +716 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/handlers/handler_protocol.py +8 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/handlers/response_validator.py +10 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/mapping/request_mapper.py +7 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/mapping/response_mapper.py +17 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/streaming/openai_stream_mapper.py +10 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/streaming/stream_termination.py +17 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/interfaces.py +14 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/middleware_errors.py +6 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/pipeline.py +12 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/registry.py +20 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/observability/logging_config.py +8 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/observability/otel.py +3 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/dispatcher.py +14 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/dispatcher_impl.py +9 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/route_registry.py +18 -0
- ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/routing_errors.py +6 -0
- ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/PKG-INFO +8 -0
- ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/SOURCES.txt +51 -0
- ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/dependency_links.txt +1 -0
- ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/requires.txt +3 -0
- ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ygo74-agent-runtime"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "Cross-language AI enterprise agent runtime"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic>=2.7",
|
|
12
|
+
"typing-extensions>=4.12",
|
|
13
|
+
"PyJWT>=2.8"
|
|
14
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from ygo74.agent_runtime.domains.contracts.exchange_models import StandardExchangeRequest, StandardExchangeResponse
|
|
2
|
+
from ygo74.agent_runtime.domains.contracts.error_envelope import ErrorEnvelope
|
|
3
|
+
from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext, ResolvedUser, UserIdentity
|
|
4
|
+
from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError, AuthorizationError
|
|
5
|
+
from ygo74.agent_runtime.domains.auth.authenticator import Authenticator, RequestAuthenticator
|
|
6
|
+
from ygo74.agent_runtime.domains.auth.apikey_authenticator import (
|
|
7
|
+
ApiKeyAuthenticator,
|
|
8
|
+
ApiKeyUserResolver,
|
|
9
|
+
StaticApiKeyUserResolver,
|
|
10
|
+
)
|
|
11
|
+
from ygo74.agent_runtime.domains.auth.jwt_authenticator import JwtAuthenticator, JwtValidationConfig
|
|
12
|
+
from ygo74.agent_runtime.domains.discovery.agent_descriptor import (
|
|
13
|
+
AgentCapabilitySet,
|
|
14
|
+
AgentDescriptor,
|
|
15
|
+
AgentSkill,
|
|
16
|
+
CapabilitySizeUnit,
|
|
17
|
+
DiscoveryVisibility,
|
|
18
|
+
Modality,
|
|
19
|
+
)
|
|
20
|
+
from ygo74.agent_runtime.domains.discovery.agent_access_policy import AgentAccessPolicy, RoleRequiredAccessPolicy
|
|
21
|
+
from ygo74.agent_runtime.domains.discovery.anthropic_model_projection import AnthropicModelProjection
|
|
22
|
+
from ygo74.agent_runtime.domains.discovery.capability_extensions import CapabilityExtensions
|
|
23
|
+
from ygo74.agent_runtime.domains.discovery.capability_validator import CapabilityValidator
|
|
24
|
+
from ygo74.agent_runtime.domains.discovery.descriptor_binding import DescriptorBinding
|
|
25
|
+
from ygo74.agent_runtime.domains.discovery.descriptor_defaults import DescriptorDefaults
|
|
26
|
+
from ygo74.agent_runtime.domains.discovery.descriptor_registry import DescriptorOrdering, DescriptorRegistry
|
|
27
|
+
from ygo74.agent_runtime.domains.discovery.dialect_selector import (
|
|
28
|
+
DialectSelection,
|
|
29
|
+
DialectSelector,
|
|
30
|
+
ProviderDialect,
|
|
31
|
+
)
|
|
32
|
+
from ygo74.agent_runtime.domains.discovery.discovery_configuration import (
|
|
33
|
+
DiscoveryConfiguration,
|
|
34
|
+
DiscoveryService,
|
|
35
|
+
DiscoverySurface,
|
|
36
|
+
)
|
|
37
|
+
from ygo74.agent_runtime.domains.discovery.discovery_errors import (
|
|
38
|
+
DiscoveryError,
|
|
39
|
+
DiscoveryErrorCategory,
|
|
40
|
+
DiscoveryErrorCode,
|
|
41
|
+
DiscoveryErrors,
|
|
42
|
+
)
|
|
43
|
+
from ygo74.agent_runtime.domains.discovery.model_route_resolver import ModelRouteResolver
|
|
44
|
+
from ygo74.agent_runtime.domains.discovery.openai_model_projection import OpenAiModelProjection
|
|
45
|
+
from ygo74.agent_runtime.domains.discovery.pagination import (
|
|
46
|
+
DiscoveryPagination,
|
|
47
|
+
PaginationRequest,
|
|
48
|
+
PaginationResult,
|
|
49
|
+
)
|
|
50
|
+
from ygo74.agent_runtime.domains.endpoints.fastapi_endpoints import (
|
|
51
|
+
add_ai_endpoint,
|
|
52
|
+
add_ai_endpoints,
|
|
53
|
+
add_discovery_endpoints,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"StandardExchangeRequest",
|
|
58
|
+
"StandardExchangeResponse",
|
|
59
|
+
"ErrorEnvelope",
|
|
60
|
+
"AuthenticatedUserContext",
|
|
61
|
+
"UserIdentity",
|
|
62
|
+
"ResolvedUser",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"AuthorizationError",
|
|
65
|
+
"Authenticator",
|
|
66
|
+
"RequestAuthenticator",
|
|
67
|
+
"JwtAuthenticator",
|
|
68
|
+
"JwtValidationConfig",
|
|
69
|
+
"ApiKeyAuthenticator",
|
|
70
|
+
"ApiKeyUserResolver",
|
|
71
|
+
"StaticApiKeyUserResolver",
|
|
72
|
+
"AgentDescriptor",
|
|
73
|
+
"AgentCapabilitySet",
|
|
74
|
+
"AgentSkill",
|
|
75
|
+
"CapabilitySizeUnit",
|
|
76
|
+
"DiscoveryVisibility",
|
|
77
|
+
"Modality",
|
|
78
|
+
"AgentAccessPolicy",
|
|
79
|
+
"RoleRequiredAccessPolicy",
|
|
80
|
+
"DescriptorRegistry",
|
|
81
|
+
"DescriptorOrdering",
|
|
82
|
+
"DescriptorDefaults",
|
|
83
|
+
"DescriptorBinding",
|
|
84
|
+
"CapabilityValidator",
|
|
85
|
+
"CapabilityExtensions",
|
|
86
|
+
"DiscoveryConfiguration",
|
|
87
|
+
"DiscoveryService",
|
|
88
|
+
"DiscoverySurface",
|
|
89
|
+
"DiscoveryError",
|
|
90
|
+
"DiscoveryErrorCategory",
|
|
91
|
+
"DiscoveryErrorCode",
|
|
92
|
+
"DiscoveryErrors",
|
|
93
|
+
"DialectSelection",
|
|
94
|
+
"DialectSelector",
|
|
95
|
+
"ProviderDialect",
|
|
96
|
+
"DiscoveryPagination",
|
|
97
|
+
"ModelRouteResolver",
|
|
98
|
+
"PaginationRequest",
|
|
99
|
+
"PaginationResult",
|
|
100
|
+
"OpenAiModelProjection",
|
|
101
|
+
"AnthropicModelProjection",
|
|
102
|
+
"add_ai_endpoint",
|
|
103
|
+
"add_ai_endpoints",
|
|
104
|
+
"add_discovery_endpoints",
|
|
105
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping, Protocol
|
|
5
|
+
|
|
6
|
+
from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext, ResolvedUser
|
|
7
|
+
from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiKeyUserResolver(Protocol):
|
|
11
|
+
"""Contract a developer implements to map an API key to a user.
|
|
12
|
+
|
|
13
|
+
Returning ``None`` means the key is unknown and the request is rejected with
|
|
14
|
+
``api_key_invalid``. The returned :class:`ResolvedUser` defines exactly which
|
|
15
|
+
user information is loaded into the handler's ``auth_context``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def resolve_user(self, api_key: str) -> ResolvedUser | None:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class StaticApiKeyUserResolver:
|
|
24
|
+
"""In-memory resolver, mostly useful for local development and tests."""
|
|
25
|
+
|
|
26
|
+
users_by_key: dict[str, ResolvedUser]
|
|
27
|
+
|
|
28
|
+
def resolve_user(self, api_key: str) -> ResolvedUser | None:
|
|
29
|
+
return self.users_by_key.get(api_key)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiKeyAuthenticator:
|
|
33
|
+
"""Authenticates callers presenting an API key header.
|
|
34
|
+
|
|
35
|
+
The raw key is never propagated into the resulting context: only the user
|
|
36
|
+
information returned by the resolver is exposed to the handler.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
DEFAULT_HEADER_NAME = "x-api-key"
|
|
40
|
+
|
|
41
|
+
def __init__(self, resolver: ApiKeyUserResolver, *, header_name: str = DEFAULT_HEADER_NAME) -> None:
|
|
42
|
+
self._resolver = resolver
|
|
43
|
+
self._header_name = header_name.lower()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def auth_type(self) -> str:
|
|
47
|
+
return "api_key"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def header_name(self) -> str:
|
|
51
|
+
return self._header_name
|
|
52
|
+
|
|
53
|
+
def can_authenticate(self, headers: Mapping[str, Any]) -> bool:
|
|
54
|
+
return bool(headers.get(self._header_name))
|
|
55
|
+
|
|
56
|
+
def missing_credential_error(self) -> AuthenticationError:
|
|
57
|
+
return AuthenticationError(
|
|
58
|
+
code="api_key_header_missing",
|
|
59
|
+
message=f"Missing {self._header_name} header",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def authenticate(self, headers: Mapping[str, Any]) -> AuthenticatedUserContext:
|
|
63
|
+
api_key = headers.get(self._header_name)
|
|
64
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
65
|
+
raise self.missing_credential_error()
|
|
66
|
+
|
|
67
|
+
return self.authenticate_key(api_key)
|
|
68
|
+
|
|
69
|
+
def authenticate_key(self, api_key: str) -> AuthenticatedUserContext:
|
|
70
|
+
if not api_key:
|
|
71
|
+
raise self.missing_credential_error()
|
|
72
|
+
|
|
73
|
+
user = self._resolve(api_key)
|
|
74
|
+
|
|
75
|
+
return AuthenticatedUserContext(
|
|
76
|
+
auth_type=self.auth_type,
|
|
77
|
+
identity=user.to_identity(),
|
|
78
|
+
roles=list(user.roles),
|
|
79
|
+
groups=list(user.groups),
|
|
80
|
+
scopes=list(user.scopes),
|
|
81
|
+
claims=dict(user.claims),
|
|
82
|
+
tenant_id=user.tenant_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _resolve(self, api_key: str) -> ResolvedUser:
|
|
86
|
+
try:
|
|
87
|
+
resolved: object = self._resolver.resolve_user(api_key)
|
|
88
|
+
except AuthenticationError:
|
|
89
|
+
raise
|
|
90
|
+
except Exception as ex:
|
|
91
|
+
raise AuthenticationError(
|
|
92
|
+
code="user_resolution_failed",
|
|
93
|
+
message="API key user-resolution hook raised an error",
|
|
94
|
+
) from ex
|
|
95
|
+
|
|
96
|
+
if resolved is None:
|
|
97
|
+
raise AuthenticationError(code="api_key_invalid", message="API key is not recognized")
|
|
98
|
+
|
|
99
|
+
if not isinstance(resolved, ResolvedUser):
|
|
100
|
+
raise AuthenticationError(
|
|
101
|
+
code="user_context_malformed",
|
|
102
|
+
message="API key user-resolution hook must return a ResolvedUser",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not resolved.user_id or not resolved.user_id.strip():
|
|
106
|
+
raise AuthenticationError(
|
|
107
|
+
code="user_context_malformed",
|
|
108
|
+
message="API key user-resolution hook must provide a user_id",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
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,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Mapping, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext
|
|
7
|
+
from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Authenticator(Protocol):
|
|
12
|
+
"""Contract implemented by every authentication scheme.
|
|
13
|
+
|
|
14
|
+
``RequestAuthenticator`` inspects the incoming headers and delegates to the
|
|
15
|
+
first authenticator that claims them, so adding a scheme means adding a
|
|
16
|
+
class implementing this protocol.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def auth_type(self) -> str:
|
|
21
|
+
"""Stable identifier projected as ``authContext.authType``."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def can_authenticate(self, headers: Mapping[str, Any]) -> bool:
|
|
25
|
+
"""Return True when the request carries a credential for this scheme."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def authenticate(self, headers: Mapping[str, Any]) -> AuthenticatedUserContext:
|
|
29
|
+
"""Validate the credential and project a normalized user context."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def missing_credential_error(self) -> AuthenticationError:
|
|
33
|
+
"""Error raised when authentication is required but no credential was sent."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class RequestAuthenticator:
|
|
39
|
+
"""Selects and runs the authenticator matching the incoming request headers.
|
|
40
|
+
|
|
41
|
+
Authenticators are evaluated in order and the first one claiming the request
|
|
42
|
+
wins, so ordering expresses precedence (JWT before API key by convention).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
authenticators: list[Authenticator] = field(default_factory=list)
|
|
46
|
+
require_authentication: bool = False
|
|
47
|
+
|
|
48
|
+
def authenticate(self, headers: Mapping[str, Any] | None) -> AuthenticatedUserContext | None:
|
|
49
|
+
if headers is None:
|
|
50
|
+
if self.require_authentication:
|
|
51
|
+
raise self._missing_credential_error()
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
for authenticator in self.authenticators:
|
|
55
|
+
if authenticator.can_authenticate(headers):
|
|
56
|
+
return authenticator.authenticate(headers)
|
|
57
|
+
|
|
58
|
+
if self.require_authentication:
|
|
59
|
+
raise self._missing_credential_error()
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def _missing_credential_error(self) -> AuthenticationError:
|
|
64
|
+
if self.authenticators:
|
|
65
|
+
return self.authenticators[0].missing_credential_error()
|
|
66
|
+
|
|
67
|
+
return AuthenticationError(
|
|
68
|
+
code="authentication_not_configured",
|
|
69
|
+
message="Authentication is required but no authenticator is configured",
|
|
70
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping, cast
|
|
5
|
+
|
|
6
|
+
from ygo74.agent_runtime.domains.auth.auth_context import UserIdentity
|
|
7
|
+
|
|
8
|
+
_PROJECTED_CLAIM_KEYS = (
|
|
9
|
+
"iss",
|
|
10
|
+
"aud",
|
|
11
|
+
"exp",
|
|
12
|
+
"nbf",
|
|
13
|
+
"iat",
|
|
14
|
+
"jti",
|
|
15
|
+
"scope",
|
|
16
|
+
"roles",
|
|
17
|
+
"name",
|
|
18
|
+
"given_name",
|
|
19
|
+
"family_name",
|
|
20
|
+
"preferred_username",
|
|
21
|
+
"email",
|
|
22
|
+
"email_verified",
|
|
23
|
+
"groups",
|
|
24
|
+
"realm_access",
|
|
25
|
+
"resource_access",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class ClaimsProjector:
|
|
31
|
+
"""Projects raw OIDC claims into the normalized authentication context.
|
|
32
|
+
|
|
33
|
+
``roles_claim_path`` and ``groups_claim_path`` are dot-separated paths into
|
|
34
|
+
the decoded claims (for example ``realm_access.roles`` or
|
|
35
|
+
``resource_access.<client>.roles``), mirroring the
|
|
36
|
+
``OPENID_REQUIRED_ROLE_PARAMETER_PATH`` style of configuration used by OIDC
|
|
37
|
+
providers such as Keycloak.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
roles_claim_path: str | None = None
|
|
41
|
+
groups_claim_path: str | None = None
|
|
42
|
+
|
|
43
|
+
def identity(self, claims: Mapping[str, Any], subject: str) -> UserIdentity:
|
|
44
|
+
email_verified = claims.get("email_verified")
|
|
45
|
+
|
|
46
|
+
return UserIdentity(
|
|
47
|
+
user_id=subject,
|
|
48
|
+
subject=subject,
|
|
49
|
+
username=self._optional_str(claims.get("preferred_username")),
|
|
50
|
+
name=self._optional_str(claims.get("name")),
|
|
51
|
+
given_name=self._optional_str(claims.get("given_name")),
|
|
52
|
+
family_name=self._optional_str(claims.get("family_name")),
|
|
53
|
+
email=self._optional_str(claims.get("email")),
|
|
54
|
+
email_verified=email_verified if isinstance(email_verified, bool) else None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def roles(self, claims: Mapping[str, Any]) -> list[str]:
|
|
58
|
+
return self.values_at(claims, self.roles_claim_path)
|
|
59
|
+
|
|
60
|
+
def groups(self, claims: Mapping[str, Any]) -> list[str]:
|
|
61
|
+
return self.values_at(claims, self.groups_claim_path)
|
|
62
|
+
|
|
63
|
+
def scopes(self, claims: Mapping[str, Any]) -> list[str]:
|
|
64
|
+
scope = claims.get("scope")
|
|
65
|
+
if isinstance(scope, str):
|
|
66
|
+
return scope.split()
|
|
67
|
+
|
|
68
|
+
return self._as_string_list(scope)
|
|
69
|
+
|
|
70
|
+
def context_claims(self, claims: Mapping[str, Any]) -> dict[str, Any]:
|
|
71
|
+
return {key: claims[key] for key in _PROJECTED_CLAIM_KEYS if key in claims}
|
|
72
|
+
|
|
73
|
+
def values_at(self, claims: Mapping[str, Any], path: str | None) -> list[str]:
|
|
74
|
+
if not path:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
return self._as_string_list(self.resolve_path(claims, path))
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def resolve_path(claims: Mapping[str, Any], path: str) -> Any:
|
|
81
|
+
value: Any = claims
|
|
82
|
+
for part in path.split("."):
|
|
83
|
+
if not isinstance(value, Mapping):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
current = cast("Mapping[str, Any]", value)
|
|
87
|
+
if part not in current:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
value = current[part]
|
|
91
|
+
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _as_string_list(value: Any) -> list[str]:
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
return [value]
|
|
98
|
+
|
|
99
|
+
if isinstance(value, (list, tuple)):
|
|
100
|
+
items = cast("list[Any] | tuple[Any, ...]", value)
|
|
101
|
+
return [str(item) for item in items]
|
|
102
|
+
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _optional_str(value: Any) -> str | None:
|
|
107
|
+
return value if isinstance(value, str) else None
|