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,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