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,388 @@
|
|
|
1
|
+
"""API Key authentication implementation.
|
|
2
|
+
|
|
3
|
+
Provides authenticator and in-memory store for API key authentication.
|
|
4
|
+
Keys are stored as SHA-256 hashes, never in plaintext.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import hashlib
|
|
9
|
+
import secrets
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from ...domain.contracts.authentication import ApiKeyMetadata, AuthRequest, IApiKeyStore, IAuthenticator
|
|
15
|
+
from ...domain.exceptions import ExpiredCredentialsError, InvalidCredentialsError, RevokedCredentialsError
|
|
16
|
+
from ...domain.value_objects import Principal, PrincipalId, PrincipalType
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
# Maximum allowed API key length to prevent DoS
|
|
21
|
+
MAX_API_KEY_LENGTH = 256
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiKeyAuthenticator(IAuthenticator):
|
|
25
|
+
"""Authenticates requests using API keys.
|
|
26
|
+
|
|
27
|
+
API keys are expected in the X-API-Key header (configurable).
|
|
28
|
+
Keys must start with the configured prefix (default: 'mcp_') for
|
|
29
|
+
easy identification and log redaction.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
HEADER_NAME: Name of the header containing the API key.
|
|
33
|
+
PREFIX: Required prefix for API keys.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
HEADER_NAME = "X-API-Key"
|
|
37
|
+
PREFIX = "mcp_"
|
|
38
|
+
|
|
39
|
+
def __init__(self, key_store: IApiKeyStore, header_name: str | None = None):
|
|
40
|
+
"""Initialize the authenticator.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
key_store: Storage backend for API keys.
|
|
44
|
+
header_name: Optional custom header name (default: X-API-Key).
|
|
45
|
+
"""
|
|
46
|
+
self._key_store = key_store
|
|
47
|
+
self._header_name = header_name or self.HEADER_NAME
|
|
48
|
+
|
|
49
|
+
def supports(self, request: AuthRequest) -> bool:
|
|
50
|
+
"""Check if request has API key header."""
|
|
51
|
+
# Check both original and lowercase versions for case-insensitive lookup
|
|
52
|
+
return self._header_name in request.headers or self._header_name.lower() in request.headers
|
|
53
|
+
|
|
54
|
+
def authenticate(self, request: AuthRequest) -> Principal:
|
|
55
|
+
"""Authenticate using API key.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
request: The authentication request with headers.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Authenticated Principal.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
InvalidCredentialsError: If key format is invalid or key not found.
|
|
65
|
+
ExpiredCredentialsError: If key has expired.
|
|
66
|
+
RevokedCredentialsError: If key has been revoked.
|
|
67
|
+
"""
|
|
68
|
+
# Case-insensitive header lookup
|
|
69
|
+
key = request.headers.get(self._header_name) or request.headers.get(self._header_name.lower()) or ""
|
|
70
|
+
|
|
71
|
+
if not key:
|
|
72
|
+
raise InvalidCredentialsError(
|
|
73
|
+
message="API key header is empty",
|
|
74
|
+
auth_method="api_key",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Validate key length to prevent DoS
|
|
78
|
+
if len(key) > MAX_API_KEY_LENGTH:
|
|
79
|
+
raise InvalidCredentialsError(
|
|
80
|
+
message="API key exceeds maximum length",
|
|
81
|
+
auth_method="api_key",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not key.startswith(self.PREFIX):
|
|
85
|
+
raise InvalidCredentialsError(
|
|
86
|
+
message=f"Invalid API key format: must start with '{self.PREFIX}'",
|
|
87
|
+
auth_method="api_key",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
key_hash = self._hash_key(key)
|
|
91
|
+
|
|
92
|
+
# Use constant-time lookup to prevent timing attacks
|
|
93
|
+
principal = self._key_store.get_principal_for_key(key_hash)
|
|
94
|
+
|
|
95
|
+
if principal is None:
|
|
96
|
+
# Log with minimal key info (only prefix indicator, no actual key content)
|
|
97
|
+
logger.warning(
|
|
98
|
+
"api_key_not_found",
|
|
99
|
+
key_length=len(key),
|
|
100
|
+
source_ip=request.source_ip,
|
|
101
|
+
)
|
|
102
|
+
raise InvalidCredentialsError(
|
|
103
|
+
message="Invalid API key",
|
|
104
|
+
auth_method="api_key",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
logger.info(
|
|
108
|
+
"api_key_authenticated",
|
|
109
|
+
principal_id=principal.id.value,
|
|
110
|
+
principal_type=principal.type.value,
|
|
111
|
+
source_ip=request.source_ip,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return principal
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _hash_key(key: str) -> str:
|
|
118
|
+
"""Hash API key for storage lookup.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
key: The raw API key.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
SHA-256 hash of the key.
|
|
125
|
+
"""
|
|
126
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def generate_key(cls) -> str:
|
|
130
|
+
"""Generate a new API key.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A new API key with the configured prefix.
|
|
134
|
+
"""
|
|
135
|
+
random_part = secrets.token_urlsafe(32)
|
|
136
|
+
return f"{cls.PREFIX}{random_part}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class InMemoryApiKeyStore(IApiKeyStore):
|
|
140
|
+
"""In-memory API key store for development/testing.
|
|
141
|
+
|
|
142
|
+
WARNING: Keys are lost on restart. Use a persistent store
|
|
143
|
+
(e.g., SQLite, PostgreSQL) for production.
|
|
144
|
+
|
|
145
|
+
This implementation is thread-safe using a reentrant lock.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
# Maximum number of keys per principal to prevent abuse
|
|
149
|
+
MAX_KEYS_PER_PRINCIPAL = 100
|
|
150
|
+
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
"""Initialize the in-memory store."""
|
|
153
|
+
self._lock = threading.RLock()
|
|
154
|
+
# key_hash -> (metadata, principal)
|
|
155
|
+
self._keys: dict[str, tuple[ApiKeyMetadata, Principal]] = {}
|
|
156
|
+
# principal_id -> list of key_ids
|
|
157
|
+
self._principal_keys: dict[str, list[str]] = {}
|
|
158
|
+
|
|
159
|
+
def get_principal_for_key(self, key_hash: str) -> Principal | None:
|
|
160
|
+
"""Look up principal for an API key hash.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
key_hash: SHA-256 hash of the API key.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Principal if found and valid, None if not found.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ExpiredCredentialsError: If the key has expired.
|
|
170
|
+
RevokedCredentialsError: If the key has been revoked.
|
|
171
|
+
"""
|
|
172
|
+
with self._lock:
|
|
173
|
+
if key_hash not in self._keys:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
metadata, principal = self._keys[key_hash]
|
|
177
|
+
|
|
178
|
+
if metadata.revoked:
|
|
179
|
+
raise RevokedCredentialsError(
|
|
180
|
+
message="API key has been revoked",
|
|
181
|
+
auth_method="api_key",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if metadata.expires_at and metadata.expires_at < datetime.now(timezone.utc):
|
|
185
|
+
raise ExpiredCredentialsError(
|
|
186
|
+
message="API key has expired",
|
|
187
|
+
auth_method="api_key",
|
|
188
|
+
expired_at=metadata.expires_at.timestamp(),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Update last_used_at
|
|
192
|
+
# Note: Creating new metadata object since ApiKeyMetadata is a dataclass
|
|
193
|
+
# In production, this would be an atomic update in the database
|
|
194
|
+
updated_metadata = ApiKeyMetadata(
|
|
195
|
+
key_id=metadata.key_id,
|
|
196
|
+
name=metadata.name,
|
|
197
|
+
principal_id=metadata.principal_id,
|
|
198
|
+
created_at=metadata.created_at,
|
|
199
|
+
expires_at=metadata.expires_at,
|
|
200
|
+
last_used_at=datetime.now(timezone.utc),
|
|
201
|
+
revoked=metadata.revoked,
|
|
202
|
+
)
|
|
203
|
+
self._keys[key_hash] = (updated_metadata, principal)
|
|
204
|
+
|
|
205
|
+
return principal
|
|
206
|
+
|
|
207
|
+
def create_key(
|
|
208
|
+
self,
|
|
209
|
+
principal_id: str,
|
|
210
|
+
name: str,
|
|
211
|
+
expires_at: datetime | None = None,
|
|
212
|
+
groups: frozenset[str] | None = None,
|
|
213
|
+
tenant_id: str | None = None,
|
|
214
|
+
created_by: str = "system",
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Create a new API key.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
principal_id: ID for the principal this key authenticates as.
|
|
220
|
+
name: Human-readable name for the key.
|
|
221
|
+
expires_at: Optional expiration datetime.
|
|
222
|
+
groups: Optional groups to assign to the principal.
|
|
223
|
+
tenant_id: Optional tenant ID for multi-tenancy.
|
|
224
|
+
created_by: Principal creating the key.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The raw API key (only shown once!).
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: If principal has reached maximum number of keys.
|
|
231
|
+
"""
|
|
232
|
+
with self._lock:
|
|
233
|
+
# Check key limit per principal
|
|
234
|
+
existing_keys = self._principal_keys.get(principal_id, [])
|
|
235
|
+
if len(existing_keys) >= self.MAX_KEYS_PER_PRINCIPAL:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"Principal {principal_id} has reached maximum number of API keys "
|
|
238
|
+
f"({self.MAX_KEYS_PER_PRINCIPAL})"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
raw_key = ApiKeyAuthenticator.generate_key()
|
|
242
|
+
key_hash = ApiKeyAuthenticator._hash_key(raw_key)
|
|
243
|
+
key_id = secrets.token_urlsafe(8)
|
|
244
|
+
|
|
245
|
+
now = datetime.now(timezone.utc)
|
|
246
|
+
metadata = ApiKeyMetadata(
|
|
247
|
+
key_id=key_id,
|
|
248
|
+
name=name,
|
|
249
|
+
principal_id=principal_id,
|
|
250
|
+
created_at=now,
|
|
251
|
+
expires_at=expires_at,
|
|
252
|
+
last_used_at=None,
|
|
253
|
+
revoked=False,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
principal = Principal(
|
|
257
|
+
id=PrincipalId(principal_id),
|
|
258
|
+
type=PrincipalType.SERVICE_ACCOUNT,
|
|
259
|
+
tenant_id=tenant_id,
|
|
260
|
+
groups=groups or frozenset(),
|
|
261
|
+
metadata={"key_id": key_id, "key_name": name},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
self._keys[key_hash] = (metadata, principal)
|
|
265
|
+
|
|
266
|
+
if principal_id not in self._principal_keys:
|
|
267
|
+
self._principal_keys[principal_id] = []
|
|
268
|
+
self._principal_keys[principal_id].append(key_id)
|
|
269
|
+
|
|
270
|
+
logger.info(
|
|
271
|
+
"api_key_created",
|
|
272
|
+
key_id=key_id,
|
|
273
|
+
principal_id=principal_id,
|
|
274
|
+
name=name,
|
|
275
|
+
expires_at=expires_at.isoformat() if expires_at else None,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return raw_key # Only returned once!
|
|
279
|
+
|
|
280
|
+
def revoke_key(self, key_id: str, revoked_by: str = "system", reason: str = "") -> bool:
|
|
281
|
+
"""Revoke an API key.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
key_id: Unique identifier of the key to revoke.
|
|
285
|
+
revoked_by: Principal revoking the key.
|
|
286
|
+
reason: Reason for revocation.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
True if key was found and revoked, False if not found.
|
|
290
|
+
"""
|
|
291
|
+
with self._lock:
|
|
292
|
+
for key_hash, (metadata, principal) in self._keys.items():
|
|
293
|
+
if metadata.key_id == key_id:
|
|
294
|
+
# Create new metadata with revoked=True
|
|
295
|
+
updated_metadata = ApiKeyMetadata(
|
|
296
|
+
key_id=metadata.key_id,
|
|
297
|
+
name=metadata.name,
|
|
298
|
+
principal_id=metadata.principal_id,
|
|
299
|
+
created_at=metadata.created_at,
|
|
300
|
+
expires_at=metadata.expires_at,
|
|
301
|
+
last_used_at=metadata.last_used_at,
|
|
302
|
+
revoked=True,
|
|
303
|
+
)
|
|
304
|
+
self._keys[key_hash] = (updated_metadata, principal)
|
|
305
|
+
|
|
306
|
+
logger.info(
|
|
307
|
+
"api_key_revoked",
|
|
308
|
+
key_id=key_id,
|
|
309
|
+
principal_id=metadata.principal_id,
|
|
310
|
+
revoked_by=revoked_by,
|
|
311
|
+
reason=reason,
|
|
312
|
+
)
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
logger.warning("api_key_revoke_not_found", key_id=key_id)
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
def list_keys(self, principal_id: str) -> list[ApiKeyMetadata]:
|
|
319
|
+
"""List API keys for a principal (metadata only, not the keys).
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
principal_id: ID of the principal to list keys for.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
List of ApiKeyMetadata for the principal's keys.
|
|
326
|
+
"""
|
|
327
|
+
with self._lock:
|
|
328
|
+
result: list[ApiKeyMetadata] = []
|
|
329
|
+
for metadata, principal in self._keys.values():
|
|
330
|
+
if metadata.principal_id == principal_id:
|
|
331
|
+
result.append(metadata)
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
def get_key_by_id(self, key_id: str) -> ApiKeyMetadata | None:
|
|
335
|
+
"""Get key metadata by key_id (for admin operations).
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
key_id: The key identifier.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
ApiKeyMetadata if found, None otherwise.
|
|
342
|
+
"""
|
|
343
|
+
with self._lock:
|
|
344
|
+
for metadata, _ in self._keys.values():
|
|
345
|
+
if metadata.key_id == key_id:
|
|
346
|
+
return metadata
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def count_keys(self, principal_id: str) -> int:
|
|
350
|
+
"""Count active (non-revoked) API keys for a principal.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
principal_id: ID of the principal to count keys for.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Number of active API keys.
|
|
357
|
+
"""
|
|
358
|
+
with self._lock:
|
|
359
|
+
count = 0
|
|
360
|
+
for metadata, _ in self._keys.values():
|
|
361
|
+
if metadata.principal_id == principal_id and not metadata.revoked:
|
|
362
|
+
if metadata.expires_at is None or metadata.expires_at > datetime.now(timezone.utc):
|
|
363
|
+
count += 1
|
|
364
|
+
return count
|
|
365
|
+
|
|
366
|
+
def count_all_keys(self) -> int:
|
|
367
|
+
"""Get total number of keys in the store.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Number of API keys (including revoked).
|
|
371
|
+
"""
|
|
372
|
+
with self._lock:
|
|
373
|
+
return len(self._keys)
|
|
374
|
+
|
|
375
|
+
def count_all_active_keys(self) -> int:
|
|
376
|
+
"""Get number of active (non-revoked, non-expired) keys.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Number of active API keys.
|
|
380
|
+
"""
|
|
381
|
+
with self._lock:
|
|
382
|
+
now = datetime.now(timezone.utc)
|
|
383
|
+
count = 0
|
|
384
|
+
for metadata, _ in self._keys.values():
|
|
385
|
+
if not metadata.revoked:
|
|
386
|
+
if metadata.expires_at is None or metadata.expires_at > now:
|
|
387
|
+
count += 1
|
|
388
|
+
return count
|