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