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