pgbulkhead 0.1.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.
pgbulkhead/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """pgbulkhead: tenant isolation for multi-tenant MCP servers on Postgres.
2
+
3
+ One database, many tenants and workspaces: Postgres enforces the walls, and no agent can talk
4
+ its way through them.
5
+
6
+ * :class:`TenancyModel` names the walls (a tenant column, an optional workspace column) and
7
+ the settings that carry them; :func:`policy_sql` renders its row-level security policies
8
+ for a migration.
9
+ * :class:`SecurityContext` is the verified identity of one request, and
10
+ :func:`scoped_session` binds it for the length of a transaction, so every query runs
11
+ inside its walls and a pooled connection never carries it to the next request.
12
+ * :class:`SecuredDatabase` holds one server's engine, pool settings and per-workspace
13
+ admission, so one agent's fan-out queues behind its own workspace's slots, and refuses
14
+ an overloaded call with :class:`Overloaded` before it runs anything.
15
+
16
+ Isolation holds against query bugs and against identity taken from untrusted input. It does
17
+ not hold against arbitrary SQL executed as the app role, which can set any context setting.
18
+ The README's threat model says what each guarantee rests on.
19
+ """
20
+
21
+ from .admission import (
22
+ AdmittedSlot,
23
+ Overloaded,
24
+ PoolExhausted,
25
+ RefusalText,
26
+ WorkspaceBusy,
27
+ WorkspaceLimiter,
28
+ admitted_session,
29
+ )
30
+ from .context import ClaimNames, SecurityContext, context_from_claims
31
+ from .database import SecuredDatabase
32
+ from .model import GucBypass, RoleBypass, TenancyModel
33
+ from .policy import policy_sql, policy_statements
34
+ from .pool import CONNECTIONS_PER_CALL, PoolConfigError, PoolSettings, create_pooled_engine
35
+ from .session import (
36
+ CLEANUP_TIMEOUT_SECONDS,
37
+ bind_context,
38
+ check_context,
39
+ scoped_session,
40
+ system_session,
41
+ transaction,
42
+ )
43
+
44
+ __all__ = [
45
+ "CLEANUP_TIMEOUT_SECONDS",
46
+ "CONNECTIONS_PER_CALL",
47
+ "AdmittedSlot",
48
+ "ClaimNames",
49
+ "GucBypass",
50
+ "Overloaded",
51
+ "PoolConfigError",
52
+ "PoolExhausted",
53
+ "PoolSettings",
54
+ "RefusalText",
55
+ "RoleBypass",
56
+ "SecuredDatabase",
57
+ "SecurityContext",
58
+ "TenancyModel",
59
+ "WorkspaceBusy",
60
+ "WorkspaceLimiter",
61
+ "admitted_session",
62
+ "bind_context",
63
+ "check_context",
64
+ "context_from_claims",
65
+ "create_pooled_engine",
66
+ "policy_sql",
67
+ "policy_statements",
68
+ "scoped_session",
69
+ "system_session",
70
+ "transaction",
71
+ ]
@@ -0,0 +1,235 @@
1
+ """Per-workspace admission: no one workspace can take every pooled connection on a pod.
2
+
3
+ Every call takes a pooled connection for its session, and the pool is shared by every
4
+ workspace the pod serves. Without a limit, one client that fires a burst of calls holds the
5
+ whole pool, and every other workspace's call waits behind it. :class:`WorkspaceLimiter` admits
6
+ at most ``limit`` calls per workspace at once, *before* the call checks a connection out, so a
7
+ burst queues behind its own workspace's slots while other workspaces keep theirs.
8
+
9
+ The key is the verified ``(tenant_id, workspace_id)`` of the call's :class:`SecurityContext`,
10
+ which a server builds from a verified identity alone; nothing a caller passes can choose it.
11
+
12
+ A call over the limit waits up to ``wait_seconds`` for a slot, then is refused with
13
+ :class:`WorkspaceBusy`. An admitted call whose checkout then waits past the pool's timeout is
14
+ refused with :class:`PoolExhausted`. Both are :class:`Overloaded`: the call ran nothing, so
15
+ retrying it is safe, and each server maps it onto its own error surface as ``rate_limited``.
16
+
17
+ State is bounded by the calls in flight: a workspace's entry exists only while some call holds
18
+ or waits for one of its slots, and is dropped when the last one leaves.
19
+
20
+ Admit only the call's own session (:func:`admitted_session`). A write a call commits apart
21
+ from its session opens a second session while the call already holds a slot; admitting that
22
+ one too could deadlock a workspace whose every slot is held by a call waiting for its second
23
+ session. A request that runs several sessions one after another holds one slot across them
24
+ all (:class:`AdmittedSlot`, from :meth:`SecuredDatabase.admitted
25
+ <pgbulkhead.SecuredDatabase.admitted>`).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import AsyncGenerator, Callable, Mapping
31
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ import anyio
36
+ from sqlalchemy.exc import TimeoutError as PoolTimeout
37
+
38
+ from .context import SecurityContext
39
+ from .pool import PoolSettings
40
+
41
+ __all__ = [
42
+ "AdmittedSlot",
43
+ "Overloaded",
44
+ "RefusalText",
45
+ "PoolExhausted",
46
+ "WorkspaceBusy",
47
+ "WorkspaceLimiter",
48
+ "admitted_session",
49
+ ]
50
+
51
+
52
+ class Overloaded(Exception):
53
+ """The call was refused before it ran anything, because the server is at capacity.
54
+
55
+ ``message`` and ``next_step`` are text a server can return to the agent as they are: they
56
+ say nothing ran, so a retry is safe. Map it onto your error surface as a rate limit. Replace
57
+ the text with ``refusals`` on :class:`~pgbulkhead.SecuredDatabase` or
58
+ :func:`admitted_session`.
59
+ """
60
+
61
+ message = "The server is at capacity right now. Nothing was run."
62
+ next_step = "Wait a few seconds, then retry the call."
63
+
64
+ def __init__(self) -> None:
65
+ super().__init__(self.message)
66
+
67
+
68
+ class WorkspaceBusy(Overloaded):
69
+ """A call waited its bound for one of its workspace's slots and did not get one."""
70
+
71
+ message = (
72
+ "This workspace already has as many requests running as the server allows at once, "
73
+ "and this one waited too long for its turn. Nothing was run."
74
+ )
75
+ next_step = "Wait a few seconds, then retry the call; send fewer calls at once."
76
+
77
+
78
+ class PoolExhausted(Overloaded):
79
+ """An admitted call found no free database connection within the pool's timeout."""
80
+
81
+ message = "The server has no free database connection right now. Nothing was run."
82
+ next_step = "Wait a few seconds, then retry the call."
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class RefusalText:
87
+ """Text to return in place of an :class:`Overloaded` refusal's defaults."""
88
+
89
+ message: str
90
+ next_step: str
91
+
92
+
93
+ #: Replacement text per refusal class. A refusal takes its own class's entry, else the entry of
94
+ #: its nearest listed base class, else keeps its defaults.
95
+ Refusals = Mapping[type[Overloaded], RefusalText]
96
+
97
+
98
+ def _apply(exc: Overloaded, refusals: Refusals | None) -> Overloaded:
99
+ """``exc`` carrying the text ``refusals`` gives its class, if any."""
100
+ if refusals:
101
+ for cls in type(exc).__mro__:
102
+ text = refusals.get(cls)
103
+ if text is not None:
104
+ exc.message = text.message
105
+ exc.next_step = text.next_step
106
+ exc.args = (text.message,)
107
+ break
108
+ return exc
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class _Slots:
113
+ semaphore: anyio.Semaphore
114
+ # The calls holding or waiting for one of these slots; the entry is dropped at zero.
115
+ users: int = field(default=0)
116
+
117
+
118
+ class WorkspaceLimiter:
119
+ """At most ``limit`` concurrent admitted calls per workspace, waiting ``wait_seconds``."""
120
+
121
+ def __init__(self, *, limit: int, wait_seconds: float) -> None:
122
+ if limit < 1:
123
+ raise ValueError("limit must be at least 1")
124
+ self.limit = limit
125
+ self.wait_seconds = wait_seconds
126
+ self._slots: dict[tuple[str, str | None], _Slots] = {}
127
+
128
+ @classmethod
129
+ def from_settings(cls, settings: PoolSettings) -> WorkspaceLimiter:
130
+ """The limiter ``settings`` sized together with the pool."""
131
+ return cls(
132
+ limit=settings.workspace_concurrency, wait_seconds=settings.workspace_wait_seconds
133
+ )
134
+
135
+ def tracked(self) -> int:
136
+ """How many workspaces have an entry: those with a call holding or waiting for a slot."""
137
+ return len(self._slots)
138
+
139
+ @asynccontextmanager
140
+ async def admit(self, ctx: SecurityContext) -> AsyncGenerator[None, None]:
141
+ """Hold one of ``ctx``'s workspace's slots for the body, or raise :class:`WorkspaceBusy`.
142
+
143
+ The lookup and the count change run with no await between them, so on one event loop
144
+ no other call can see the entry between its creation and its first user.
145
+ """
146
+ key = (ctx.tenant_id, ctx.workspace_id)
147
+ slots = self._slots.get(key)
148
+ if slots is None:
149
+ slots = self._slots[key] = _Slots(anyio.Semaphore(self.limit))
150
+ slots.users += 1
151
+ try:
152
+ try:
153
+ with anyio.fail_after(self.wait_seconds):
154
+ await slots.semaphore.acquire()
155
+ except TimeoutError:
156
+ raise WorkspaceBusy from None
157
+ try:
158
+ yield
159
+ finally:
160
+ slots.semaphore.release()
161
+ finally:
162
+ slots.users -= 1
163
+ if slots.users == 0:
164
+ del self._slots[key]
165
+
166
+
167
+ @asynccontextmanager
168
+ async def _refusing_checkout_timeout[T](
169
+ session: AbstractAsyncContextManager[T], refusals: Refusals | None = None
170
+ ) -> AsyncGenerator[T, None]:
171
+ """Enter ``session``, turning a pool timeout at its own checkout into :class:`PoolExhausted`.
172
+
173
+ A pool timeout raised after the session is open belongs to the body (a second session it
174
+ opened, say, which may follow a committed write), so it propagates unchanged.
175
+ """
176
+ entered = False
177
+ try:
178
+ async with session as opened:
179
+ entered = True
180
+ yield opened
181
+ except PoolTimeout:
182
+ if entered:
183
+ raise
184
+ raise _apply(PoolExhausted(), refusals) from None
185
+
186
+
187
+ class AdmittedSlot:
188
+ """One of a workspace's slots, held by a request that opens several sessions in turn.
189
+
190
+ Each :meth:`session` runs under the held slot, so the request uses one connection at a
191
+ time and never waits on its own workspace's slots. A checkout that times out raises the
192
+ pool's own ``TimeoutError``, not :class:`PoolExhausted`: an earlier session under the slot
193
+ may have committed, so the request is not safe to retry as a whole.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ ctx: SecurityContext,
199
+ open_session: Callable[[SecurityContext], AbstractAsyncContextManager[Any]],
200
+ ) -> None:
201
+ self.ctx = ctx
202
+ self._open_session = open_session
203
+
204
+ def session(self) -> AbstractAsyncContextManager[Any]:
205
+ """A session for the slot's context, under the held slot."""
206
+ return self._open_session(self.ctx)
207
+
208
+
209
+ @asynccontextmanager
210
+ async def admitted_session[T](
211
+ limiter: WorkspaceLimiter,
212
+ ctx: SecurityContext,
213
+ open_session: Callable[[SecurityContext], AbstractAsyncContextManager[T]],
214
+ *,
215
+ refusals: Refusals | None = None,
216
+ ) -> AsyncGenerator[T, None]:
217
+ """Admit the call for ``ctx``'s workspace, then open its session with ``open_session``.
218
+
219
+ Raises :class:`WorkspaceBusy` when no slot frees within the limiter's wait, and
220
+ :class:`PoolExhausted` when the session's own checkout times out. A pool timeout raised
221
+ after the session is open belongs to the body, so it propagates unchanged.
222
+
223
+ The slot is released by the same exit that returns the session's connection, so a call
224
+ left by cancellation gives its slot back.
225
+ """
226
+ try:
227
+ async with (
228
+ limiter.admit(ctx),
229
+ _refusing_checkout_timeout(open_session(ctx), refusals) as session,
230
+ ):
231
+ yield session
232
+ except WorkspaceBusy as exc:
233
+ # The same object, re-raised: a refusal from the body is this server's too.
234
+ _apply(exc, refusals)
235
+ raise
pgbulkhead/context.py ADDED
@@ -0,0 +1,114 @@
1
+ """The verified identity a session binds.
2
+
3
+ A :class:`SecurityContext` is built **only** from something your server has verified: the
4
+ claims of a token it checked, or headers set by a gateway that checked one and that a caller
5
+ cannot reach past. Never from a tool argument or a request body. An agent writes those, and an
6
+ agent writes what its prompt steers it to, so a tenant taken from one is a tenant anyone who can
7
+ put text in front of the agent can choose. The body may *ask* for a workspace; the verified
8
+ identity decides whether it gets one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from typing import Any, overload
16
+
17
+ __all__ = ["ClaimNames", "SecurityContext", "context_from_claims"]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class SecurityContext:
22
+ """The identity and grants of one request.
23
+
24
+ - ``tenant_id``: the hard wall. Never crossed.
25
+ - ``workspace_id``: the active workspace, the soft wall inside the tenant, and the only one
26
+ writes go to. ``None`` for a single-key model, which has no workspace.
27
+ - ``allowed_workspaces``: the workspaces the request may **read**, from grants in the
28
+ verified identity. A session reads the active workspace whether or not it is listed. A
29
+ read grant never implies a write.
30
+ - ``scopes``: capability scopes, checked with :meth:`require_scope`.
31
+ """
32
+
33
+ tenant_id: str
34
+ workspace_id: str | None = None
35
+ allowed_workspaces: frozenset[str] = frozenset()
36
+ scopes: frozenset[str] = frozenset()
37
+ user_id: str | None = None
38
+ correlation_id: str | None = None
39
+
40
+ def readable_workspaces(self) -> frozenset[str]:
41
+ """The workspaces a session may read: the grants plus the active one."""
42
+ if self.workspace_id is None:
43
+ return self.allowed_workspaces
44
+ return self.allowed_workspaces | {self.workspace_id}
45
+
46
+ def require_scope(self, scope: str) -> None:
47
+ """Raise :class:`PermissionError` unless the context carries ``scope``."""
48
+ if scope not in self.scopes:
49
+ raise PermissionError(f"missing required scope: {scope}")
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ClaimNames:
54
+ """The claim each context field is read from. ``workspace`` is ``None`` for a single-key
55
+ model, whose context has no workspace."""
56
+
57
+ tenant: str = "tenant_id"
58
+ workspace: str | None = "workspace_id"
59
+ allowed_workspaces: str = "allowed_workspaces"
60
+ scopes: str = "scopes"
61
+ user_id: str = "user_id"
62
+ correlation_id: str = "correlation_id"
63
+
64
+
65
+ @overload
66
+ def context_from_claims(
67
+ claims: Mapping[str, object], names: ClaimNames | None = None
68
+ ) -> SecurityContext: ...
69
+ @overload
70
+ def context_from_claims[C: SecurityContext](
71
+ claims: Mapping[str, object], names: ClaimNames | None = None, *, cls: type[C]
72
+ ) -> C: ...
73
+ def context_from_claims(
74
+ claims: Mapping[str, object],
75
+ names: ClaimNames | None = None,
76
+ *,
77
+ cls: type[Any] = SecurityContext,
78
+ ) -> Any:
79
+ """A context from **verified** token claims; ``cls`` builds a subclass.
80
+
81
+ Fails closed: a missing or empty tenant (or workspace, when ``names.workspace`` is set)
82
+ raises :class:`ValueError` rather than defaulting to a wider scope. The active workspace is
83
+ always readable, so it is added to ``allowed_workspaces``.
84
+ """
85
+ names = names or ClaimNames()
86
+ tenant = claims.get(names.tenant)
87
+ if not isinstance(tenant, str) or not tenant:
88
+ raise ValueError(f"security context requires a verified {names.tenant} claim")
89
+ workspace: str | None = None
90
+ if names.workspace is not None:
91
+ value = claims.get(names.workspace)
92
+ if not isinstance(value, str) or not value:
93
+ raise ValueError(f"security context requires a verified {names.workspace} claim")
94
+ workspace = value
95
+ allowed = _str_set(claims.get(names.allowed_workspaces))
96
+ return cls(
97
+ tenant_id=tenant,
98
+ workspace_id=workspace,
99
+ allowed_workspaces=allowed | {workspace} if workspace else allowed,
100
+ scopes=_str_set(claims.get(names.scopes)),
101
+ user_id=_opt_str(claims.get(names.user_id)),
102
+ correlation_id=_opt_str(claims.get(names.correlation_id)),
103
+ )
104
+
105
+
106
+ def _opt_str(value: object) -> str | None:
107
+ return value if isinstance(value, str) and value else None
108
+
109
+
110
+ def _str_set(value: object) -> frozenset[str]:
111
+ """A loosely typed claim (a list, tuple or set, or nothing) as a set of strings."""
112
+ if isinstance(value, list | tuple | set | frozenset):
113
+ return frozenset(str(x) for x in value)
114
+ return frozenset()
pgbulkhead/database.py ADDED
@@ -0,0 +1,144 @@
1
+ """One server's database: its tenancy model, pool settings, pooled engine and workspace limiter.
2
+
3
+ A server declares one :class:`SecuredDatabase` per process::
4
+
5
+ db = SecuredDatabase(model=MODEL, url_env="NOTES_DATABASE_URL", prefix="NOTES")
6
+
7
+ and calls :meth:`SecuredDatabase.check` once before it serves. The object reads and validates
8
+ the settings once, builds the engine lazily on the first session (importing the server needs no
9
+ database), and admits every :meth:`~SecuredDatabase.session` against the workspace's
10
+ concurrency limit before it checks a connection out.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from collections.abc import AsyncGenerator, Callable
17
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
18
+
19
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
20
+
21
+ from .admission import (
22
+ AdmittedSlot,
23
+ Refusals,
24
+ WorkspaceBusy,
25
+ WorkspaceLimiter,
26
+ _apply,
27
+ admitted_session,
28
+ )
29
+ from .context import SecurityContext
30
+ from .model import TenancyModel
31
+ from .pool import PoolSettings, create_pooled_engine
32
+ from .session import scoped_session, system_session
33
+
34
+ __all__ = ["SecuredDatabase"]
35
+
36
+
37
+ class SecuredDatabase:
38
+ """The model, settings, engine and limiter one server process uses for its own database.
39
+
40
+ ``model`` is how its tables are isolated. ``url_env`` names the env var holding the async
41
+ Postgres URL, for a role that is neither a superuser nor ``BYPASSRLS``; ``url`` instead
42
+ supplies it with a callable. ``prefix`` is the env namespace of the pool settings
43
+ (:class:`PoolSettings`), and ``defaults`` the server's own when its traffic needs more than
44
+ the class defaults. ``refusals`` replaces the text of the overload errors its sessions raise.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ model: TenancyModel,
51
+ prefix: str,
52
+ url_env: str | None = None,
53
+ url: Callable[[], str] | None = None,
54
+ defaults: PoolSettings | None = None,
55
+ refusals: Refusals | None = None,
56
+ ) -> None:
57
+ if url_env is None and url is None:
58
+ raise ValueError("SecuredDatabase needs url_env or url")
59
+ self.model = model
60
+ self.url_env = url_env
61
+ self.prefix = prefix
62
+ self.refusals = refusals
63
+ self._defaults = defaults
64
+ self._url = url or self._url_from_env
65
+ self._settings: PoolSettings | None = None
66
+ self._limiter: WorkspaceLimiter | None = None
67
+ self._engine: AsyncEngine | None = None
68
+
69
+ def _url_from_env(self) -> str:
70
+ assert self.url_env is not None
71
+ url = os.environ.get(self.url_env, "").strip()
72
+ if not url:
73
+ raise RuntimeError(
74
+ f"{self.url_env} is not set — the async Postgres URL for this server's app "
75
+ "role (NOSUPERUSER NOBYPASSRLS) is required."
76
+ )
77
+ return url
78
+
79
+ @property
80
+ def settings(self) -> PoolSettings:
81
+ """The pool and admission settings, read and validated on first use, then fixed."""
82
+ if self._settings is None:
83
+ self._settings = PoolSettings.from_env(self.prefix, defaults=self._defaults)
84
+ return self._settings
85
+
86
+ @property
87
+ def limiter(self) -> WorkspaceLimiter:
88
+ """The process-wide per-workspace limiter, sized with the pool."""
89
+ if self._limiter is None:
90
+ self._limiter = WorkspaceLimiter.from_settings(self.settings)
91
+ return self._limiter
92
+
93
+ def check(self) -> None:
94
+ """Read and validate the settings and build the limiter; call it at startup.
95
+
96
+ Raises :class:`~pgbulkhead.PoolConfigError` for settings that cannot hold, so a bad
97
+ value fails the rollout rather than a user's call. Opens no connection.
98
+ """
99
+ _ = self.limiter
100
+
101
+ def get_engine(self) -> AsyncEngine:
102
+ """The pooled engine, built on first use."""
103
+ if self._engine is None:
104
+ self._engine = create_pooled_engine(self._url(), self.settings)
105
+ return self._engine
106
+
107
+ async def dispose(self) -> None:
108
+ """Dispose the engine and its pool; the next session builds a new one."""
109
+ if self._engine is not None:
110
+ engine, self._engine = self._engine, None
111
+ await engine.dispose()
112
+
113
+ def session(self, ctx: SecurityContext) -> AbstractAsyncContextManager[AsyncConnection]:
114
+ """The call's own scoped session, admitted against ``ctx``'s workspace limit first.
115
+
116
+ Raises :class:`~pgbulkhead.Overloaded`, having run nothing, when no slot frees in time
117
+ or, once admitted, no connection does.
118
+ """
119
+ return admitted_session(self.limiter, ctx, self.unadmitted_session, refusals=self.refusals)
120
+
121
+ def unadmitted_session(
122
+ self, ctx: SecurityContext
123
+ ) -> AbstractAsyncContextManager[AsyncConnection]:
124
+ """A scoped session with no admission, for one opened while the request holds a slot
125
+ (a write committed apart from its session, say). Admitting that one too could deadlock
126
+ a workspace whose every slot waits on a second session."""
127
+ return scoped_session(self.get_engine(), self.model, ctx)
128
+
129
+ @asynccontextmanager
130
+ async def admitted(self, ctx: SecurityContext) -> AsyncGenerator[AdmittedSlot, None]:
131
+ """Hold one of ``ctx``'s workspace slots across several sessions opened in turn."""
132
+ try:
133
+ async with self.limiter.admit(ctx):
134
+ yield AdmittedSlot(ctx, self.unadmitted_session)
135
+ except WorkspaceBusy as exc:
136
+ _apply(exc, self.refusals)
137
+ raise
138
+
139
+ def system_session(
140
+ self, tenant_id: str | None = None
141
+ ) -> AbstractAsyncContextManager[AsyncConnection]:
142
+ """A session through the model's :class:`~pgbulkhead.GucBypass`; never admitted, since
143
+ it belongs to no one workspace. See :func:`pgbulkhead.system_session`."""
144
+ return system_session(self.get_engine(), self.model, tenant_id)