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,323 @@
1
+ """RBAC (Role-Based Access Control) authorization implementation.
2
+
3
+ Provides authorizer and in-memory role store for role-based access control.
4
+ """
5
+
6
+ import threading
7
+
8
+ import structlog
9
+
10
+ from ...domain.contracts.authorization import AuthorizationRequest, AuthorizationResult, IAuthorizer, IRoleStore
11
+ from ...domain.security.roles import BUILTIN_ROLES
12
+ from ...domain.value_objects import Permission, Principal, Role
13
+
14
+ logger = structlog.get_logger(__name__)
15
+
16
+
17
+ class RBACAuthorizer(IAuthorizer):
18
+ """Role-Based Access Control authorizer.
19
+
20
+ Checks authorization based on:
21
+ 1. System principal - always allowed
22
+ 2. Direct role assignments to principal
23
+ 3. Role assignments to principal's groups
24
+
25
+ Multiple scopes are supported (global, tenant, namespace).
26
+ """
27
+
28
+ def __init__(self, role_store: IRoleStore):
29
+ """Initialize the RBAC authorizer.
30
+
31
+ Args:
32
+ role_store: Storage backend for roles and assignments.
33
+ """
34
+ self._role_store = role_store
35
+
36
+ def authorize(self, request: AuthorizationRequest) -> AuthorizationResult:
37
+ """Check if principal is authorized for the action.
38
+
39
+ Args:
40
+ request: Authorization request with principal, action, and resource.
41
+
42
+ Returns:
43
+ AuthorizationResult with allowed status and reason.
44
+ """
45
+ principal = request.principal
46
+
47
+ # System principal has full access
48
+ if principal.is_system():
49
+ logger.debug(
50
+ "authorization_granted_system",
51
+ action=request.action,
52
+ resource_type=request.resource_type,
53
+ resource_id=request.resource_id,
54
+ )
55
+ return AuthorizationResult.allow(reason="system_principal")
56
+
57
+ # Collect all roles for principal
58
+ roles = self._collect_roles(principal)
59
+
60
+ # Check each role for matching permission
61
+ for role in roles:
62
+ if role.has_permission(request.resource_type, request.action, request.resource_id):
63
+ # Find the specific permission that matched (for audit)
64
+ matched_permission = self._find_matching_permission(
65
+ role, request.resource_type, request.action, request.resource_id
66
+ )
67
+
68
+ logger.debug(
69
+ "authorization_granted",
70
+ principal_id=principal.id.value,
71
+ role=role.name,
72
+ action=request.action,
73
+ resource_type=request.resource_type,
74
+ resource_id=request.resource_id,
75
+ )
76
+
77
+ return AuthorizationResult.allow(
78
+ reason=f"granted_by_role:{role.name}",
79
+ permission=matched_permission,
80
+ role=role.name,
81
+ )
82
+
83
+ # No matching permission found
84
+ logger.warning(
85
+ "authorization_denied",
86
+ principal_id=principal.id.value,
87
+ action=request.action,
88
+ resource_type=request.resource_type,
89
+ resource_id=request.resource_id,
90
+ roles_checked=[r.name for r in roles],
91
+ )
92
+
93
+ return AuthorizationResult.deny(reason="no_matching_permission")
94
+
95
+ def _collect_roles(self, principal: Principal) -> list[Role]:
96
+ """Collect all roles for a principal.
97
+
98
+ Includes:
99
+ - Direct role assignments (global scope)
100
+ - Group-based role assignments (global scope)
101
+ - Tenant-scoped assignments if principal has tenant_id
102
+
103
+ Args:
104
+ principal: The principal to collect roles for.
105
+
106
+ Returns:
107
+ List of all applicable roles.
108
+ """
109
+ roles: list[Role] = []
110
+
111
+ # Direct assignments - global scope only
112
+ direct_roles = self._role_store.get_roles_for_principal(principal.id.value, scope="global")
113
+ roles.extend(direct_roles)
114
+
115
+ # Group-based assignments - global scope only
116
+ for group in principal.groups:
117
+ group_roles = self._role_store.get_roles_for_principal(f"group:{group}", scope="global")
118
+ roles.extend(group_roles)
119
+
120
+ # Tenant-scoped assignments (only if principal has tenant_id)
121
+ if principal.tenant_id:
122
+ tenant_scope = f"tenant:{principal.tenant_id}"
123
+ # Direct tenant-scoped roles
124
+ tenant_roles = self._role_store.get_roles_for_principal(principal.id.value, scope=tenant_scope)
125
+ roles.extend(tenant_roles)
126
+ # Group-based tenant-scoped roles
127
+ for group in principal.groups:
128
+ group_tenant_roles = self._role_store.get_roles_for_principal(f"group:{group}", scope=tenant_scope)
129
+ roles.extend(group_tenant_roles)
130
+
131
+ return roles
132
+
133
+ def _find_matching_permission(
134
+ self,
135
+ role: Role,
136
+ resource_type: str,
137
+ action: str,
138
+ resource_id: str,
139
+ ) -> Permission | None:
140
+ """Find the specific permission that matched.
141
+
142
+ Args:
143
+ role: Role to search.
144
+ resource_type: Resource type to match.
145
+ action: Action to match.
146
+ resource_id: Resource ID to match.
147
+
148
+ Returns:
149
+ The matching Permission, or None if not found.
150
+ """
151
+ for permission in role.permissions:
152
+ if permission.matches(resource_type, action, resource_id):
153
+ return permission
154
+ return None
155
+
156
+
157
+ class InMemoryRoleStore(IRoleStore):
158
+ """In-memory role store for development/testing.
159
+
160
+ WARNING: Data is lost on restart. Use a persistent store
161
+ for production.
162
+
163
+ This implementation is thread-safe using a reentrant lock.
164
+ """
165
+
166
+ def __init__(self) -> None:
167
+ """Initialize with built-in roles."""
168
+ self._lock = threading.RLock()
169
+ # role_name -> Role
170
+ self._roles: dict[str, Role] = dict(BUILTIN_ROLES)
171
+ # principal_id -> scope -> set of role names
172
+ self._assignments: dict[str, dict[str, set[str]]] = {}
173
+
174
+ def get_role(self, role_name: str) -> Role | None:
175
+ """Get role by name.
176
+
177
+ Args:
178
+ role_name: Name of the role to retrieve.
179
+
180
+ Returns:
181
+ Role if found, None otherwise.
182
+ """
183
+ with self._lock:
184
+ return self._roles.get(role_name)
185
+
186
+ def add_role(self, role: Role) -> None:
187
+ """Add a custom role.
188
+
189
+ Args:
190
+ role: Role to add.
191
+ """
192
+ with self._lock:
193
+ self._roles[role.name] = role
194
+ logger.info("role_added", role_name=role.name)
195
+
196
+ def get_roles_for_principal(
197
+ self,
198
+ principal_id: str,
199
+ scope: str = "*",
200
+ ) -> list[Role]:
201
+ """Get all roles assigned to a principal.
202
+
203
+ Args:
204
+ principal_id: ID of the principal.
205
+ scope: Filter by scope ('*' for all, 'global' for global only,
206
+ 'tenant:X' for that specific scope only, etc.).
207
+
208
+ Returns:
209
+ List of roles assigned to the principal for the specified scope.
210
+
211
+ Note:
212
+ When scope is specific (not '*'), only roles in that exact scope
213
+ are returned. The caller is responsible for aggregating roles
214
+ from multiple scopes if needed.
215
+ """
216
+ with self._lock:
217
+ if principal_id not in self._assignments:
218
+ return []
219
+
220
+ principal_assignments = self._assignments[principal_id]
221
+ role_names: set[str] = set()
222
+
223
+ if scope == "*":
224
+ # All scopes
225
+ for scope_roles in principal_assignments.values():
226
+ role_names.update(scope_roles)
227
+ else:
228
+ # Specific scope only - no automatic global inclusion
229
+ role_names.update(principal_assignments.get(scope, set()))
230
+
231
+ # Convert role names to Role objects
232
+ return [self._roles[name] for name in role_names if name in self._roles]
233
+
234
+ def assign_role(
235
+ self,
236
+ principal_id: str,
237
+ role_name: str,
238
+ scope: str = "global",
239
+ assigned_by: str = "system",
240
+ ) -> None:
241
+ """Assign a role to a principal.
242
+
243
+ Args:
244
+ principal_id: ID of the principal receiving the role.
245
+ role_name: Name of the role to assign.
246
+ scope: Scope of the assignment.
247
+ assigned_by: Principal making the assignment.
248
+
249
+ Raises:
250
+ ValueError: If role_name doesn't exist.
251
+ """
252
+ with self._lock:
253
+ if role_name not in self._roles:
254
+ raise ValueError(f"Unknown role: {role_name}")
255
+
256
+ if principal_id not in self._assignments:
257
+ self._assignments[principal_id] = {}
258
+
259
+ if scope not in self._assignments[principal_id]:
260
+ self._assignments[principal_id][scope] = set()
261
+
262
+ self._assignments[principal_id][scope].add(role_name)
263
+
264
+ logger.info(
265
+ "role_assigned",
266
+ principal_id=principal_id,
267
+ role_name=role_name,
268
+ scope=scope,
269
+ assigned_by=assigned_by,
270
+ )
271
+
272
+ def revoke_role(
273
+ self,
274
+ principal_id: str,
275
+ role_name: str,
276
+ scope: str = "global",
277
+ revoked_by: str = "system",
278
+ ) -> None:
279
+ """Revoke a role from a principal.
280
+
281
+ Args:
282
+ principal_id: ID of the principal losing the role.
283
+ role_name: Name of the role to revoke.
284
+ scope: Scope from which to revoke.
285
+ revoked_by: Principal making the revocation.
286
+ """
287
+ with self._lock:
288
+ if principal_id in self._assignments:
289
+ if scope in self._assignments[principal_id]:
290
+ self._assignments[principal_id][scope].discard(role_name)
291
+ logger.info(
292
+ "role_revoked",
293
+ principal_id=principal_id,
294
+ role_name=role_name,
295
+ scope=scope,
296
+ revoked_by=revoked_by,
297
+ )
298
+
299
+ def list_assignments(self, principal_id: str) -> dict[str, list[str]]:
300
+ """List all role assignments for a principal.
301
+
302
+ Args:
303
+ principal_id: ID of the principal.
304
+
305
+ Returns:
306
+ Dict of scope -> list of role names.
307
+ """
308
+ with self._lock:
309
+ if principal_id not in self._assignments:
310
+ return {}
311
+
312
+ return {scope: list(roles) for scope, roles in self._assignments[principal_id].items()}
313
+
314
+ def clear_assignments(self, principal_id: str) -> None:
315
+ """Clear all role assignments for a principal.
316
+
317
+ Args:
318
+ principal_id: ID of the principal.
319
+ """
320
+ with self._lock:
321
+ if principal_id in self._assignments:
322
+ del self._assignments[principal_id]
323
+ logger.info("assignments_cleared", principal_id=principal_id)