mcp-hangar 0.2.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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
mcp_hangar/context.py ADDED
@@ -0,0 +1,178 @@
1
+ """Request context management using contextvars.
2
+
3
+ This module provides utilities for binding contextual information to log entries
4
+ within a request scope. All logs emitted during request processing will automatically
5
+ include the bound context (request_id, server_name, tool_name, etc.).
6
+
7
+ Usage:
8
+ from mcp_hangar.context import bind_request_context, clear_request_context
9
+
10
+ async def handle_request(request):
11
+ bind_request_context(
12
+ request_id=request.id,
13
+ server_name="filesystem",
14
+ tool_name="read_file",
15
+ )
16
+ try:
17
+ # All logs in this scope will include the bound context
18
+ logger.info("processing_request")
19
+ result = await process(request)
20
+ logger.info("request_completed")
21
+ return result
22
+ finally:
23
+ clear_request_context()
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from contextvars import ContextVar
29
+ from typing import Any
30
+ import uuid
31
+
32
+ import structlog
33
+
34
+ # Context variables for request-scoped data
35
+ request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
36
+ server_name_var: ContextVar[str | None] = ContextVar("server_name", default=None)
37
+ tool_name_var: ContextVar[str | None] = ContextVar("tool_name", default=None)
38
+ user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
39
+
40
+
41
+ def generate_request_id() -> str:
42
+ """Generate a short unique request ID."""
43
+ return uuid.uuid4().hex[:12]
44
+
45
+
46
+ def get_request_id() -> str | None:
47
+ """Get the current request ID from context."""
48
+ return request_id_var.get()
49
+
50
+
51
+ def bind_request_context(
52
+ request_id: str | None = None,
53
+ server_name: str | None = None,
54
+ tool_name: str | None = None,
55
+ user_id: str | None = None,
56
+ **extra: Any,
57
+ ) -> str:
58
+ """Bind contextual information to all logs in the current scope.
59
+
60
+ This function sets context variables and binds them to structlog's contextvars,
61
+ ensuring all subsequent log entries include this information.
62
+
63
+ Args:
64
+ request_id: Unique identifier for the request. Auto-generated if not provided.
65
+ server_name: Name of the target server/provider.
66
+ tool_name: Name of the tool being invoked.
67
+ user_id: Optional user identifier for attribution.
68
+ **extra: Additional key-value pairs to include in log context.
69
+
70
+ Returns:
71
+ The request_id (either provided or generated).
72
+
73
+ Example:
74
+ request_id = bind_request_context(
75
+ server_name="filesystem",
76
+ tool_name="read_file",
77
+ path="/tmp/test.txt",
78
+ )
79
+ """
80
+ # Generate request_id if not provided
81
+ if request_id is None:
82
+ request_id = generate_request_id()
83
+
84
+ # Set context variables
85
+ request_id_var.set(request_id)
86
+ if server_name is not None:
87
+ server_name_var.set(server_name)
88
+ if tool_name is not None:
89
+ tool_name_var.set(tool_name)
90
+ if user_id is not None:
91
+ user_id_var.set(user_id)
92
+
93
+ # Build context dict for structlog
94
+ context: dict[str, Any] = {"request_id": request_id}
95
+ if server_name is not None:
96
+ context["server"] = server_name
97
+ if tool_name is not None:
98
+ context["tool"] = tool_name
99
+ if user_id is not None:
100
+ context["user_id"] = user_id
101
+ context.update(extra)
102
+
103
+ # Clear any previous context and bind new one
104
+ structlog.contextvars.clear_contextvars()
105
+ structlog.contextvars.bind_contextvars(**context)
106
+
107
+ return request_id
108
+
109
+
110
+ def update_request_context(**kwargs: Any) -> None:
111
+ """Update the current request context with additional information.
112
+
113
+ This is useful for adding information that becomes available during processing,
114
+ such as the routed server name or response status.
115
+
116
+ Args:
117
+ **kwargs: Key-value pairs to add to the context.
118
+
119
+ Example:
120
+ update_request_context(routed_to="memory-server", status="success")
121
+ """
122
+ structlog.contextvars.bind_contextvars(**kwargs)
123
+
124
+
125
+ def clear_request_context() -> None:
126
+ """Clear all request-scoped context.
127
+
128
+ Should be called at the end of request processing to prevent context leakage.
129
+ """
130
+ structlog.contextvars.clear_contextvars()
131
+ request_id_var.set(None)
132
+ server_name_var.set(None)
133
+ tool_name_var.set(None)
134
+ user_id_var.set(None)
135
+
136
+
137
+ class RequestContextManager:
138
+ """Context manager for automatic request context handling.
139
+
140
+ Example:
141
+ async with RequestContextManager(tool_name="read_file") as ctx:
142
+ logger.info("processing")
143
+ # ctx.request_id is available
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ request_id: str | None = None,
149
+ server_name: str | None = None,
150
+ tool_name: str | None = None,
151
+ user_id: str | None = None,
152
+ **extra: Any,
153
+ ):
154
+ self._request_id = request_id
155
+ self._server_name = server_name
156
+ self._tool_name = tool_name
157
+ self._user_id = user_id
158
+ self._extra = extra
159
+ self.request_id: str | None = None
160
+
161
+ def __enter__(self) -> "RequestContextManager":
162
+ self.request_id = bind_request_context(
163
+ request_id=self._request_id,
164
+ server_name=self._server_name,
165
+ tool_name=self._tool_name,
166
+ user_id=self._user_id,
167
+ **self._extra,
168
+ )
169
+ return self
170
+
171
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
172
+ clear_request_context()
173
+
174
+ async def __aenter__(self) -> "RequestContextManager":
175
+ return self.__enter__()
176
+
177
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
178
+ self.__exit__(exc_type, exc_val, exc_tb)
@@ -0,0 +1,117 @@
1
+ """Domain layer - Core business logic, events, and exceptions."""
2
+
3
+ from .events import (
4
+ DomainEvent,
5
+ HealthCheckFailed,
6
+ HealthCheckPassed,
7
+ ProviderDegraded,
8
+ ProviderIdleDetected,
9
+ ProviderStarted,
10
+ ProviderStateChanged,
11
+ ProviderStopped,
12
+ ToolInvocationCompleted,
13
+ ToolInvocationFailed,
14
+ ToolInvocationRequested,
15
+ )
16
+ from .exceptions import ( # Client; Base; Provider; Rate Limiting; Tool; Validation
17
+ CannotStartProviderError,
18
+ ClientError,
19
+ ClientNotConnectedError,
20
+ ClientTimeoutError,
21
+ ConfigurationError,
22
+ InvalidStateTransitionError,
23
+ MCPError,
24
+ ProviderDegradedError,
25
+ ProviderError,
26
+ ProviderNotFoundError,
27
+ ProviderNotReadyError,
28
+ ProviderStartError,
29
+ RateLimitExceeded,
30
+ ToolError,
31
+ ToolInvocationError,
32
+ ToolNotFoundError,
33
+ ToolTimeoutError,
34
+ ValidationError,
35
+ )
36
+ from .repository import InMemoryProviderRepository, IProviderRepository
37
+ from .value_objects import ( # Configuration; Timing; Identity; Enums; Tool Arguments
38
+ CommandLine,
39
+ CorrelationId,
40
+ DockerImage,
41
+ Endpoint,
42
+ EnvironmentVariables,
43
+ HealthCheckInterval,
44
+ HealthStatus,
45
+ IdleTTL,
46
+ MaxConsecutiveFailures,
47
+ ProviderConfig,
48
+ ProviderId,
49
+ ProviderMode,
50
+ ProviderState,
51
+ TimeoutSeconds,
52
+ ToolArguments,
53
+ ToolName,
54
+ )
55
+
56
+ __all__ = [
57
+ # Events
58
+ "DomainEvent",
59
+ "ProviderStarted",
60
+ "ProviderStopped",
61
+ "ProviderDegraded",
62
+ "ProviderStateChanged",
63
+ "ToolInvocationRequested",
64
+ "ToolInvocationCompleted",
65
+ "ToolInvocationFailed",
66
+ "HealthCheckPassed",
67
+ "HealthCheckFailed",
68
+ "ProviderIdleDetected",
69
+ # Enums
70
+ "ProviderState",
71
+ "ProviderMode",
72
+ "HealthStatus",
73
+ # Value Objects - Identity
74
+ "ProviderId",
75
+ "ToolName",
76
+ "CorrelationId",
77
+ # Value Objects - Configuration
78
+ "CommandLine",
79
+ "DockerImage",
80
+ "Endpoint",
81
+ "EnvironmentVariables",
82
+ "ProviderConfig",
83
+ # Value Objects - Timing
84
+ "IdleTTL",
85
+ "HealthCheckInterval",
86
+ "MaxConsecutiveFailures",
87
+ "TimeoutSeconds",
88
+ # Value Objects - Tool Arguments
89
+ "ToolArguments",
90
+ # Exceptions - Base
91
+ "MCPError",
92
+ # Exceptions - Provider
93
+ "ProviderError",
94
+ "ProviderNotFoundError",
95
+ "ProviderStartError",
96
+ "ProviderDegradedError",
97
+ "CannotStartProviderError",
98
+ "ProviderNotReadyError",
99
+ "InvalidStateTransitionError",
100
+ # Exceptions - Tool
101
+ "ToolError",
102
+ "ToolNotFoundError",
103
+ "ToolInvocationError",
104
+ "ToolTimeoutError",
105
+ # Exceptions - Client
106
+ "ClientError",
107
+ "ClientNotConnectedError",
108
+ "ClientTimeoutError",
109
+ # Exceptions - Validation
110
+ "ValidationError",
111
+ "ConfigurationError",
112
+ # Exceptions - Rate Limiting
113
+ "RateLimitExceeded",
114
+ # Repository
115
+ "IProviderRepository",
116
+ "InMemoryProviderRepository",
117
+ ]
@@ -0,0 +1,57 @@
1
+ """Domain contracts - interfaces for external dependencies.
2
+
3
+ This module defines contracts (abstract interfaces) that the domain layer
4
+ depends on. Implementations are provided by the infrastructure layer.
5
+ """
6
+
7
+ from .authentication import ApiKeyMetadata, AuthRequest, IApiKeyStore, IAuthenticator, ITokenValidator
8
+ from .authorization import AuthorizationRequest, AuthorizationResult, IAuthorizer, IPolicyEngine, IRoleStore
9
+ from .event_store import ConcurrencyError, IEventStore, NullEventStore, StreamNotFoundError
10
+ from .metrics_publisher import IMetricsPublisher
11
+ from .persistence import (
12
+ AuditAction,
13
+ AuditEntry,
14
+ ConcurrentModificationError,
15
+ ConfigurationNotFoundError,
16
+ IAuditRepository,
17
+ IProviderConfigRepository,
18
+ IRecoveryService,
19
+ IUnitOfWork,
20
+ PersistenceError,
21
+ ProviderConfigSnapshot,
22
+ )
23
+ from .provider_runtime import ProviderRuntime
24
+
25
+ __all__ = [
26
+ # Authentication contracts
27
+ "ApiKeyMetadata",
28
+ "AuthRequest",
29
+ "IApiKeyStore",
30
+ "IAuthenticator",
31
+ "ITokenValidator",
32
+ # Authorization contracts
33
+ "AuthorizationRequest",
34
+ "AuthorizationResult",
35
+ "IAuthorizer",
36
+ "IPolicyEngine",
37
+ "IRoleStore",
38
+ # Event store
39
+ "ConcurrencyError",
40
+ "IEventStore",
41
+ "NullEventStore",
42
+ "StreamNotFoundError",
43
+ # Metrics
44
+ "IMetricsPublisher",
45
+ # Persistence
46
+ "AuditAction",
47
+ "AuditEntry",
48
+ "ConcurrentModificationError",
49
+ "ConfigurationNotFoundError",
50
+ "IAuditRepository",
51
+ "IProviderConfigRepository",
52
+ "IRecoveryService",
53
+ "IUnitOfWork",
54
+ "PersistenceError",
55
+ "ProviderConfigSnapshot",
56
+ "ProviderRuntime",
57
+ ]
@@ -0,0 +1,225 @@
1
+ """Authentication contracts (ports) for the domain layer.
2
+
3
+ These protocols define the interfaces for authentication components.
4
+ Infrastructure layer provides concrete implementations.
5
+ """
6
+
7
+ from abc import abstractmethod
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from ..value_objects import Principal
13
+
14
+
15
+ @dataclass
16
+ class AuthRequest:
17
+ """Normalized authentication request.
18
+
19
+ Contains all information needed to authenticate a request,
20
+ abstracted from the transport layer (HTTP, gRPC, etc.).
21
+
22
+ Attributes:
23
+ headers: Request headers as a case-insensitive dict.
24
+ source_ip: IP address of the request origin.
25
+ method: HTTP method or equivalent (GET, POST, etc.).
26
+ path: Request path/endpoint.
27
+ metadata: Additional context for authentication decisions.
28
+ """
29
+
30
+ headers: dict[str, str]
31
+ source_ip: str
32
+ method: str = ""
33
+ path: str = ""
34
+ metadata: dict = field(default_factory=dict)
35
+
36
+
37
+ @dataclass
38
+ class ApiKeyMetadata:
39
+ """Metadata about an API key (never contains the actual key).
40
+
41
+ Attributes:
42
+ key_id: Unique identifier for the key (not the key itself).
43
+ name: Human-readable name for the key.
44
+ principal_id: Principal ID this key authenticates as.
45
+ created_at: When the key was created.
46
+ expires_at: Optional expiration datetime.
47
+ last_used_at: When the key was last used for authentication.
48
+ revoked: Whether the key has been revoked.
49
+ """
50
+
51
+ key_id: str
52
+ name: str
53
+ principal_id: str
54
+ created_at: datetime
55
+ expires_at: datetime | None = None
56
+ last_used_at: datetime | None = None
57
+ revoked: bool = False
58
+
59
+ @property
60
+ def is_expired(self) -> bool:
61
+ """Check if the key has expired."""
62
+ if self.expires_at is None:
63
+ return False
64
+ from datetime import timezone
65
+
66
+ return datetime.now(timezone.utc) > self.expires_at
67
+
68
+ @property
69
+ def is_valid(self) -> bool:
70
+ """Check if the key is valid (not revoked and not expired)."""
71
+ return not self.revoked and not self.is_expired
72
+
73
+
74
+ @runtime_checkable
75
+ class IAuthenticator(Protocol):
76
+ """Extracts and validates credentials from a request.
77
+
78
+ Authenticators are responsible for:
79
+ 1. Detecting if they can handle a request (supports())
80
+ 2. Extracting credentials from the request
81
+ 3. Validating credentials
82
+ 4. Returning an authenticated Principal
83
+
84
+ Multiple authenticators can be chained, with the first supporting
85
+ authenticator handling the request.
86
+ """
87
+
88
+ @abstractmethod
89
+ def authenticate(self, request: AuthRequest) -> Principal:
90
+ """Authenticate a request and return the principal.
91
+
92
+ Args:
93
+ request: The incoming request with credentials.
94
+
95
+ Returns:
96
+ Authenticated Principal.
97
+
98
+ Raises:
99
+ InvalidCredentialsError: If credentials are invalid.
100
+ ExpiredCredentialsError: If credentials have expired.
101
+ RevokedCredentialsError: If credentials have been revoked.
102
+ MissingCredentialsError: If required credentials are missing.
103
+ """
104
+ ...
105
+
106
+ @abstractmethod
107
+ def supports(self, request: AuthRequest) -> bool:
108
+ """Check if this authenticator can handle the request.
109
+
110
+ Args:
111
+ request: The incoming request to check.
112
+
113
+ Returns:
114
+ True if this authenticator should handle the request.
115
+ """
116
+ ...
117
+
118
+
119
+ @runtime_checkable
120
+ class ITokenValidator(Protocol):
121
+ """Validates tokens (JWT, API keys, etc.).
122
+
123
+ Token validators handle the cryptographic verification of tokens
124
+ without the transport-layer concerns of extracting them from requests.
125
+ """
126
+
127
+ @abstractmethod
128
+ def validate(self, token: str) -> dict:
129
+ """Validate token and return claims.
130
+
131
+ Args:
132
+ token: The token string to validate.
133
+
134
+ Returns:
135
+ Dictionary of validated claims from the token.
136
+
137
+ Raises:
138
+ InvalidCredentialsError: If token is invalid or malformed.
139
+ ExpiredCredentialsError: If token has expired.
140
+ """
141
+ ...
142
+
143
+
144
+ @runtime_checkable
145
+ class IApiKeyStore(Protocol):
146
+ """Storage for API keys.
147
+
148
+ Handles CRUD operations for API keys. Keys are stored as hashes,
149
+ never in plaintext. The raw key is only returned once during creation.
150
+ """
151
+
152
+ @abstractmethod
153
+ def get_principal_for_key(self, key_hash: str) -> Principal | None:
154
+ """Look up principal for an API key hash.
155
+
156
+ Args:
157
+ key_hash: SHA-256 hash of the API key.
158
+
159
+ Returns:
160
+ Principal associated with the key, or None if not found.
161
+
162
+ Raises:
163
+ ExpiredCredentialsError: If the key has expired.
164
+ RevokedCredentialsError: If the key has been revoked.
165
+ """
166
+ ...
167
+
168
+ @abstractmethod
169
+ def create_key(
170
+ self,
171
+ principal_id: str,
172
+ name: str,
173
+ expires_at: datetime | None = None,
174
+ groups: frozenset[str] | None = None,
175
+ tenant_id: str | None = None,
176
+ ) -> str:
177
+ """Create a new API key.
178
+
179
+ Args:
180
+ principal_id: ID for the principal this key authenticates as.
181
+ name: Human-readable name for the key.
182
+ expires_at: Optional expiration datetime.
183
+ groups: Optional groups to assign to the principal.
184
+ tenant_id: Optional tenant ID for multi-tenancy.
185
+
186
+ Returns:
187
+ The raw API key (only shown once!).
188
+ """
189
+ ...
190
+
191
+ @abstractmethod
192
+ def revoke_key(self, key_id: str) -> bool:
193
+ """Revoke an API key.
194
+
195
+ Args:
196
+ key_id: Unique identifier of the key to revoke.
197
+
198
+ Returns:
199
+ True if key was found and revoked, False if not found.
200
+ """
201
+ ...
202
+
203
+ @abstractmethod
204
+ def list_keys(self, principal_id: str) -> list[ApiKeyMetadata]:
205
+ """List API keys for a principal (metadata only, not the keys).
206
+
207
+ Args:
208
+ principal_id: ID of the principal to list keys for.
209
+
210
+ Returns:
211
+ List of ApiKeyMetadata for the principal's keys.
212
+ """
213
+ ...
214
+
215
+ @abstractmethod
216
+ def count_keys(self, principal_id: str) -> int:
217
+ """Count active (non-revoked) API keys for a principal.
218
+
219
+ Args:
220
+ principal_id: ID of the principal to count keys for.
221
+
222
+ Returns:
223
+ Number of active API keys.
224
+ """
225
+ ...