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 +71 -0
- pgbulkhead/admission.py +235 -0
- pgbulkhead/context.py +114 -0
- pgbulkhead/database.py +144 -0
- pgbulkhead/model.py +154 -0
- pgbulkhead/policy.py +110 -0
- pgbulkhead/pool.py +188 -0
- pgbulkhead/py.typed +0 -0
- pgbulkhead/session.py +159 -0
- pgbulkhead/testing.py +24 -0
- pgbulkhead-0.1.0.dist-info/METADATA +285 -0
- pgbulkhead-0.1.0.dist-info/RECORD +15 -0
- pgbulkhead-0.1.0.dist-info/WHEEL +4 -0
- pgbulkhead-0.1.0.dist-info/licenses/LICENSE +201 -0
- pgbulkhead-0.1.0.dist-info/licenses/NOTICE +4 -0
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
|
+
]
|
pgbulkhead/admission.py
ADDED
|
@@ -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)
|