mcp-hangar 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
mcp_hangar/__init__.py ADDED
@@ -0,0 +1,139 @@
1
+ """MCP Registry - Hot-load provider management.
2
+
3
+ This package provides a production-grade registry for managing MCP (Model Context Protocol)
4
+ providers with hot-loading, health monitoring, and automatic garbage collection.
5
+
6
+ New code should use:
7
+ - Provider aggregate from mcp_hangar.domain.model
8
+ - Domain exceptions from mcp_hangar.domain.exceptions
9
+ - Value objects from mcp_hangar.domain.value_objects
10
+
11
+ Legacy imports are maintained for backward compatibility.
12
+ """
13
+
14
+ # Domain layer - preferred imports for new code
15
+ from .domain.exceptions import (
16
+ CannotStartProviderError,
17
+ ClientError,
18
+ ClientNotConnectedError,
19
+ ClientTimeoutError,
20
+ ConfigurationError,
21
+ InvalidStateTransitionError,
22
+ MCPError,
23
+ ProviderDegradedError,
24
+ ProviderError,
25
+ ProviderNotFoundError,
26
+ ProviderNotReadyError,
27
+ ProviderStartError,
28
+ RateLimitExceeded,
29
+ ToolError,
30
+ ToolInvocationError,
31
+ ToolNotFoundError,
32
+ ToolTimeoutError,
33
+ ValidationError,
34
+ )
35
+ from .domain.model import Provider
36
+ from .domain.value_objects import (
37
+ CorrelationId,
38
+ HealthStatus,
39
+ ProviderConfig,
40
+ ProviderId,
41
+ ProviderMode,
42
+ ProviderState,
43
+ ToolArguments,
44
+ ToolName,
45
+ )
46
+
47
+ # UX Improvements - Rich errors, retry, progress
48
+ from .errors import (
49
+ ConfigurationError as HangarConfigurationError,
50
+ HangarError,
51
+ is_retryable,
52
+ map_exception_to_hangar_error,
53
+ NetworkError,
54
+ ProviderCrashError,
55
+ ProviderDegradedError as HangarProviderDegradedError,
56
+ ProviderNotFoundError as HangarProviderNotFoundError,
57
+ ProviderProtocolError,
58
+ RateLimitError,
59
+ TimeoutError as HangarTimeoutError,
60
+ ToolNotFoundError as HangarToolNotFoundError,
61
+ TransientError,
62
+ )
63
+
64
+ # Legacy imports - for backward compatibility (re-exports from domain)
65
+ from .models import ToolSchema
66
+ from .progress import (
67
+ create_progress_tracker,
68
+ get_stage_message,
69
+ ProgressCallback,
70
+ ProgressEvent,
71
+ ProgressStage,
72
+ ProgressTracker,
73
+ )
74
+ from .retry import BackoffStrategy, get_retry_policy, get_retry_store, RetryPolicy, RetryResult, with_retry
75
+ from .stdio_client import StdioClient
76
+
77
+ __all__ = [
78
+ # Domain - Provider aggregate (preferred)
79
+ "Provider",
80
+ # Domain - Value Objects
81
+ "ProviderId",
82
+ "ToolName",
83
+ "CorrelationId",
84
+ "ProviderState",
85
+ "ProviderMode",
86
+ "HealthStatus",
87
+ "ProviderConfig",
88
+ "ToolArguments",
89
+ # Domain - Exceptions
90
+ "MCPError",
91
+ "ProviderError",
92
+ "ProviderNotFoundError",
93
+ "ProviderStartError",
94
+ "ProviderDegradedError",
95
+ "CannotStartProviderError",
96
+ "ProviderNotReadyError",
97
+ "InvalidStateTransitionError",
98
+ "ToolError",
99
+ "ToolNotFoundError",
100
+ "ToolInvocationError",
101
+ "ToolTimeoutError",
102
+ "ClientError",
103
+ "ClientNotConnectedError",
104
+ "ClientTimeoutError",
105
+ "ValidationError",
106
+ "ConfigurationError",
107
+ "RateLimitExceeded",
108
+ # UX - Rich Errors
109
+ "HangarError",
110
+ "TransientError",
111
+ "ProviderProtocolError",
112
+ "ProviderCrashError",
113
+ "NetworkError",
114
+ "HangarConfigurationError",
115
+ "HangarProviderNotFoundError",
116
+ "HangarToolNotFoundError",
117
+ "HangarTimeoutError",
118
+ "RateLimitError",
119
+ "HangarProviderDegradedError",
120
+ "map_exception_to_hangar_error",
121
+ "is_retryable",
122
+ # UX - Retry
123
+ "RetryPolicy",
124
+ "BackoffStrategy",
125
+ "RetryResult",
126
+ "get_retry_policy",
127
+ "get_retry_store",
128
+ "with_retry",
129
+ # UX - Progress
130
+ "ProgressStage",
131
+ "ProgressEvent",
132
+ "ProgressTracker",
133
+ "ProgressCallback",
134
+ "create_progress_tracker",
135
+ "get_stage_message",
136
+ # Legacy - for backward compatibility
137
+ "ToolSchema",
138
+ "StdioClient",
139
+ ]
@@ -0,0 +1 @@
1
+ """Application layer - Use cases and event handlers."""
@@ -0,0 +1,67 @@
1
+ """Command handlers for CQRS."""
2
+
3
+ from .auth_commands import (
4
+ AssignRoleCommand,
5
+ CreateApiKeyCommand,
6
+ CreateCustomRoleCommand,
7
+ ListApiKeysCommand,
8
+ RevokeApiKeyCommand,
9
+ RevokeRoleCommand,
10
+ )
11
+ from .auth_handlers import (
12
+ AssignRoleHandler,
13
+ CreateApiKeyHandler,
14
+ CreateCustomRoleHandler,
15
+ ListApiKeysHandler,
16
+ register_auth_command_handlers,
17
+ RevokeApiKeyHandler,
18
+ RevokeRoleHandler,
19
+ )
20
+ from .commands import (
21
+ Command,
22
+ HealthCheckCommand,
23
+ InvokeToolCommand,
24
+ ShutdownIdleProvidersCommand,
25
+ StartProviderCommand,
26
+ StopProviderCommand,
27
+ )
28
+ from .handlers import (
29
+ HealthCheckHandler,
30
+ InvokeToolHandler,
31
+ register_all_handlers,
32
+ ShutdownIdleProvidersHandler,
33
+ StartProviderHandler,
34
+ StopProviderHandler,
35
+ )
36
+
37
+ __all__ = [
38
+ # Commands
39
+ "Command",
40
+ "StartProviderCommand",
41
+ "StopProviderCommand",
42
+ "InvokeToolCommand",
43
+ "HealthCheckCommand",
44
+ "ShutdownIdleProvidersCommand",
45
+ # Auth Commands
46
+ "CreateApiKeyCommand",
47
+ "RevokeApiKeyCommand",
48
+ "ListApiKeysCommand",
49
+ "AssignRoleCommand",
50
+ "RevokeRoleCommand",
51
+ "CreateCustomRoleCommand",
52
+ # Handlers
53
+ "StartProviderHandler",
54
+ "StopProviderHandler",
55
+ "InvokeToolHandler",
56
+ "HealthCheckHandler",
57
+ "ShutdownIdleProvidersHandler",
58
+ "register_all_handlers",
59
+ # Auth Handlers
60
+ "CreateApiKeyHandler",
61
+ "RevokeApiKeyHandler",
62
+ "ListApiKeysHandler",
63
+ "AssignRoleHandler",
64
+ "RevokeRoleHandler",
65
+ "CreateCustomRoleHandler",
66
+ "register_auth_command_handlers",
67
+ ]
@@ -0,0 +1,118 @@
1
+ """Authentication and Authorization commands.
2
+
3
+ Commands represent user intentions for auth operations:
4
+ - API Key management (create, revoke)
5
+ - Role assignment (assign, revoke)
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+
11
+ from .commands import Command
12
+
13
+ # =============================================================================
14
+ # API Key Commands
15
+ # =============================================================================
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CreateApiKeyCommand(Command):
20
+ """Command to create a new API key.
21
+
22
+ Attributes:
23
+ principal_id: Principal ID the key authenticates as.
24
+ name: Human-readable name for the key.
25
+ created_by: Principal creating the key.
26
+ expires_at: Optional expiration datetime.
27
+ groups: Optional groups to assign.
28
+ tenant_id: Optional tenant for multi-tenancy.
29
+ """
30
+
31
+ principal_id: str
32
+ name: str
33
+ created_by: str = "system"
34
+ expires_at: datetime | None = None
35
+ groups: frozenset[str] = field(default_factory=frozenset)
36
+ tenant_id: str | None = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RevokeApiKeyCommand(Command):
41
+ """Command to revoke an API key.
42
+
43
+ Attributes:
44
+ key_id: Unique identifier of the key to revoke.
45
+ revoked_by: Principal revoking the key.
46
+ reason: Optional reason for revocation.
47
+ """
48
+
49
+ key_id: str
50
+ revoked_by: str = "system"
51
+ reason: str = ""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ListApiKeysCommand(Command):
56
+ """Command to list API keys for a principal.
57
+
58
+ Attributes:
59
+ principal_id: Principal whose keys to list.
60
+ """
61
+
62
+ principal_id: str
63
+
64
+
65
+ # =============================================================================
66
+ # Role Commands
67
+ # =============================================================================
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class AssignRoleCommand(Command):
72
+ """Command to assign a role to a principal.
73
+
74
+ Attributes:
75
+ principal_id: Principal receiving the role.
76
+ role_name: Name of the role to assign.
77
+ scope: Scope of the assignment (global, tenant:X, etc.).
78
+ assigned_by: Principal making the assignment.
79
+ """
80
+
81
+ principal_id: str
82
+ role_name: str
83
+ scope: str = "global"
84
+ assigned_by: str = "system"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RevokeRoleCommand(Command):
89
+ """Command to revoke a role from a principal.
90
+
91
+ Attributes:
92
+ principal_id: Principal losing the role.
93
+ role_name: Name of the role to revoke.
94
+ scope: Scope from which to revoke.
95
+ revoked_by: Principal making the revocation.
96
+ """
97
+
98
+ principal_id: str
99
+ role_name: str
100
+ scope: str = "global"
101
+ revoked_by: str = "system"
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class CreateCustomRoleCommand(Command):
106
+ """Command to create a custom role.
107
+
108
+ Attributes:
109
+ role_name: Name for the new role.
110
+ description: Human-readable description.
111
+ permissions: Set of permission strings (format: "resource:action:id").
112
+ created_by: Principal creating the role.
113
+ """
114
+
115
+ role_name: str
116
+ description: str = ""
117
+ permissions: frozenset[str] = field(default_factory=frozenset)
118
+ created_by: str = "system"
@@ -0,0 +1,296 @@
1
+ """Authentication and Authorization command handlers.
2
+
3
+ Implements CQRS command handlers for auth operations.
4
+ All handlers emit domain events via the event bus.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from ...domain.contracts.authentication import IApiKeyStore
10
+ from ...domain.contracts.authorization import IRoleStore
11
+ from ...domain.value_objects import Permission, Role
12
+ from ...infrastructure.command_bus import CommandHandler
13
+ from ...logging_config import get_logger
14
+ from .auth_commands import (
15
+ AssignRoleCommand,
16
+ CreateApiKeyCommand,
17
+ CreateCustomRoleCommand,
18
+ ListApiKeysCommand,
19
+ RevokeApiKeyCommand,
20
+ RevokeRoleCommand,
21
+ )
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ # =============================================================================
27
+ # API Key Command Handlers
28
+ # =============================================================================
29
+
30
+
31
+ class CreateApiKeyHandler(CommandHandler):
32
+ """Handler for CreateApiKeyCommand.
33
+
34
+ Creates a new API key and returns the raw key value.
35
+ Note: The raw key is only returned once - it cannot be retrieved later.
36
+ """
37
+
38
+ def __init__(self, api_key_store: IApiKeyStore):
39
+ self._store = api_key_store
40
+
41
+ def handle(self, command: CreateApiKeyCommand) -> dict[str, Any]:
42
+ """Create a new API key.
43
+
44
+ Returns:
45
+ Dict with key_id (for management) and raw_key (for authentication).
46
+ The raw_key is only shown once!
47
+ """
48
+ logger.info(
49
+ "creating_api_key",
50
+ principal_id=command.principal_id,
51
+ name=command.name,
52
+ created_by=command.created_by,
53
+ )
54
+
55
+ raw_key = self._store.create_key(
56
+ principal_id=command.principal_id,
57
+ name=command.name,
58
+ expires_at=command.expires_at,
59
+ groups=command.groups,
60
+ tenant_id=command.tenant_id,
61
+ created_by=command.created_by,
62
+ )
63
+
64
+ # Get the key_id from the list (the raw_key is not stored)
65
+ keys = self._store.list_keys(command.principal_id)
66
+ key_metadata = next((k for k in keys if k.name == command.name), None)
67
+
68
+ return {
69
+ "key_id": key_metadata.key_id if key_metadata else None,
70
+ "raw_key": raw_key,
71
+ "principal_id": command.principal_id,
72
+ "name": command.name,
73
+ "expires_at": command.expires_at.isoformat() if command.expires_at else None,
74
+ "warning": "Save this key now - it cannot be retrieved later!",
75
+ }
76
+
77
+
78
+ class RevokeApiKeyHandler(CommandHandler):
79
+ """Handler for RevokeApiKeyCommand.
80
+
81
+ Revokes an API key, making it unusable for authentication.
82
+ """
83
+
84
+ def __init__(self, api_key_store: IApiKeyStore):
85
+ self._store = api_key_store
86
+
87
+ def handle(self, command: RevokeApiKeyCommand) -> dict[str, Any]:
88
+ """Revoke an API key.
89
+
90
+ Returns:
91
+ Dict with revocation status.
92
+ """
93
+ logger.info(
94
+ "revoking_api_key",
95
+ key_id=command.key_id,
96
+ revoked_by=command.revoked_by,
97
+ reason=command.reason,
98
+ )
99
+
100
+ success = self._store.revoke_key(
101
+ key_id=command.key_id,
102
+ revoked_by=command.revoked_by,
103
+ reason=command.reason,
104
+ )
105
+
106
+ return {
107
+ "key_id": command.key_id,
108
+ "revoked": success,
109
+ "revoked_by": command.revoked_by,
110
+ "reason": command.reason,
111
+ }
112
+
113
+
114
+ class ListApiKeysHandler(CommandHandler):
115
+ """Handler for ListApiKeysCommand.
116
+
117
+ Note: This is technically a query, but kept as command for simplicity.
118
+ In a strict CQRS implementation, this would be a query handler.
119
+ """
120
+
121
+ def __init__(self, api_key_store: IApiKeyStore):
122
+ self._store = api_key_store
123
+
124
+ def handle(self, command: ListApiKeysCommand) -> dict[str, Any]:
125
+ """List API keys for a principal.
126
+
127
+ Returns:
128
+ Dict with list of key metadata (not the actual keys).
129
+ """
130
+ keys = self._store.list_keys(command.principal_id)
131
+
132
+ return {
133
+ "principal_id": command.principal_id,
134
+ "keys": [
135
+ {
136
+ "key_id": k.key_id,
137
+ "name": k.name,
138
+ "created_at": k.created_at.isoformat() if k.created_at else None,
139
+ "expires_at": k.expires_at.isoformat() if k.expires_at else None,
140
+ "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
141
+ "revoked": k.revoked,
142
+ }
143
+ for k in keys
144
+ ],
145
+ "count": len(keys),
146
+ }
147
+
148
+
149
+ # =============================================================================
150
+ # Role Command Handlers
151
+ # =============================================================================
152
+
153
+
154
+ class AssignRoleHandler(CommandHandler):
155
+ """Handler for AssignRoleCommand.
156
+
157
+ Assigns a role to a principal with optional scope.
158
+ """
159
+
160
+ def __init__(self, role_store: IRoleStore):
161
+ self._store = role_store
162
+
163
+ def handle(self, command: AssignRoleCommand) -> dict[str, Any]:
164
+ """Assign a role to a principal.
165
+
166
+ Returns:
167
+ Dict with assignment confirmation.
168
+ """
169
+ logger.info(
170
+ "assigning_role",
171
+ principal_id=command.principal_id,
172
+ role_name=command.role_name,
173
+ scope=command.scope,
174
+ assigned_by=command.assigned_by,
175
+ )
176
+
177
+ self._store.assign_role(
178
+ principal_id=command.principal_id,
179
+ role_name=command.role_name,
180
+ scope=command.scope,
181
+ assigned_by=command.assigned_by,
182
+ )
183
+
184
+ return {
185
+ "principal_id": command.principal_id,
186
+ "role_name": command.role_name,
187
+ "scope": command.scope,
188
+ "assigned": True,
189
+ "assigned_by": command.assigned_by,
190
+ }
191
+
192
+
193
+ class RevokeRoleHandler(CommandHandler):
194
+ """Handler for RevokeRoleCommand.
195
+
196
+ Revokes a role from a principal.
197
+ """
198
+
199
+ def __init__(self, role_store: IRoleStore):
200
+ self._store = role_store
201
+
202
+ def handle(self, command: RevokeRoleCommand) -> dict[str, Any]:
203
+ """Revoke a role from a principal.
204
+
205
+ Returns:
206
+ Dict with revocation confirmation.
207
+ """
208
+ logger.info(
209
+ "revoking_role",
210
+ principal_id=command.principal_id,
211
+ role_name=command.role_name,
212
+ scope=command.scope,
213
+ revoked_by=command.revoked_by,
214
+ )
215
+
216
+ self._store.revoke_role(
217
+ principal_id=command.principal_id,
218
+ role_name=command.role_name,
219
+ scope=command.scope,
220
+ revoked_by=command.revoked_by,
221
+ )
222
+
223
+ return {
224
+ "principal_id": command.principal_id,
225
+ "role_name": command.role_name,
226
+ "scope": command.scope,
227
+ "revoked": True,
228
+ "revoked_by": command.revoked_by,
229
+ }
230
+
231
+
232
+ class CreateCustomRoleHandler(CommandHandler):
233
+ """Handler for CreateCustomRoleCommand.
234
+
235
+ Creates a custom role with specified permissions.
236
+ """
237
+
238
+ def __init__(self, role_store: IRoleStore):
239
+ self._store = role_store
240
+
241
+ def handle(self, command: CreateCustomRoleCommand) -> dict[str, Any]:
242
+ """Create a custom role.
243
+
244
+ Returns:
245
+ Dict with role creation confirmation.
246
+ """
247
+ logger.info(
248
+ "creating_custom_role",
249
+ role_name=command.role_name,
250
+ permissions_count=len(command.permissions),
251
+ created_by=command.created_by,
252
+ )
253
+
254
+ # Parse permission strings to Permission objects
255
+ permissions = frozenset(Permission.parse(p) for p in command.permissions)
256
+
257
+ role = Role(
258
+ name=command.role_name,
259
+ description=command.description,
260
+ permissions=permissions,
261
+ )
262
+
263
+ self._store.add_role(role)
264
+
265
+ return {
266
+ "role_name": command.role_name,
267
+ "description": command.description,
268
+ "permissions_count": len(permissions),
269
+ "created": True,
270
+ "created_by": command.created_by,
271
+ }
272
+
273
+
274
+ def register_auth_command_handlers(
275
+ command_bus,
276
+ api_key_store: IApiKeyStore | None = None,
277
+ role_store: IRoleStore | None = None,
278
+ ) -> None:
279
+ """Register all auth command handlers with the command bus.
280
+
281
+ Args:
282
+ command_bus: CommandBus instance.
283
+ api_key_store: API key store (optional, handlers skipped if None).
284
+ role_store: Role store (optional, handlers skipped if None).
285
+ """
286
+ if api_key_store:
287
+ command_bus.register(CreateApiKeyCommand, CreateApiKeyHandler(api_key_store))
288
+ command_bus.register(RevokeApiKeyCommand, RevokeApiKeyHandler(api_key_store))
289
+ command_bus.register(ListApiKeysCommand, ListApiKeysHandler(api_key_store))
290
+ logger.info("auth_api_key_handlers_registered")
291
+
292
+ if role_store:
293
+ command_bus.register(AssignRoleCommand, AssignRoleHandler(role_store))
294
+ command_bus.register(RevokeRoleCommand, RevokeRoleHandler(role_store))
295
+ command_bus.register(CreateCustomRoleCommand, CreateCustomRoleHandler(role_store))
296
+ logger.info("auth_role_handlers_registered")
@@ -0,0 +1,59 @@
1
+ """Application commands - represent user intentions.
2
+
3
+ Commands are immutable data structures that represent actions to be performed.
4
+ They are named in imperative form (StartProvider, not ProviderStarted).
5
+ """
6
+
7
+ from abc import ABC
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Command(ABC):
14
+ """Base class for all commands.
15
+
16
+ Commands are immutable and represent a request to perform an action.
17
+ They should be named in imperative form (StartProvider, not ProviderStarted).
18
+ """
19
+
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StartProviderCommand(Command):
25
+ """Command to start a provider."""
26
+
27
+ provider_id: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class StopProviderCommand(Command):
32
+ """Command to stop a provider."""
33
+
34
+ provider_id: str
35
+ reason: str = "user_request"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class InvokeToolCommand(Command):
40
+ """Command to invoke a tool on a provider."""
41
+
42
+ provider_id: str
43
+ tool_name: str
44
+ arguments: Dict[str, Any] = field(default_factory=dict)
45
+ timeout: float = 30.0
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class HealthCheckCommand(Command):
50
+ """Command to perform health check on a provider."""
51
+
52
+ provider_id: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ShutdownIdleProvidersCommand(Command):
57
+ """Command to shutdown all idle providers."""
58
+
59
+ pass