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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|