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,659 @@
1
+ """PostgreSQL-based persistent storage for API keys and roles.
2
+
3
+ Provides production-ready storage backends with:
4
+ - Connection pooling
5
+ - Retry logic
6
+ - Multi-instance support
7
+ - Proper transaction handling
8
+ - Domain event emission (CQRS compatible)
9
+
10
+ Requires: asyncpg or psycopg2
11
+ """
12
+
13
+ from contextlib import contextmanager
14
+ from datetime import datetime, timezone
15
+ import json
16
+ import secrets
17
+ from typing import Callable
18
+
19
+ import structlog
20
+
21
+ from ...domain.contracts.authentication import ApiKeyMetadata, IApiKeyStore
22
+ from ...domain.contracts.authorization import IRoleStore
23
+ from ...domain.events import ApiKeyCreated, ApiKeyRevoked, RoleAssigned, RoleRevoked
24
+ from ...domain.exceptions import ExpiredCredentialsError, RevokedCredentialsError
25
+ from ...domain.security.roles import BUILTIN_ROLES
26
+ from ...domain.value_objects import Permission, Principal, PrincipalId, PrincipalType, Role
27
+
28
+ logger = structlog.get_logger(__name__)
29
+
30
+
31
+ # SQL Schema for API Keys
32
+ API_KEYS_SCHEMA = """
33
+ CREATE TABLE IF NOT EXISTS api_keys (
34
+ key_hash VARCHAR(64) PRIMARY KEY,
35
+ key_id VARCHAR(32) NOT NULL UNIQUE,
36
+ principal_id VARCHAR(256) NOT NULL,
37
+ name VARCHAR(256) NOT NULL,
38
+ tenant_id VARCHAR(256),
39
+ groups JSONB DEFAULT '[]',
40
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
41
+ expires_at TIMESTAMP WITH TIME ZONE,
42
+ last_used_at TIMESTAMP WITH TIME ZONE,
43
+ revoked BOOLEAN NOT NULL DEFAULT FALSE,
44
+ revoked_at TIMESTAMP WITH TIME ZONE,
45
+ metadata JSONB DEFAULT '{}'
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_api_keys_principal_id ON api_keys(principal_id);
49
+ CREATE INDEX IF NOT EXISTS idx_api_keys_key_id ON api_keys(key_id);
50
+ CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at) WHERE expires_at IS NOT NULL;
51
+ """
52
+
53
+ # SQL Schema for Roles
54
+ ROLES_SCHEMA = """
55
+ CREATE TABLE IF NOT EXISTS roles (
56
+ name VARCHAR(128) PRIMARY KEY,
57
+ description TEXT,
58
+ permissions JSONB NOT NULL DEFAULT '[]',
59
+ is_builtin BOOLEAN NOT NULL DEFAULT FALSE,
60
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
61
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS role_assignments (
65
+ id SERIAL PRIMARY KEY,
66
+ principal_id VARCHAR(256) NOT NULL,
67
+ role_name VARCHAR(128) NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
68
+ scope VARCHAR(256) NOT NULL DEFAULT 'global',
69
+ assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
70
+ assigned_by VARCHAR(256),
71
+ UNIQUE(principal_id, role_name, scope)
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_role_assignments_principal_scope
75
+ ON role_assignments(principal_id, scope);
76
+ """
77
+
78
+
79
+ class PostgresApiKeyStore(IApiKeyStore):
80
+ """PostgreSQL-based API key store.
81
+
82
+ Features:
83
+ - Connection pooling via connection factory
84
+ - Atomic operations with proper transactions
85
+ - Optimistic locking for updates
86
+ - Automatic last_used_at updates
87
+
88
+ Multi-instance safe: Uses database-level locking.
89
+ """
90
+
91
+ MAX_KEYS_PER_PRINCIPAL = 100
92
+
93
+ def __init__(
94
+ self,
95
+ connection_factory,
96
+ table_prefix: str = "",
97
+ ):
98
+ """Initialize the PostgreSQL store.
99
+
100
+ Args:
101
+ connection_factory: Callable that returns a DB connection.
102
+ Should support context manager protocol.
103
+ table_prefix: Optional prefix for table names.
104
+ """
105
+ self._get_connection = connection_factory
106
+ self._prefix = table_prefix
107
+ self._table = f"{table_prefix}api_keys" if table_prefix else "api_keys"
108
+
109
+ def initialize(self) -> None:
110
+ """Create tables if they don't exist."""
111
+ schema = API_KEYS_SCHEMA
112
+ if self._prefix:
113
+ schema = schema.replace("api_keys", self._table)
114
+
115
+ with self._get_connection() as conn:
116
+ with conn.cursor() as cur:
117
+ cur.execute(schema)
118
+ conn.commit()
119
+ logger.info("postgres_api_key_store_initialized", table=self._table)
120
+
121
+ def get_principal_for_key(self, key_hash: str) -> Principal | None:
122
+ """Look up principal for an API key hash."""
123
+ with self._get_connection() as conn:
124
+ with conn.cursor() as cur:
125
+ cur.execute(
126
+ f"""
127
+ SELECT principal_id, tenant_id, groups, name, key_id,
128
+ expires_at, revoked, metadata
129
+ FROM {self._table}
130
+ WHERE key_hash = %s
131
+ """,
132
+ (key_hash,),
133
+ )
134
+
135
+ row = cur.fetchone()
136
+ if row is None:
137
+ return None
138
+
139
+ principal_id, tenant_id, groups, name, key_id, expires_at, revoked, metadata = row
140
+
141
+ # Check revocation
142
+ if revoked:
143
+ raise RevokedCredentialsError(
144
+ message="API key has been revoked",
145
+ auth_method="api_key",
146
+ )
147
+
148
+ # Check expiration
149
+ if expires_at and expires_at < datetime.now(timezone.utc):
150
+ raise ExpiredCredentialsError(
151
+ message="API key has expired",
152
+ auth_method="api_key",
153
+ expired_at=expires_at.timestamp(),
154
+ )
155
+
156
+ # Update last_used_at (fire and forget, don't fail auth on update error)
157
+ try:
158
+ cur.execute(
159
+ f"""
160
+ UPDATE {self._table}
161
+ SET last_used_at = NOW()
162
+ WHERE key_hash = %s
163
+ """,
164
+ (key_hash,),
165
+ )
166
+ conn.commit()
167
+ except Exception as e:
168
+ logger.warning("failed_to_update_last_used", error=str(e))
169
+ conn.rollback()
170
+
171
+ # Parse groups from JSON
172
+ if isinstance(groups, str):
173
+ groups = json.loads(groups)
174
+
175
+ return Principal(
176
+ id=PrincipalId(principal_id),
177
+ type=PrincipalType.SERVICE_ACCOUNT,
178
+ tenant_id=tenant_id,
179
+ groups=frozenset(groups or []),
180
+ metadata={"key_id": key_id, "key_name": name, **(metadata or {})},
181
+ )
182
+
183
+ def create_key(
184
+ self,
185
+ principal_id: str,
186
+ name: str,
187
+ expires_at: datetime | None = None,
188
+ groups: frozenset[str] | None = None,
189
+ tenant_id: str | None = None,
190
+ created_by: str = "system",
191
+ ) -> str:
192
+ """Create a new API key.
193
+
194
+ Emits: ApiKeyCreated event
195
+ """
196
+ from .api_key_authenticator import ApiKeyAuthenticator
197
+
198
+ with self._get_connection() as conn:
199
+ with conn.cursor() as cur:
200
+ # Check key count for principal
201
+ cur.execute(
202
+ f"""
203
+ SELECT COUNT(*) FROM {self._table}
204
+ WHERE principal_id = %s AND revoked = FALSE
205
+ """,
206
+ (principal_id,),
207
+ )
208
+ count = cur.fetchone()[0]
209
+
210
+ if count >= self.MAX_KEYS_PER_PRINCIPAL:
211
+ raise ValueError(
212
+ f"Principal {principal_id} has reached maximum API keys ({self.MAX_KEYS_PER_PRINCIPAL})"
213
+ )
214
+
215
+ # Generate key
216
+ raw_key = ApiKeyAuthenticator.generate_key()
217
+ key_hash = ApiKeyAuthenticator._hash_key(raw_key)
218
+ key_id = secrets.token_urlsafe(8)
219
+
220
+ # Insert
221
+ cur.execute(
222
+ f"""
223
+ INSERT INTO {self._table}
224
+ (key_hash, key_id, principal_id, name, tenant_id, groups, expires_at)
225
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
226
+ """,
227
+ (
228
+ key_hash,
229
+ key_id,
230
+ principal_id,
231
+ name,
232
+ tenant_id,
233
+ json.dumps(list(groups or [])),
234
+ expires_at,
235
+ ),
236
+ )
237
+ conn.commit()
238
+
239
+ logger.info(
240
+ "api_key_created",
241
+ key_id=key_id,
242
+ principal_id=principal_id,
243
+ name=name,
244
+ expires_at=expires_at.isoformat() if expires_at else None,
245
+ )
246
+
247
+ # Emit domain event
248
+ if self._event_publisher:
249
+ self._event_publisher(
250
+ ApiKeyCreated(
251
+ key_id=key_id,
252
+ principal_id=principal_id,
253
+ key_name=name,
254
+ expires_at=expires_at.timestamp() if expires_at else None,
255
+ created_by=created_by,
256
+ )
257
+ )
258
+
259
+ return raw_key
260
+
261
+ def revoke_key(self, key_id: str, revoked_by: str = "system", reason: str = "") -> bool:
262
+ """Revoke an API key.
263
+
264
+ Emits: ApiKeyRevoked event
265
+ """
266
+ with self._get_connection() as conn:
267
+ with conn.cursor() as cur:
268
+ # Get principal_id before revoking
269
+ cur.execute(
270
+ f"""
271
+ SELECT principal_id FROM {self._table}
272
+ WHERE key_id = %s AND revoked = FALSE
273
+ """,
274
+ (key_id,),
275
+ )
276
+ row = cur.fetchone()
277
+ principal_id = row[0] if row else None
278
+
279
+ cur.execute(
280
+ f"""
281
+ UPDATE {self._table}
282
+ SET revoked = TRUE, revoked_at = NOW()
283
+ WHERE key_id = %s AND revoked = FALSE
284
+ RETURNING key_id
285
+ """,
286
+ (key_id,),
287
+ )
288
+
289
+ result = cur.fetchone()
290
+ conn.commit()
291
+
292
+ if result:
293
+ logger.info("api_key_revoked", key_id=key_id)
294
+
295
+ # Emit domain event
296
+ if self._event_publisher and principal_id:
297
+ self._event_publisher(
298
+ ApiKeyRevoked(
299
+ key_id=key_id,
300
+ principal_id=principal_id,
301
+ revoked_by=revoked_by,
302
+ reason=reason,
303
+ )
304
+ )
305
+ return True
306
+ return False
307
+
308
+ def list_keys(self, principal_id: str) -> list[ApiKeyMetadata]:
309
+ """List API keys for a principal."""
310
+ with self._get_connection() as conn:
311
+ with conn.cursor() as cur:
312
+ cur.execute(
313
+ f"""
314
+ SELECT key_id, name, principal_id, created_at,
315
+ expires_at, last_used_at, revoked
316
+ FROM {self._table}
317
+ WHERE principal_id = %s
318
+ ORDER BY created_at DESC
319
+ """,
320
+ (principal_id,),
321
+ )
322
+
323
+ return [
324
+ ApiKeyMetadata(
325
+ key_id=row[0],
326
+ name=row[1],
327
+ principal_id=row[2],
328
+ created_at=row[3],
329
+ expires_at=row[4],
330
+ last_used_at=row[5],
331
+ revoked=row[6],
332
+ )
333
+ for row in cur.fetchall()
334
+ ]
335
+
336
+ def count_keys(self, principal_id: str) -> int:
337
+ """Count active keys for a principal."""
338
+ with self._get_connection() as conn:
339
+ with conn.cursor() as cur:
340
+ cur.execute(
341
+ f"""
342
+ SELECT COUNT(*) FROM {self._table}
343
+ WHERE principal_id = %s AND revoked = FALSE
344
+ """,
345
+ (principal_id,),
346
+ )
347
+ return cur.fetchone()[0]
348
+
349
+
350
+ class PostgresRoleStore(IRoleStore):
351
+ """PostgreSQL-based role store.
352
+
353
+ Features:
354
+ - Built-in roles seeded on init
355
+ - Custom roles support
356
+ - Multi-scope assignments
357
+ - Proper foreign key constraints
358
+ - Domain event emission
359
+
360
+ Multi-instance safe: Uses database-level constraints.
361
+
362
+ Events emitted:
363
+ - RoleAssigned: When a role is assigned
364
+ - RoleRevoked: When a role is revoked
365
+ """
366
+
367
+ def __init__(
368
+ self,
369
+ connection_factory,
370
+ table_prefix: str = "",
371
+ event_publisher: Callable | None = None,
372
+ ):
373
+ """Initialize the PostgreSQL store.
374
+
375
+ Args:
376
+ connection_factory: Callable that returns a DB connection.
377
+ table_prefix: Optional prefix for table names.
378
+ event_publisher: Optional callback for publishing domain events.
379
+ """
380
+ self._get_connection = connection_factory
381
+ self._prefix = table_prefix
382
+ self._roles_table = f"{table_prefix}roles" if table_prefix else "roles"
383
+ self._assignments_table = f"{table_prefix}role_assignments" if table_prefix else "role_assignments"
384
+ self._event_publisher = event_publisher
385
+
386
+ def initialize(self) -> None:
387
+ """Create tables and seed built-in roles."""
388
+ schema = ROLES_SCHEMA
389
+ if self._prefix:
390
+ schema = schema.replace("roles", self._roles_table)
391
+ schema = schema.replace("role_assignments", self._assignments_table)
392
+
393
+ with self._get_connection() as conn:
394
+ with conn.cursor() as cur:
395
+ cur.execute(schema)
396
+
397
+ # Seed built-in roles
398
+ for role_name, role in BUILTIN_ROLES.items():
399
+ permissions_json = json.dumps(
400
+ [
401
+ {"resource_type": p.resource_type, "action": p.action, "resource_id": p.resource_id}
402
+ for p in role.permissions
403
+ ]
404
+ )
405
+
406
+ cur.execute(
407
+ f"""
408
+ INSERT INTO {self._roles_table} (name, description, permissions, is_builtin)
409
+ VALUES (%s, %s, %s, TRUE)
410
+ ON CONFLICT (name) DO UPDATE SET
411
+ description = EXCLUDED.description,
412
+ permissions = EXCLUDED.permissions,
413
+ updated_at = NOW()
414
+ """,
415
+ (role_name, role.description, permissions_json),
416
+ )
417
+
418
+ conn.commit()
419
+ logger.info("postgres_role_store_initialized", roles_table=self._roles_table)
420
+
421
+ def get_role(self, role_name: str) -> Role | None:
422
+ """Get role by name."""
423
+ with self._get_connection() as conn:
424
+ with conn.cursor() as cur:
425
+ cur.execute(
426
+ f"""
427
+ SELECT name, description, permissions
428
+ FROM {self._roles_table}
429
+ WHERE name = %s
430
+ """,
431
+ (role_name,),
432
+ )
433
+
434
+ row = cur.fetchone()
435
+ if row is None:
436
+ return None
437
+
438
+ name, description, permissions_json = row
439
+
440
+ if isinstance(permissions_json, str):
441
+ permissions_json = json.loads(permissions_json)
442
+
443
+ permissions = frozenset(
444
+ Permission(
445
+ resource_type=p["resource_type"],
446
+ action=p["action"],
447
+ resource_id=p.get("resource_id", "*"),
448
+ )
449
+ for p in permissions_json
450
+ )
451
+
452
+ return Role(name=name, description=description or "", permissions=permissions)
453
+
454
+ def add_role(self, role: Role) -> None:
455
+ """Add a custom role."""
456
+ with self._get_connection() as conn:
457
+ with conn.cursor() as cur:
458
+ permissions_json = json.dumps(
459
+ [
460
+ {"resource_type": p.resource_type, "action": p.action, "resource_id": p.resource_id}
461
+ for p in role.permissions
462
+ ]
463
+ )
464
+
465
+ cur.execute(
466
+ f"""
467
+ INSERT INTO {self._roles_table} (name, description, permissions, is_builtin)
468
+ VALUES (%s, %s, %s, FALSE)
469
+ ON CONFLICT (name) DO UPDATE SET
470
+ description = EXCLUDED.description,
471
+ permissions = EXCLUDED.permissions,
472
+ updated_at = NOW()
473
+ """,
474
+ (role.name, role.description, permissions_json),
475
+ )
476
+
477
+ conn.commit()
478
+ logger.info("role_created", role_name=role.name)
479
+
480
+ def get_roles_for_principal(
481
+ self,
482
+ principal_id: str,
483
+ scope: str = "*",
484
+ ) -> list[Role]:
485
+ """Get all roles assigned to a principal."""
486
+ with self._get_connection() as conn:
487
+ with conn.cursor() as cur:
488
+ if scope == "*":
489
+ cur.execute(
490
+ f"""
491
+ SELECT r.name, r.description, r.permissions
492
+ FROM {self._roles_table} r
493
+ JOIN {self._assignments_table} a ON r.name = a.role_name
494
+ WHERE a.principal_id = %s
495
+ """,
496
+ (principal_id,),
497
+ )
498
+ else:
499
+ cur.execute(
500
+ f"""
501
+ SELECT r.name, r.description, r.permissions
502
+ FROM {self._roles_table} r
503
+ JOIN {self._assignments_table} a ON r.name = a.role_name
504
+ WHERE a.principal_id = %s AND (a.scope = %s OR a.scope = 'global')
505
+ """,
506
+ (principal_id, scope),
507
+ )
508
+
509
+ roles = []
510
+ for name, description, permissions_json in cur.fetchall():
511
+ if isinstance(permissions_json, str):
512
+ permissions_json = json.loads(permissions_json)
513
+
514
+ permissions = frozenset(
515
+ Permission(
516
+ resource_type=p["resource_type"],
517
+ action=p["action"],
518
+ resource_id=p.get("resource_id", "*"),
519
+ )
520
+ for p in permissions_json
521
+ )
522
+ roles.append(Role(name=name, description=description or "", permissions=permissions))
523
+
524
+ return roles
525
+
526
+ def assign_role(
527
+ self,
528
+ principal_id: str,
529
+ role_name: str,
530
+ scope: str = "global",
531
+ assigned_by: str = "system",
532
+ ) -> None:
533
+ """Assign a role to a principal.
534
+
535
+ Emits: RoleAssigned event
536
+ """
537
+ with self._get_connection() as conn:
538
+ with conn.cursor() as cur:
539
+ # Verify role exists
540
+ cur.execute(f"SELECT 1 FROM {self._roles_table} WHERE name = %s", (role_name,))
541
+ if cur.fetchone() is None:
542
+ raise ValueError(f"Unknown role: {role_name}")
543
+
544
+ cur.execute(
545
+ f"""
546
+ INSERT INTO {self._assignments_table} (principal_id, role_name, scope)
547
+ VALUES (%s, %s, %s)
548
+ ON CONFLICT (principal_id, role_name, scope) DO NOTHING
549
+ RETURNING id
550
+ """,
551
+ (principal_id, role_name, scope),
552
+ )
553
+
554
+ result = cur.fetchone()
555
+ conn.commit()
556
+
557
+ # Only emit event if actually inserted
558
+ if result:
559
+ logger.info("role_assigned", principal_id=principal_id, role_name=role_name, scope=scope)
560
+
561
+ if self._event_publisher:
562
+ self._event_publisher(
563
+ RoleAssigned(
564
+ principal_id=principal_id,
565
+ role_name=role_name,
566
+ scope=scope,
567
+ assigned_by=assigned_by,
568
+ )
569
+ )
570
+
571
+ def revoke_role(
572
+ self,
573
+ principal_id: str,
574
+ role_name: str,
575
+ scope: str = "global",
576
+ revoked_by: str = "system",
577
+ ) -> None:
578
+ """Revoke a role from a principal.
579
+
580
+ Emits: RoleRevoked event
581
+ """
582
+ with self._get_connection() as conn:
583
+ with conn.cursor() as cur:
584
+ cur.execute(
585
+ f"""
586
+ DELETE FROM {self._assignments_table}
587
+ WHERE principal_id = %s AND role_name = %s AND scope = %s
588
+ RETURNING id
589
+ """,
590
+ (principal_id, role_name, scope),
591
+ )
592
+
593
+ result = cur.fetchone()
594
+ conn.commit()
595
+
596
+ if result:
597
+ logger.info("role_revoked", principal_id=principal_id, role_name=role_name, scope=scope)
598
+
599
+ if self._event_publisher:
600
+ self._event_publisher(
601
+ RoleRevoked(
602
+ principal_id=principal_id,
603
+ role_name=role_name,
604
+ scope=scope,
605
+ revoked_by=revoked_by,
606
+ )
607
+ )
608
+
609
+
610
+ def create_postgres_connection_factory(
611
+ host: str = "localhost",
612
+ port: int = 5432,
613
+ database: str = "mcp_hangar",
614
+ user: str = "mcp_hangar",
615
+ password: str = "",
616
+ min_connections: int = 2,
617
+ max_connections: int = 10,
618
+ ):
619
+ """Create a connection factory for PostgreSQL.
620
+
621
+ Uses psycopg2 with connection pooling.
622
+
623
+ Args:
624
+ host: Database host.
625
+ port: Database port.
626
+ database: Database name.
627
+ user: Database user.
628
+ password: Database password.
629
+ min_connections: Minimum pool size.
630
+ max_connections: Maximum pool size.
631
+
632
+ Returns:
633
+ Connection factory callable.
634
+ """
635
+ try:
636
+ import psycopg2 # noqa: F401 - imported for availability check
637
+ from psycopg2 import pool
638
+ except ImportError:
639
+ raise ImportError("psycopg2 is required for PostgreSQL storage. Install with: pip install psycopg2-binary")
640
+
641
+ connection_pool = pool.ThreadedConnectionPool(
642
+ minconn=min_connections,
643
+ maxconn=max_connections,
644
+ host=host,
645
+ port=port,
646
+ database=database,
647
+ user=user,
648
+ password=password,
649
+ )
650
+
651
+ @contextmanager
652
+ def get_connection():
653
+ conn = connection_pool.getconn()
654
+ try:
655
+ yield conn
656
+ finally:
657
+ connection_pool.putconn(conn)
658
+
659
+ return get_connection