mcp-hangar 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ """