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,335 @@
|
|
|
1
|
+
"""CLI commands for authentication management.
|
|
2
|
+
|
|
3
|
+
Provides commands for API key management and role assignment.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
# Create an API key
|
|
7
|
+
mcp-hangar auth create-key --principal user:admin --name "Admin Key" --role admin
|
|
8
|
+
|
|
9
|
+
# List keys for a principal
|
|
10
|
+
mcp-hangar auth list-keys --principal user:admin
|
|
11
|
+
|
|
12
|
+
# Revoke a key
|
|
13
|
+
mcp-hangar auth revoke-key KEY_ID
|
|
14
|
+
|
|
15
|
+
# Assign a role
|
|
16
|
+
mcp-hangar auth assign-role --principal user:dev --role developer
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ..domain.security.roles import list_builtin_roles
|
|
25
|
+
from ..infrastructure.auth.api_key_authenticator import InMemoryApiKeyStore
|
|
26
|
+
from ..infrastructure.auth.rbac_authorizer import InMemoryRoleStore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_auth_parser(subparsers) -> argparse.ArgumentParser:
|
|
30
|
+
"""Create the auth subparser with all auth commands.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
subparsers: The subparsers object from the main parser.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The auth parser.
|
|
37
|
+
"""
|
|
38
|
+
auth_parser = subparsers.add_parser(
|
|
39
|
+
"auth",
|
|
40
|
+
help="Authentication management commands",
|
|
41
|
+
description="Commands for managing API keys and role assignments.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
auth_subparsers = auth_parser.add_subparsers(dest="auth_command", help="Auth commands")
|
|
45
|
+
|
|
46
|
+
# create-key command
|
|
47
|
+
create_key_parser = auth_subparsers.add_parser(
|
|
48
|
+
"create-key",
|
|
49
|
+
help="Create a new API key",
|
|
50
|
+
description="Create a new API key for a principal. The key is only shown once!",
|
|
51
|
+
)
|
|
52
|
+
create_key_parser.add_argument(
|
|
53
|
+
"--principal",
|
|
54
|
+
required=True,
|
|
55
|
+
help="Principal ID for the key (e.g., 'user:admin', 'service:ci-pipeline')",
|
|
56
|
+
)
|
|
57
|
+
create_key_parser.add_argument(
|
|
58
|
+
"--name",
|
|
59
|
+
required=True,
|
|
60
|
+
help="Human-readable name for the key",
|
|
61
|
+
)
|
|
62
|
+
create_key_parser.add_argument(
|
|
63
|
+
"--role",
|
|
64
|
+
action="append",
|
|
65
|
+
default=[],
|
|
66
|
+
help="Roles to assign (can be repeated)",
|
|
67
|
+
)
|
|
68
|
+
create_key_parser.add_argument(
|
|
69
|
+
"--expires",
|
|
70
|
+
type=int,
|
|
71
|
+
help="Expiration in days",
|
|
72
|
+
)
|
|
73
|
+
create_key_parser.add_argument(
|
|
74
|
+
"--tenant",
|
|
75
|
+
help="Tenant ID for multi-tenancy",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# list-keys command
|
|
79
|
+
list_keys_parser = auth_subparsers.add_parser(
|
|
80
|
+
"list-keys",
|
|
81
|
+
help="List API keys for a principal",
|
|
82
|
+
)
|
|
83
|
+
list_keys_parser.add_argument(
|
|
84
|
+
"--principal",
|
|
85
|
+
required=True,
|
|
86
|
+
help="Principal ID",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# revoke-key command
|
|
90
|
+
revoke_key_parser = auth_subparsers.add_parser(
|
|
91
|
+
"revoke-key",
|
|
92
|
+
help="Revoke an API key",
|
|
93
|
+
)
|
|
94
|
+
revoke_key_parser.add_argument(
|
|
95
|
+
"key_id",
|
|
96
|
+
help="Key ID to revoke",
|
|
97
|
+
)
|
|
98
|
+
revoke_key_parser.add_argument(
|
|
99
|
+
"--yes",
|
|
100
|
+
"-y",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="Skip confirmation prompt",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# assign-role command
|
|
106
|
+
assign_role_parser = auth_subparsers.add_parser(
|
|
107
|
+
"assign-role",
|
|
108
|
+
help="Assign a role to a principal",
|
|
109
|
+
)
|
|
110
|
+
assign_role_parser.add_argument(
|
|
111
|
+
"--principal",
|
|
112
|
+
required=True,
|
|
113
|
+
help="Principal ID",
|
|
114
|
+
)
|
|
115
|
+
assign_role_parser.add_argument(
|
|
116
|
+
"--role",
|
|
117
|
+
required=True,
|
|
118
|
+
help="Role name",
|
|
119
|
+
)
|
|
120
|
+
assign_role_parser.add_argument(
|
|
121
|
+
"--scope",
|
|
122
|
+
default="global",
|
|
123
|
+
help="Scope (global, tenant:X, namespace:Y)",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# revoke-role command
|
|
127
|
+
revoke_role_parser = auth_subparsers.add_parser(
|
|
128
|
+
"revoke-role",
|
|
129
|
+
help="Revoke a role from a principal",
|
|
130
|
+
)
|
|
131
|
+
revoke_role_parser.add_argument(
|
|
132
|
+
"--principal",
|
|
133
|
+
required=True,
|
|
134
|
+
help="Principal ID",
|
|
135
|
+
)
|
|
136
|
+
revoke_role_parser.add_argument(
|
|
137
|
+
"--role",
|
|
138
|
+
required=True,
|
|
139
|
+
help="Role name",
|
|
140
|
+
)
|
|
141
|
+
revoke_role_parser.add_argument(
|
|
142
|
+
"--scope",
|
|
143
|
+
default="global",
|
|
144
|
+
help="Scope",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# list-roles command
|
|
148
|
+
_list_roles_parser = auth_subparsers.add_parser( # noqa: F841 - used by argparse
|
|
149
|
+
"list-roles",
|
|
150
|
+
help="List available built-in roles",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return auth_parser
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def handle_auth_command(args, key_store: InMemoryApiKeyStore, role_store: InMemoryRoleStore) -> int:
|
|
157
|
+
"""Handle auth CLI commands.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
args: Parsed arguments.
|
|
161
|
+
key_store: API key store.
|
|
162
|
+
role_store: Role store.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Exit code (0 for success, non-zero for errors).
|
|
166
|
+
"""
|
|
167
|
+
if args.auth_command == "create-key":
|
|
168
|
+
return _handle_create_key(args, key_store, role_store)
|
|
169
|
+
elif args.auth_command == "list-keys":
|
|
170
|
+
return _handle_list_keys(args, key_store)
|
|
171
|
+
elif args.auth_command == "revoke-key":
|
|
172
|
+
return _handle_revoke_key(args, key_store)
|
|
173
|
+
elif args.auth_command == "assign-role":
|
|
174
|
+
return _handle_assign_role(args, role_store)
|
|
175
|
+
elif args.auth_command == "revoke-role":
|
|
176
|
+
return _handle_revoke_role(args, role_store)
|
|
177
|
+
elif args.auth_command == "list-roles":
|
|
178
|
+
return _handle_list_roles()
|
|
179
|
+
else:
|
|
180
|
+
print(f"Unknown auth command: {args.auth_command}", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _handle_create_key(args, key_store: InMemoryApiKeyStore, role_store: InMemoryRoleStore) -> int:
|
|
185
|
+
"""Handle create-key command."""
|
|
186
|
+
principal_id = args.principal
|
|
187
|
+
name = args.name
|
|
188
|
+
roles = args.role or []
|
|
189
|
+
expires_days = args.expires
|
|
190
|
+
tenant_id = args.tenant
|
|
191
|
+
|
|
192
|
+
# Calculate expiration
|
|
193
|
+
expires_at: Optional[datetime] = None
|
|
194
|
+
if expires_days:
|
|
195
|
+
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_days)
|
|
196
|
+
|
|
197
|
+
# Validate roles exist
|
|
198
|
+
for role_name in roles:
|
|
199
|
+
if role_store.get_role(role_name) is None:
|
|
200
|
+
print(f"Error: Unknown role '{role_name}'", file=sys.stderr)
|
|
201
|
+
print(f"Available roles: {', '.join(list_builtin_roles())}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
# Create the key
|
|
205
|
+
raw_key = key_store.create_key(
|
|
206
|
+
principal_id=principal_id,
|
|
207
|
+
name=name,
|
|
208
|
+
expires_at=expires_at,
|
|
209
|
+
tenant_id=tenant_id,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Assign roles
|
|
213
|
+
for role_name in roles:
|
|
214
|
+
role_store.assign_role(principal_id, role_name)
|
|
215
|
+
|
|
216
|
+
# Output
|
|
217
|
+
print(f"API Key created for {principal_id}")
|
|
218
|
+
print(f"Key: {raw_key}")
|
|
219
|
+
print()
|
|
220
|
+
print("⚠️ Save this key now - it cannot be retrieved later!")
|
|
221
|
+
print()
|
|
222
|
+
|
|
223
|
+
if expires_at:
|
|
224
|
+
print(f"Expires: {expires_at.isoformat()}")
|
|
225
|
+
|
|
226
|
+
if roles:
|
|
227
|
+
print(f"Roles assigned: {', '.join(roles)}")
|
|
228
|
+
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _handle_list_keys(args, key_store: InMemoryApiKeyStore) -> int:
|
|
233
|
+
"""Handle list-keys command."""
|
|
234
|
+
principal_id = args.principal
|
|
235
|
+
keys = key_store.list_keys(principal_id)
|
|
236
|
+
|
|
237
|
+
if not keys:
|
|
238
|
+
print(f"No keys found for {principal_id}")
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
print(f"API Keys for {principal_id}:")
|
|
242
|
+
print()
|
|
243
|
+
|
|
244
|
+
for key in keys:
|
|
245
|
+
status = "🔴 REVOKED" if key.revoked else "🟢 ACTIVE"
|
|
246
|
+
if not key.revoked and key.is_expired:
|
|
247
|
+
status = "🟡 EXPIRED"
|
|
248
|
+
|
|
249
|
+
print(f"{status} {key.key_id}: {key.name}")
|
|
250
|
+
print(f" Created: {key.created_at.isoformat()}")
|
|
251
|
+
if key.expires_at:
|
|
252
|
+
print(f" Expires: {key.expires_at.isoformat()}")
|
|
253
|
+
if key.last_used_at:
|
|
254
|
+
print(f" Last used: {key.last_used_at.isoformat()}")
|
|
255
|
+
print()
|
|
256
|
+
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _handle_revoke_key(args, key_store: InMemoryApiKeyStore) -> int:
|
|
261
|
+
"""Handle revoke-key command."""
|
|
262
|
+
key_id = args.key_id
|
|
263
|
+
|
|
264
|
+
# Check if key exists
|
|
265
|
+
key_metadata = key_store.get_key_by_id(key_id)
|
|
266
|
+
if key_metadata is None:
|
|
267
|
+
print(f"Error: Key {key_id} not found", file=sys.stderr)
|
|
268
|
+
return 1
|
|
269
|
+
|
|
270
|
+
if key_metadata.revoked:
|
|
271
|
+
print(f"Key {key_id} is already revoked")
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
# Confirm unless --yes flag
|
|
275
|
+
if not args.yes:
|
|
276
|
+
confirm = input(f"Are you sure you want to revoke key {key_id}? [y/N] ")
|
|
277
|
+
if confirm.lower() not in ("y", "yes"):
|
|
278
|
+
print("Cancelled")
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
# Revoke
|
|
282
|
+
if key_store.revoke_key(key_id):
|
|
283
|
+
print(f"Key {key_id} revoked")
|
|
284
|
+
return 0
|
|
285
|
+
else:
|
|
286
|
+
print(f"Error: Failed to revoke key {key_id}", file=sys.stderr)
|
|
287
|
+
return 1
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _handle_assign_role(args, role_store: InMemoryRoleStore) -> int:
|
|
291
|
+
"""Handle assign-role command."""
|
|
292
|
+
principal_id = args.principal
|
|
293
|
+
role_name = args.role
|
|
294
|
+
scope = args.scope
|
|
295
|
+
|
|
296
|
+
# Validate role exists
|
|
297
|
+
if role_store.get_role(role_name) is None:
|
|
298
|
+
print(f"Error: Unknown role '{role_name}'", file=sys.stderr)
|
|
299
|
+
print(f"Available roles: {', '.join(list_builtin_roles())}", file=sys.stderr)
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
role_store.assign_role(principal_id, role_name, scope)
|
|
304
|
+
print(f"Assigned role '{role_name}' to {principal_id} (scope: {scope})")
|
|
305
|
+
return 0
|
|
306
|
+
except ValueError as e:
|
|
307
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
308
|
+
return 1
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _handle_revoke_role(args, role_store: InMemoryRoleStore) -> int:
|
|
312
|
+
"""Handle revoke-role command."""
|
|
313
|
+
principal_id = args.principal
|
|
314
|
+
role_name = args.role
|
|
315
|
+
scope = args.scope
|
|
316
|
+
|
|
317
|
+
role_store.revoke_role(principal_id, role_name, scope)
|
|
318
|
+
print(f"Revoked role '{role_name}' from {principal_id} (scope: {scope})")
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _handle_list_roles() -> int:
|
|
323
|
+
"""Handle list-roles command."""
|
|
324
|
+
from ..domain.security.roles import BUILTIN_ROLES
|
|
325
|
+
|
|
326
|
+
print("Available built-in roles:")
|
|
327
|
+
print()
|
|
328
|
+
|
|
329
|
+
for name, role in BUILTIN_ROLES.items():
|
|
330
|
+
print(f" {name}")
|
|
331
|
+
print(f" {role.description}")
|
|
332
|
+
print(f" Permissions: {len(role.permissions)}")
|
|
333
|
+
print()
|
|
334
|
+
|
|
335
|
+
return 0
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Authentication and Authorization configuration.
|
|
2
|
+
|
|
3
|
+
Defines dataclasses for auth configuration and functions to load
|
|
4
|
+
auth settings from YAML configuration files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ApiKeyAuthConfig:
|
|
13
|
+
"""API Key authentication configuration.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
enabled: Whether API key authentication is enabled.
|
|
17
|
+
header_name: Name of the HTTP header containing the API key.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
enabled: bool = True
|
|
21
|
+
header_name: str = "X-API-Key"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class OIDCAuthConfig:
|
|
26
|
+
"""OIDC/JWT authentication configuration.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
enabled: Whether OIDC/JWT authentication is enabled.
|
|
30
|
+
issuer: OIDC issuer URL (e.g., https://auth.company.com).
|
|
31
|
+
audience: Expected audience claim value.
|
|
32
|
+
jwks_uri: JWKS endpoint URL (auto-discovered from issuer if None).
|
|
33
|
+
client_id: Optional client ID for additional validation.
|
|
34
|
+
subject_claim: JWT claim for subject identifier.
|
|
35
|
+
groups_claim: JWT claim for group memberships.
|
|
36
|
+
tenant_claim: JWT claim for tenant identifier.
|
|
37
|
+
email_claim: JWT claim for email address.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
enabled: bool = False
|
|
41
|
+
issuer: str = ""
|
|
42
|
+
audience: str = ""
|
|
43
|
+
jwks_uri: str | None = None
|
|
44
|
+
client_id: str | None = None
|
|
45
|
+
|
|
46
|
+
# Claim mappings
|
|
47
|
+
subject_claim: str = "sub"
|
|
48
|
+
groups_claim: str = "groups"
|
|
49
|
+
tenant_claim: str = "tenant_id"
|
|
50
|
+
email_claim: str = "email"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class OPAConfig:
|
|
55
|
+
"""OPA (Open Policy Agent) configuration.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
enabled: Whether OPA policy engine is enabled.
|
|
59
|
+
url: URL of the OPA server.
|
|
60
|
+
policy_path: Path to the policy decision endpoint.
|
|
61
|
+
timeout: HTTP request timeout in seconds.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
enabled: bool = False
|
|
65
|
+
url: str = "http://localhost:8181"
|
|
66
|
+
policy_path: str = "v1/data/mcp/authz/allow"
|
|
67
|
+
timeout: float = 5.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class RoleAssignment:
|
|
72
|
+
"""A single role assignment configuration.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
principal: Principal ID (e.g., "user:admin@company.com", "group:platform-engineering").
|
|
76
|
+
role: Role name (e.g., "admin", "developer").
|
|
77
|
+
scope: Scope of the assignment (e.g., "global", "tenant:data-team").
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
principal: str
|
|
81
|
+
role: str
|
|
82
|
+
scope: str = "global"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class StorageConfig:
|
|
87
|
+
"""Storage backend configuration for auth data.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
driver: Storage driver ("memory", "sqlite", "postgresql").
|
|
91
|
+
path: Path for SQLite database file (only for sqlite driver).
|
|
92
|
+
host: Database host (only for postgresql driver).
|
|
93
|
+
port: Database port (only for postgresql driver).
|
|
94
|
+
database: Database name (only for postgresql driver).
|
|
95
|
+
user: Database user (only for postgresql driver).
|
|
96
|
+
password: Database password (only for postgresql driver).
|
|
97
|
+
min_connections: Minimum pool connections (only for postgresql driver).
|
|
98
|
+
max_connections: Maximum pool connections (only for postgresql driver).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
driver: str = "memory" # memory, sqlite, postgresql
|
|
102
|
+
|
|
103
|
+
# SQLite options
|
|
104
|
+
path: str = "data/auth.db"
|
|
105
|
+
|
|
106
|
+
# PostgreSQL options
|
|
107
|
+
host: str = "localhost"
|
|
108
|
+
port: int = 5432
|
|
109
|
+
database: str = "mcp_hangar"
|
|
110
|
+
user: str = "mcp_hangar"
|
|
111
|
+
password: str = ""
|
|
112
|
+
min_connections: int = 2
|
|
113
|
+
max_connections: int = 10
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class RateLimitConfig:
|
|
118
|
+
"""Rate limiting configuration for auth attempts.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
enabled: Whether rate limiting is enabled.
|
|
122
|
+
max_attempts: Maximum failed attempts per window.
|
|
123
|
+
window_seconds: Time window for counting attempts.
|
|
124
|
+
lockout_seconds: How long to lock out after exceeding limit.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
enabled: bool = True
|
|
128
|
+
max_attempts: int = 10
|
|
129
|
+
window_seconds: int = 60
|
|
130
|
+
lockout_seconds: int = 300
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class AuthConfig:
|
|
135
|
+
"""Authentication and authorization configuration.
|
|
136
|
+
|
|
137
|
+
This is the main configuration container for all auth settings.
|
|
138
|
+
|
|
139
|
+
Authentication is OPT-IN by default (enabled=False). Set enabled=True
|
|
140
|
+
in your configuration to activate authentication.
|
|
141
|
+
|
|
142
|
+
Attributes:
|
|
143
|
+
enabled: Master switch for auth (if False, all requests are allowed). Default: False (opt-in).
|
|
144
|
+
allow_anonymous: If True, allow unauthenticated requests as anonymous.
|
|
145
|
+
storage: Storage backend configuration.
|
|
146
|
+
rate_limit: Rate limiting configuration.
|
|
147
|
+
api_key: API key authentication configuration.
|
|
148
|
+
oidc: OIDC/JWT authentication configuration.
|
|
149
|
+
opa: OPA policy engine configuration.
|
|
150
|
+
role_assignments: Static role assignments from configuration.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
enabled: bool = False # OPT-IN: auth disabled by default
|
|
154
|
+
allow_anonymous: bool = False
|
|
155
|
+
|
|
156
|
+
storage: StorageConfig = field(default_factory=StorageConfig)
|
|
157
|
+
rate_limit: RateLimitConfig = field(default_factory=RateLimitConfig)
|
|
158
|
+
|
|
159
|
+
api_key: ApiKeyAuthConfig = field(default_factory=ApiKeyAuthConfig)
|
|
160
|
+
oidc: OIDCAuthConfig = field(default_factory=OIDCAuthConfig)
|
|
161
|
+
opa: OPAConfig = field(default_factory=OPAConfig)
|
|
162
|
+
|
|
163
|
+
role_assignments: list[RoleAssignment] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_auth_config(config_dict: dict[str, Any] | None) -> AuthConfig:
|
|
167
|
+
"""Parse auth configuration from dictionary.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
config_dict: The 'auth' section of the configuration file.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Parsed AuthConfig object with defaults for missing values.
|
|
174
|
+
"""
|
|
175
|
+
if config_dict is None:
|
|
176
|
+
return AuthConfig()
|
|
177
|
+
|
|
178
|
+
# Parse storage config
|
|
179
|
+
storage_dict = config_dict.get("storage", {})
|
|
180
|
+
storage_config = StorageConfig(
|
|
181
|
+
driver=storage_dict.get("driver", "memory"),
|
|
182
|
+
path=storage_dict.get("path", "data/auth.db"),
|
|
183
|
+
host=storage_dict.get("host", "localhost"),
|
|
184
|
+
port=storage_dict.get("port", 5432),
|
|
185
|
+
database=storage_dict.get("database", "mcp_hangar"),
|
|
186
|
+
user=storage_dict.get("user", "mcp_hangar"),
|
|
187
|
+
password=storage_dict.get("password", ""),
|
|
188
|
+
min_connections=storage_dict.get("min_connections", 2),
|
|
189
|
+
max_connections=storage_dict.get("max_connections", 10),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Parse rate limit config
|
|
193
|
+
rate_limit_dict = config_dict.get("rate_limit", {})
|
|
194
|
+
rate_limit_config = RateLimitConfig(
|
|
195
|
+
enabled=rate_limit_dict.get("enabled", True),
|
|
196
|
+
max_attempts=rate_limit_dict.get("max_attempts", 10),
|
|
197
|
+
window_seconds=rate_limit_dict.get("window_seconds", 60),
|
|
198
|
+
lockout_seconds=rate_limit_dict.get("lockout_seconds", 300),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Parse API key config
|
|
202
|
+
api_key_dict = config_dict.get("api_key", {})
|
|
203
|
+
api_key_config = ApiKeyAuthConfig(
|
|
204
|
+
enabled=api_key_dict.get("enabled", True),
|
|
205
|
+
header_name=api_key_dict.get("header_name", "X-API-Key"),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Parse OIDC config
|
|
209
|
+
oidc_dict = config_dict.get("oidc", {})
|
|
210
|
+
oidc_config = OIDCAuthConfig(
|
|
211
|
+
enabled=oidc_dict.get("enabled", False),
|
|
212
|
+
issuer=oidc_dict.get("issuer", ""),
|
|
213
|
+
audience=oidc_dict.get("audience", ""),
|
|
214
|
+
jwks_uri=oidc_dict.get("jwks_uri"),
|
|
215
|
+
client_id=oidc_dict.get("client_id"),
|
|
216
|
+
subject_claim=oidc_dict.get("subject_claim", "sub"),
|
|
217
|
+
groups_claim=oidc_dict.get("groups_claim", "groups"),
|
|
218
|
+
tenant_claim=oidc_dict.get("tenant_claim", "tenant_id"),
|
|
219
|
+
email_claim=oidc_dict.get("email_claim", "email"),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Parse OPA config
|
|
223
|
+
opa_dict = config_dict.get("opa", {})
|
|
224
|
+
opa_config = OPAConfig(
|
|
225
|
+
enabled=opa_dict.get("enabled", False),
|
|
226
|
+
url=opa_dict.get("url", "http://localhost:8181"),
|
|
227
|
+
policy_path=opa_dict.get("policy_path", "v1/data/mcp/authz/allow"),
|
|
228
|
+
timeout=opa_dict.get("timeout", 5.0),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Parse role assignments
|
|
232
|
+
role_assignments: list[RoleAssignment] = []
|
|
233
|
+
for assignment_dict in config_dict.get("role_assignments", []):
|
|
234
|
+
if isinstance(assignment_dict, dict):
|
|
235
|
+
role_assignments.append(
|
|
236
|
+
RoleAssignment(
|
|
237
|
+
principal=assignment_dict.get("principal", ""),
|
|
238
|
+
role=assignment_dict.get("role", ""),
|
|
239
|
+
scope=assignment_dict.get("scope", "global"),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return AuthConfig(
|
|
244
|
+
enabled=config_dict.get("enabled", False), # OPT-IN: default to disabled
|
|
245
|
+
allow_anonymous=config_dict.get("allow_anonymous", False),
|
|
246
|
+
storage=storage_config,
|
|
247
|
+
rate_limit=rate_limit_config,
|
|
248
|
+
api_key=api_key_config,
|
|
249
|
+
oidc=oidc_config,
|
|
250
|
+
opa=opa_config,
|
|
251
|
+
role_assignments=role_assignments,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_default_auth_config() -> AuthConfig:
|
|
256
|
+
"""Get default auth configuration.
|
|
257
|
+
|
|
258
|
+
Returns a disabled auth configuration suitable for development
|
|
259
|
+
where authentication is not required.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
AuthConfig with auth disabled.
|
|
263
|
+
"""
|
|
264
|
+
return AuthConfig(enabled=False, allow_anonymous=True)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# Example configuration (for documentation):
|
|
268
|
+
EXAMPLE_AUTH_CONFIG = """
|
|
269
|
+
auth:
|
|
270
|
+
enabled: true
|
|
271
|
+
allow_anonymous: false
|
|
272
|
+
|
|
273
|
+
api_key:
|
|
274
|
+
enabled: true
|
|
275
|
+
header_name: X-API-Key
|
|
276
|
+
|
|
277
|
+
oidc:
|
|
278
|
+
enabled: true
|
|
279
|
+
issuer: https://auth.company.com
|
|
280
|
+
audience: mcp-hangar
|
|
281
|
+
# jwks_uri auto-discovered from issuer if not specified
|
|
282
|
+
groups_claim: groups
|
|
283
|
+
tenant_claim: org_id
|
|
284
|
+
|
|
285
|
+
opa:
|
|
286
|
+
enabled: false # Use built-in RBAC by default
|
|
287
|
+
url: http://opa:8181
|
|
288
|
+
policy_path: v1/data/mcp/authz/allow
|
|
289
|
+
|
|
290
|
+
role_assignments:
|
|
291
|
+
# Bootstrap admin
|
|
292
|
+
- principal: "user:admin@company.com"
|
|
293
|
+
role: admin
|
|
294
|
+
scope: global
|
|
295
|
+
|
|
296
|
+
# Platform team
|
|
297
|
+
- principal: "group:platform-engineering"
|
|
298
|
+
role: provider-admin
|
|
299
|
+
scope: global
|
|
300
|
+
|
|
301
|
+
# Data team - scoped to their tenant
|
|
302
|
+
- principal: "group:data-science"
|
|
303
|
+
role: developer
|
|
304
|
+
scope: "tenant:data-team"
|
|
305
|
+
"""
|