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
@@ -0,0 +1,152 @@
1
+ """Circuit Breaker pattern implementation.
2
+
3
+ The Circuit Breaker pattern prevents cascading failures by stopping
4
+ requests to a failing service and allowing it time to recover.
5
+
6
+ States:
7
+ - CLOSED: Normal operation, requests pass through
8
+ - OPEN: Failing, all requests rejected immediately
9
+ - HALF_OPEN: Testing if service recovered (not implemented - we auto-reset)
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ import threading
15
+ import time
16
+ from typing import Optional
17
+
18
+
19
+ class CircuitState(Enum):
20
+ """Circuit breaker states."""
21
+
22
+ CLOSED = "closed"
23
+ OPEN = "open"
24
+
25
+
26
+ @dataclass
27
+ class CircuitBreakerConfig:
28
+ """Configuration for circuit breaker behavior."""
29
+
30
+ failure_threshold: int = 10
31
+ reset_timeout_s: float = 60.0
32
+
33
+ def __post_init__(self):
34
+ self.failure_threshold = max(1, self.failure_threshold)
35
+ self.reset_timeout_s = max(1.0, self.reset_timeout_s)
36
+
37
+
38
+ class CircuitBreaker:
39
+ """
40
+ Circuit breaker that opens after reaching failure threshold.
41
+
42
+ Thread-safe implementation that tracks failures and automatically
43
+ resets after a timeout period.
44
+ """
45
+
46
+ def __init__(self, config: Optional[CircuitBreakerConfig] = None):
47
+ """
48
+ Initialize circuit breaker.
49
+
50
+ Args:
51
+ config: Circuit breaker configuration
52
+ """
53
+ self._config = config or CircuitBreakerConfig()
54
+ self._state = CircuitState.CLOSED
55
+ self._failure_count = 0
56
+ self._opened_at: Optional[float] = None
57
+ self._lock = threading.Lock()
58
+
59
+ @property
60
+ def is_open(self) -> bool:
61
+ """Check if circuit is open (blocking requests)."""
62
+ with self._lock:
63
+ return self._state == CircuitState.OPEN
64
+
65
+ @property
66
+ def state(self) -> CircuitState:
67
+ """Get current circuit state."""
68
+ with self._lock:
69
+ return self._state
70
+
71
+ @property
72
+ def failure_count(self) -> int:
73
+ """Get current failure count."""
74
+ with self._lock:
75
+ return self._failure_count
76
+
77
+ def allow_request(self) -> bool:
78
+ """
79
+ Check if a request should be allowed.
80
+
81
+ If circuit is open, checks if reset timeout has elapsed.
82
+ If so, closes the circuit and allows the request.
83
+
84
+ Returns:
85
+ True if request should proceed, False if circuit is open
86
+ """
87
+ with self._lock:
88
+ if self._state == CircuitState.CLOSED:
89
+ return True
90
+
91
+ # Check if we should try to close
92
+ if self._should_reset():
93
+ self._close()
94
+ return True
95
+
96
+ return False
97
+
98
+ def record_success(self) -> None:
99
+ """Record a successful operation."""
100
+ with self._lock:
101
+ self._failure_count = 0
102
+ if self._state == CircuitState.OPEN:
103
+ self._close()
104
+
105
+ def record_failure(self) -> bool:
106
+ """
107
+ Record a failed operation.
108
+
109
+ Returns:
110
+ True if circuit just opened, False otherwise
111
+ """
112
+ with self._lock:
113
+ self._failure_count += 1
114
+
115
+ if self._state == CircuitState.CLOSED and self._failure_count >= self._config.failure_threshold:
116
+ self._open()
117
+ return True
118
+
119
+ return False
120
+
121
+ def reset(self) -> None:
122
+ """Manually reset the circuit breaker."""
123
+ with self._lock:
124
+ self._close()
125
+
126
+ def _should_reset(self) -> bool:
127
+ """Check if enough time has passed to attempt reset."""
128
+ if self._opened_at is None:
129
+ return True
130
+ return time.time() - self._opened_at >= self._config.reset_timeout_s
131
+
132
+ def _open(self) -> None:
133
+ """Open the circuit (must hold lock)."""
134
+ self._state = CircuitState.OPEN
135
+ self._opened_at = time.time()
136
+
137
+ def _close(self) -> None:
138
+ """Close the circuit (must hold lock)."""
139
+ self._state = CircuitState.CLOSED
140
+ self._failure_count = 0
141
+ self._opened_at = None
142
+
143
+ def to_dict(self) -> dict:
144
+ """Get circuit breaker status as dictionary."""
145
+ with self._lock:
146
+ return {
147
+ "state": self._state.value,
148
+ "is_open": self._state == CircuitState.OPEN,
149
+ "failure_count": self._failure_count,
150
+ "failure_threshold": self._config.failure_threshold,
151
+ "reset_timeout_s": self._config.reset_timeout_s,
152
+ }
@@ -0,0 +1,413 @@
1
+ """Event Sourced API Key aggregate.
2
+
3
+ Implements Event Sourcing pattern for API keys where:
4
+ - State is derived from events, not stored directly
5
+ - All changes are captured as immutable events
6
+ - State can be rebuilt by replaying events
7
+ - Supports snapshots for performance
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from ..events import ApiKeyCreated, ApiKeyRevoked, DomainEvent
15
+ from ..value_objects import Principal, PrincipalId, PrincipalType
16
+ from .aggregate import AggregateRoot
17
+
18
+
19
+ @dataclass
20
+ class ApiKeySnapshot:
21
+ """Snapshot of API key state for faster loading.
22
+
23
+ Attributes:
24
+ key_hash: SHA-256 hash of the key.
25
+ key_id: Unique identifier.
26
+ principal_id: Principal this key authenticates as.
27
+ name: Human-readable name.
28
+ tenant_id: Optional tenant ID.
29
+ groups: Groups assigned to the principal.
30
+ created_at: Creation timestamp.
31
+ expires_at: Optional expiration timestamp.
32
+ last_used_at: Last usage timestamp.
33
+ revoked: Whether the key is revoked.
34
+ revoked_at: When the key was revoked.
35
+ version: Aggregate version.
36
+ """
37
+
38
+ key_hash: str
39
+ key_id: str
40
+ principal_id: str
41
+ name: str
42
+ tenant_id: str | None
43
+ groups: list[str]
44
+ created_at: float
45
+ expires_at: float | None
46
+ last_used_at: float | None
47
+ revoked: bool
48
+ revoked_at: float | None
49
+ version: int
50
+ metadata: dict[str, Any] = field(default_factory=dict)
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ """Convert to dictionary for serialization."""
54
+ return {
55
+ "key_hash": self.key_hash,
56
+ "key_id": self.key_id,
57
+ "principal_id": self.principal_id,
58
+ "name": self.name,
59
+ "tenant_id": self.tenant_id,
60
+ "groups": self.groups,
61
+ "created_at": self.created_at,
62
+ "expires_at": self.expires_at,
63
+ "last_used_at": self.last_used_at,
64
+ "revoked": self.revoked,
65
+ "revoked_at": self.revoked_at,
66
+ "version": self.version,
67
+ "metadata": self.metadata,
68
+ }
69
+
70
+ @classmethod
71
+ def from_dict(cls, d: dict[str, Any]) -> "ApiKeySnapshot":
72
+ """Create from dictionary."""
73
+ return cls(
74
+ key_hash=d["key_hash"],
75
+ key_id=d["key_id"],
76
+ principal_id=d["principal_id"],
77
+ name=d["name"],
78
+ tenant_id=d.get("tenant_id"),
79
+ groups=d.get("groups", []),
80
+ created_at=d["created_at"],
81
+ expires_at=d.get("expires_at"),
82
+ last_used_at=d.get("last_used_at"),
83
+ revoked=d.get("revoked", False),
84
+ revoked_at=d.get("revoked_at"),
85
+ version=d.get("version", 0),
86
+ metadata=d.get("metadata", {}),
87
+ )
88
+
89
+
90
+ class EventSourcedApiKey(AggregateRoot):
91
+ """Event Sourced API Key aggregate.
92
+
93
+ All state changes are recorded as events and state is rebuilt
94
+ by replaying those events. This provides:
95
+ - Complete audit trail
96
+ - Time-travel debugging
97
+ - Event-driven integrations
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ key_hash: str,
103
+ key_id: str,
104
+ principal_id: str,
105
+ name: str,
106
+ tenant_id: str | None = None,
107
+ groups: frozenset[str] | None = None,
108
+ expires_at: datetime | None = None,
109
+ ):
110
+ """Initialize a new API key aggregate.
111
+
112
+ Note: This doesn't record creation event - use create() factory method.
113
+ """
114
+ super().__init__()
115
+
116
+ # Identity
117
+ self._key_hash = key_hash
118
+ self._key_id = key_id
119
+
120
+ # Principal info
121
+ self._principal_id = principal_id
122
+ self._name = name
123
+ self._tenant_id = tenant_id
124
+ self._groups = groups or frozenset()
125
+
126
+ # Timestamps
127
+ self._created_at: datetime | None = None
128
+ self._expires_at = expires_at
129
+ self._last_used_at: datetime | None = None
130
+
131
+ # State
132
+ self._revoked = False
133
+ self._revoked_at: datetime | None = None
134
+ self._metadata: dict[str, Any] = {}
135
+
136
+ @property
137
+ def key_hash(self) -> str:
138
+ return self._key_hash
139
+
140
+ @property
141
+ def key_id(self) -> str:
142
+ return self._key_id
143
+
144
+ @property
145
+ def principal_id(self) -> str:
146
+ return self._principal_id
147
+
148
+ @property
149
+ def name(self) -> str:
150
+ return self._name
151
+
152
+ @property
153
+ def tenant_id(self) -> str | None:
154
+ return self._tenant_id
155
+
156
+ @property
157
+ def groups(self) -> frozenset[str]:
158
+ return self._groups
159
+
160
+ @property
161
+ def created_at(self) -> datetime | None:
162
+ return self._created_at
163
+
164
+ @property
165
+ def expires_at(self) -> datetime | None:
166
+ return self._expires_at
167
+
168
+ @property
169
+ def last_used_at(self) -> datetime | None:
170
+ return self._last_used_at
171
+
172
+ @property
173
+ def is_revoked(self) -> bool:
174
+ return self._revoked
175
+
176
+ @property
177
+ def is_expired(self) -> bool:
178
+ if self._expires_at is None:
179
+ return False
180
+ return datetime.now(timezone.utc) > self._expires_at
181
+
182
+ @property
183
+ def is_valid(self) -> bool:
184
+ return not self._revoked and not self.is_expired
185
+
186
+ # =========================================================================
187
+ # Factory Methods
188
+ # =========================================================================
189
+
190
+ @classmethod
191
+ def create(
192
+ cls,
193
+ key_hash: str,
194
+ key_id: str,
195
+ principal_id: str,
196
+ name: str,
197
+ created_by: str,
198
+ tenant_id: str | None = None,
199
+ groups: frozenset[str] | None = None,
200
+ expires_at: datetime | None = None,
201
+ ) -> "EventSourcedApiKey":
202
+ """Create a new API key and record creation event.
203
+
204
+ Args:
205
+ key_hash: SHA-256 hash of the raw key.
206
+ key_id: Unique identifier for management.
207
+ principal_id: Principal this key authenticates as.
208
+ name: Human-readable name.
209
+ created_by: Who created this key.
210
+ tenant_id: Optional tenant for multi-tenancy.
211
+ groups: Optional groups for the principal.
212
+ expires_at: Optional expiration datetime.
213
+
214
+ Returns:
215
+ New EventSourcedApiKey with ApiKeyCreated event recorded.
216
+ """
217
+ key = cls(
218
+ key_hash=key_hash,
219
+ key_id=key_id,
220
+ principal_id=principal_id,
221
+ name=name,
222
+ tenant_id=tenant_id,
223
+ groups=groups,
224
+ expires_at=expires_at,
225
+ )
226
+
227
+ # Record creation event
228
+ key._record_event(
229
+ ApiKeyCreated(
230
+ key_id=key_id,
231
+ principal_id=principal_id,
232
+ key_name=name,
233
+ expires_at=expires_at.timestamp() if expires_at else None,
234
+ created_by=created_by,
235
+ )
236
+ )
237
+
238
+ # Apply the event to set state
239
+ key._created_at = datetime.now(timezone.utc)
240
+
241
+ return key
242
+
243
+ @classmethod
244
+ def from_events(
245
+ cls,
246
+ key_hash: str,
247
+ key_id: str,
248
+ principal_id: str,
249
+ name: str,
250
+ events: list[DomainEvent],
251
+ tenant_id: str | None = None,
252
+ groups: frozenset[str] | None = None,
253
+ expires_at: datetime | None = None,
254
+ ) -> "EventSourcedApiKey":
255
+ """Rebuild API key state from events.
256
+
257
+ Args:
258
+ key_hash: SHA-256 hash of the key.
259
+ key_id: Unique identifier.
260
+ principal_id: Principal ID.
261
+ name: Key name.
262
+ events: Events to replay.
263
+ tenant_id: Optional tenant.
264
+ groups: Optional groups.
265
+ expires_at: Optional expiration.
266
+
267
+ Returns:
268
+ EventSourcedApiKey with state rebuilt from events.
269
+ """
270
+ key = cls(
271
+ key_hash=key_hash,
272
+ key_id=key_id,
273
+ principal_id=principal_id,
274
+ name=name,
275
+ tenant_id=tenant_id,
276
+ groups=groups,
277
+ expires_at=expires_at,
278
+ )
279
+
280
+ for event in events:
281
+ key._apply_event(event)
282
+
283
+ return key
284
+
285
+ @classmethod
286
+ def from_snapshot(
287
+ cls,
288
+ snapshot: ApiKeySnapshot,
289
+ events: list[DomainEvent] | None = None,
290
+ ) -> "EventSourcedApiKey":
291
+ """Load API key from snapshot and optional subsequent events.
292
+
293
+ Args:
294
+ snapshot: Snapshot to load from.
295
+ events: Optional events after snapshot.
296
+
297
+ Returns:
298
+ EventSourcedApiKey with state from snapshot + events.
299
+ """
300
+ key = cls(
301
+ key_hash=snapshot.key_hash,
302
+ key_id=snapshot.key_id,
303
+ principal_id=snapshot.principal_id,
304
+ name=snapshot.name,
305
+ tenant_id=snapshot.tenant_id,
306
+ groups=frozenset(snapshot.groups),
307
+ expires_at=datetime.fromtimestamp(snapshot.expires_at, tz=timezone.utc) if snapshot.expires_at else None,
308
+ )
309
+
310
+ # Restore state from snapshot
311
+ key._created_at = datetime.fromtimestamp(snapshot.created_at, tz=timezone.utc)
312
+ key._last_used_at = (
313
+ datetime.fromtimestamp(snapshot.last_used_at, tz=timezone.utc) if snapshot.last_used_at else None
314
+ )
315
+ key._revoked = snapshot.revoked
316
+ key._revoked_at = datetime.fromtimestamp(snapshot.revoked_at, tz=timezone.utc) if snapshot.revoked_at else None
317
+ key._metadata = dict(snapshot.metadata)
318
+ key._version = snapshot.version
319
+
320
+ # Apply any events after snapshot
321
+ if events:
322
+ for event in events:
323
+ key._apply_event(event)
324
+
325
+ return key
326
+
327
+ # =========================================================================
328
+ # Commands (mutate state via events)
329
+ # =========================================================================
330
+
331
+ def revoke(self, revoked_by: str, reason: str = "") -> None:
332
+ """Revoke this API key.
333
+
334
+ Args:
335
+ revoked_by: Principal revoking the key.
336
+ reason: Optional reason for revocation.
337
+
338
+ Raises:
339
+ ValueError: If key is already revoked.
340
+ """
341
+ if self._revoked:
342
+ raise ValueError(f"API key {self._key_id} is already revoked")
343
+
344
+ self._record_event(
345
+ ApiKeyRevoked(
346
+ key_id=self._key_id,
347
+ principal_id=self._principal_id,
348
+ revoked_by=revoked_by,
349
+ reason=reason,
350
+ )
351
+ )
352
+
353
+ # Apply immediately
354
+ self._revoked = True
355
+ self._revoked_at = datetime.now(timezone.utc)
356
+
357
+ def record_usage(self) -> None:
358
+ """Record that this key was used for authentication."""
359
+ self._last_used_at = datetime.now(timezone.utc)
360
+
361
+ # =========================================================================
362
+ # Event Application
363
+ # =========================================================================
364
+
365
+ def _apply_event(self, event: DomainEvent) -> None:
366
+ """Apply an event to update state.
367
+
368
+ This is called when replaying events to rebuild state.
369
+ """
370
+ if isinstance(event, ApiKeyCreated):
371
+ self._created_at = datetime.fromtimestamp(event.occurred_at, tz=timezone.utc)
372
+
373
+ elif isinstance(event, ApiKeyRevoked):
374
+ self._revoked = True
375
+ self._revoked_at = datetime.fromtimestamp(event.occurred_at, tz=timezone.utc)
376
+
377
+ self._version += 1
378
+
379
+ # =========================================================================
380
+ # Queries
381
+ # =========================================================================
382
+
383
+ def to_principal(self) -> Principal:
384
+ """Convert to Principal for authentication."""
385
+ return Principal(
386
+ id=PrincipalId(self._principal_id),
387
+ type=PrincipalType.SERVICE_ACCOUNT,
388
+ tenant_id=self._tenant_id,
389
+ groups=self._groups,
390
+ metadata={
391
+ "key_id": self._key_id,
392
+ "key_name": self._name,
393
+ **self._metadata,
394
+ },
395
+ )
396
+
397
+ def create_snapshot(self) -> ApiKeySnapshot:
398
+ """Create a snapshot of current state."""
399
+ return ApiKeySnapshot(
400
+ key_hash=self._key_hash,
401
+ key_id=self._key_id,
402
+ principal_id=self._principal_id,
403
+ name=self._name,
404
+ tenant_id=self._tenant_id,
405
+ groups=list(self._groups),
406
+ created_at=self._created_at.timestamp() if self._created_at else 0,
407
+ expires_at=self._expires_at.timestamp() if self._expires_at else None,
408
+ last_used_at=self._last_used_at.timestamp() if self._last_used_at else None,
409
+ revoked=self._revoked,
410
+ revoked_at=self._revoked_at.timestamp() if self._revoked_at else None,
411
+ version=self._version,
412
+ metadata=dict(self._metadata),
413
+ )