toolframe-sql-postgres 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.
- toolframe_sql_postgres/__init__.py +1 -0
- toolframe_sql_postgres/backup.py +163 -0
- toolframe_sql_postgres/cli.py +221 -0
- toolframe_sql_postgres/config.py +104 -0
- toolframe_sql_postgres/plugin.py +590 -0
- toolframe_sql_postgres/provision.py +348 -0
- toolframe_sql_postgres/sql.py +203 -0
- toolframe_sql_postgres-0.2.0.dist-info/METADATA +104 -0
- toolframe_sql_postgres-0.2.0.dist-info/RECORD +13 -0
- toolframe_sql_postgres-0.2.0.dist-info/WHEEL +4 -0
- toolframe_sql_postgres-0.2.0.dist-info/entry_points.txt +5 -0
- toolframe_sql_postgres-0.2.0.dist-info/licenses/LICENSE +202 -0
- toolframe_sql_postgres-0.2.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Explicit, administrator-run provisioning; never invoked by the runtime plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
from psycopg import sql
|
|
11
|
+
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
|
12
|
+
from toolframe_sdk import PluginError
|
|
13
|
+
|
|
14
|
+
from .config import names
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def identifier(value):
|
|
18
|
+
if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,62}", value):
|
|
19
|
+
raise PluginError(
|
|
20
|
+
"database_name_invalid", "Use a lowercase PostgreSQL database identifier"
|
|
21
|
+
)
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def role(
|
|
26
|
+
connection, name, *, password=None, existing=False, memberships=(), created=None
|
|
27
|
+
):
|
|
28
|
+
found = connection.execute(
|
|
29
|
+
"SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls,rolcanlogin,rolinherit FROM pg_roles WHERE rolname=%s",
|
|
30
|
+
(name,),
|
|
31
|
+
).fetchone()
|
|
32
|
+
if found:
|
|
33
|
+
if not existing or any(found[:5]) or found[5] != bool(password) or found[6]:
|
|
34
|
+
raise PluginError(
|
|
35
|
+
"role_conflict",
|
|
36
|
+
"Existing role cannot be adopted; use matching setup credentials",
|
|
37
|
+
)
|
|
38
|
+
parents = connection.execute(
|
|
39
|
+
"SELECT parent.rolname FROM pg_auth_members m JOIN pg_roles member ON member.oid=m.member JOIN pg_roles parent ON parent.oid=m.roleid WHERE member.rolname=%s",
|
|
40
|
+
(name,),
|
|
41
|
+
).fetchall()
|
|
42
|
+
if {row[0] for row in parents} != set(memberships):
|
|
43
|
+
raise PluginError(
|
|
44
|
+
"role_conflict",
|
|
45
|
+
"Existing role membership differs from the provisioning contract",
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
attributes = (
|
|
49
|
+
sql.SQL("LOGIN PASSWORD {} NOINHERIT").format(sql.Literal(password))
|
|
50
|
+
if password
|
|
51
|
+
else sql.SQL("NOLOGIN NOINHERIT")
|
|
52
|
+
)
|
|
53
|
+
connection.execute(
|
|
54
|
+
sql.SQL(
|
|
55
|
+
"CREATE ROLE {} {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS"
|
|
56
|
+
).format(sql.Identifier(name), attributes)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if created is not None:
|
|
60
|
+
created.append(("role", name))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def provision(admin_dsn, database, application_id, owners, *, previous=None):
|
|
64
|
+
created = []
|
|
65
|
+
try:
|
|
66
|
+
return _provision(
|
|
67
|
+
admin_dsn,
|
|
68
|
+
database,
|
|
69
|
+
application_id,
|
|
70
|
+
owners,
|
|
71
|
+
previous=previous,
|
|
72
|
+
created=created,
|
|
73
|
+
)
|
|
74
|
+
except BaseException:
|
|
75
|
+
if not created:
|
|
76
|
+
raise
|
|
77
|
+
# Roll back only resources created by this invocation. Existing data is
|
|
78
|
+
# never adopted or dropped as a response to a failed setup attempt.
|
|
79
|
+
try:
|
|
80
|
+
with psycopg.connect(admin_dsn, autocommit=True) as admin:
|
|
81
|
+
for kind, name in reversed(created):
|
|
82
|
+
if kind == "database":
|
|
83
|
+
admin.execute(
|
|
84
|
+
sql.SQL("DROP DATABASE {} WITH (FORCE)").format(
|
|
85
|
+
sql.Identifier(name)
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
for kind, name in reversed(created):
|
|
89
|
+
if kind == "role":
|
|
90
|
+
if previous:
|
|
91
|
+
admin.execute(
|
|
92
|
+
sql.SQL("REVOKE ALL ON DATABASE {} FROM {}").format(
|
|
93
|
+
sql.Identifier(database), sql.Identifier(name)
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
admin.execute(
|
|
97
|
+
sql.SQL("DROP ROLE {}").format(sql.Identifier(name))
|
|
98
|
+
)
|
|
99
|
+
except psycopg.Error:
|
|
100
|
+
raise PluginError(
|
|
101
|
+
"setup_cleanup_failed",
|
|
102
|
+
"Setup failed and its new resources need administrator inspection before retrying",
|
|
103
|
+
) from None
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _provision(admin_dsn, database, application_id, owners, *, previous=None, created):
|
|
108
|
+
"""Provision fresh resources or add owners using a previous setup result.
|
|
109
|
+
|
|
110
|
+
Returns environment values. Callers must save them privately, never print them.
|
|
111
|
+
Existing databases/roles are only reused when the stored application identity
|
|
112
|
+
and the previous credentials match. Other application resources are untouched.
|
|
113
|
+
"""
|
|
114
|
+
identifier(database)
|
|
115
|
+
if not application_id or len(application_id) > 256:
|
|
116
|
+
raise PluginError("application_id_invalid", "Application identity is required")
|
|
117
|
+
owners = sorted(set(owners))
|
|
118
|
+
if not owners or any(
|
|
119
|
+
not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", owner) for owner in owners
|
|
120
|
+
):
|
|
121
|
+
raise PluginError("owner_invalid", "Supply stable plugin IDs to provision")
|
|
122
|
+
manager = names(application_id, database=database)["manager"]
|
|
123
|
+
stored = json.loads(previous["TOOLFRAME_POSTGRES_STORES"]) if previous else {}
|
|
124
|
+
manager_password = secrets.token_hex(32)
|
|
125
|
+
manager_dsn = make_conninfo(
|
|
126
|
+
admin_dsn, dbname=database, user=manager, password=manager_password
|
|
127
|
+
)
|
|
128
|
+
if previous:
|
|
129
|
+
if previous["TOOLFRAME_POSTGRES_APPLICATION_ID"] != application_id:
|
|
130
|
+
raise PluginError(
|
|
131
|
+
"identity_mismatch", "Setup output belongs to a different application"
|
|
132
|
+
)
|
|
133
|
+
manager_dsn = previous["TOOLFRAME_POSTGRES_DSN"]
|
|
134
|
+
parsed = conninfo_to_dict(manager_dsn)
|
|
135
|
+
if parsed.get("dbname") != database or parsed.get("user") != manager:
|
|
136
|
+
raise PluginError(
|
|
137
|
+
"identity_mismatch", "Setup output belongs to a different database"
|
|
138
|
+
)
|
|
139
|
+
manager_dsn = make_conninfo(
|
|
140
|
+
admin_dsn, dbname=database, user=manager, password=parsed["password"]
|
|
141
|
+
)
|
|
142
|
+
# Prove that the retained credentials work before touching any existing resource.
|
|
143
|
+
with psycopg.connect(manager_dsn) as check:
|
|
144
|
+
identity = check.execute(
|
|
145
|
+
"SELECT application_id FROM _toolframe_platform.identity"
|
|
146
|
+
).fetchall()
|
|
147
|
+
if identity != [(application_id,)]:
|
|
148
|
+
raise PluginError(
|
|
149
|
+
"identity_mismatch", "Database application identity differs"
|
|
150
|
+
)
|
|
151
|
+
for owner, credentials in stored.items():
|
|
152
|
+
ns = names(application_id, owner, database=database)
|
|
153
|
+
if credentials.get("user") != ns["controller"]:
|
|
154
|
+
raise PluginError(
|
|
155
|
+
"identity_mismatch",
|
|
156
|
+
"Retained store credentials belong to another owner",
|
|
157
|
+
)
|
|
158
|
+
with psycopg.connect(make_conninfo(manager_dsn, **credentials)) as check:
|
|
159
|
+
identity = check.execute(
|
|
160
|
+
sql.SQL("SELECT application_id,owner FROM {}.identity").format(
|
|
161
|
+
sql.Identifier(ns["metadata"])
|
|
162
|
+
)
|
|
163
|
+
).fetchall()
|
|
164
|
+
if identity != [(application_id, owner)]:
|
|
165
|
+
raise PluginError("identity_mismatch", "Store identity differs")
|
|
166
|
+
with psycopg.connect(admin_dsn, autocommit=True) as admin:
|
|
167
|
+
if admin.info.server_version < 160000:
|
|
168
|
+
raise PluginError(
|
|
169
|
+
"postgres_version_unsupported",
|
|
170
|
+
"Provisioning requires PostgreSQL 16 or newer",
|
|
171
|
+
)
|
|
172
|
+
found = admin.execute(
|
|
173
|
+
"SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname=%s",
|
|
174
|
+
(database,),
|
|
175
|
+
).fetchone()
|
|
176
|
+
if found and (not previous or found[0] != manager):
|
|
177
|
+
raise PluginError(
|
|
178
|
+
"database_conflict", "Refusing to adopt an existing database"
|
|
179
|
+
)
|
|
180
|
+
role(
|
|
181
|
+
admin,
|
|
182
|
+
manager,
|
|
183
|
+
password=manager_password,
|
|
184
|
+
existing=bool(previous),
|
|
185
|
+
created=created,
|
|
186
|
+
)
|
|
187
|
+
if not found:
|
|
188
|
+
admin.execute(
|
|
189
|
+
sql.SQL("CREATE DATABASE {} OWNER {}").format(
|
|
190
|
+
sql.Identifier(database), sql.Identifier(manager)
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
created.append(("database", database))
|
|
194
|
+
admin.execute(
|
|
195
|
+
sql.SQL("REVOKE ALL ON DATABASE {} FROM PUBLIC").format(
|
|
196
|
+
sql.Identifier(database)
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
for owner in owners:
|
|
200
|
+
ns = names(application_id, owner, database=database)
|
|
201
|
+
creds = stored.get(owner) or {
|
|
202
|
+
"user": ns["controller"],
|
|
203
|
+
"password": secrets.token_hex(32),
|
|
204
|
+
}
|
|
205
|
+
role(
|
|
206
|
+
admin,
|
|
207
|
+
ns["controller"],
|
|
208
|
+
password=creds["password"],
|
|
209
|
+
existing=owner in stored,
|
|
210
|
+
memberships=(ns["runtime"],),
|
|
211
|
+
created=created,
|
|
212
|
+
)
|
|
213
|
+
role(admin, ns["runtime"], existing=owner in stored, created=created)
|
|
214
|
+
admin.execute(
|
|
215
|
+
sql.SQL("GRANT {} TO {} WITH INHERIT FALSE, SET TRUE").format(
|
|
216
|
+
sql.Identifier(ns["runtime"]), sql.Identifier(ns["controller"])
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
admin.execute(
|
|
220
|
+
sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format(
|
|
221
|
+
sql.Identifier(database), sql.Identifier(ns["controller"])
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
stored[owner] = creds
|
|
225
|
+
with psycopg.connect(make_conninfo(admin_dsn, dbname=database)) as db:
|
|
226
|
+
db.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC")
|
|
227
|
+
db.execute(
|
|
228
|
+
sql.SQL(
|
|
229
|
+
"CREATE SCHEMA IF NOT EXISTS _toolframe_platform AUTHORIZATION {}"
|
|
230
|
+
).format(sql.Identifier(manager))
|
|
231
|
+
)
|
|
232
|
+
db.execute(sql.SQL("SET LOCAL ROLE {}").format(sql.Identifier(manager)))
|
|
233
|
+
db.execute(
|
|
234
|
+
"CREATE TABLE IF NOT EXISTS _toolframe_platform.identity(application_id TEXT PRIMARY KEY)"
|
|
235
|
+
)
|
|
236
|
+
current = db.execute(
|
|
237
|
+
"SELECT application_id FROM _toolframe_platform.identity"
|
|
238
|
+
).fetchall()
|
|
239
|
+
if current and current != [(application_id,)]:
|
|
240
|
+
raise PluginError(
|
|
241
|
+
"identity_mismatch", "Database application identity differs"
|
|
242
|
+
)
|
|
243
|
+
db.execute(
|
|
244
|
+
"INSERT INTO _toolframe_platform.identity VALUES (%s) ON CONFLICT DO NOTHING",
|
|
245
|
+
(application_id,),
|
|
246
|
+
)
|
|
247
|
+
db.execute("RESET ROLE")
|
|
248
|
+
for owner in owners:
|
|
249
|
+
ns = names(application_id, owner, database=database)
|
|
250
|
+
for key in ("schema", "metadata"):
|
|
251
|
+
db.execute(
|
|
252
|
+
sql.SQL("CREATE SCHEMA IF NOT EXISTS {} AUTHORIZATION {}").format(
|
|
253
|
+
sql.Identifier(ns[key]), sql.Identifier(ns["controller"])
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
db.execute(
|
|
257
|
+
sql.SQL("REVOKE ALL ON SCHEMA {} FROM PUBLIC").format(
|
|
258
|
+
sql.Identifier(ns[key])
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
db.execute(
|
|
262
|
+
sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(
|
|
263
|
+
sql.Identifier(ns[key]), sql.Identifier(manager)
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
db.execute(
|
|
267
|
+
sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(
|
|
268
|
+
sql.Identifier(ns["schema"]), sql.Identifier(ns["runtime"])
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
db.execute(
|
|
272
|
+
sql.SQL("SET LOCAL ROLE {}").format(sql.Identifier(ns["controller"]))
|
|
273
|
+
)
|
|
274
|
+
for who, permissions in (
|
|
275
|
+
(ns["runtime"], "SELECT, INSERT, UPDATE, DELETE"),
|
|
276
|
+
(manager, "SELECT"),
|
|
277
|
+
):
|
|
278
|
+
db.execute(
|
|
279
|
+
sql.SQL(
|
|
280
|
+
"ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT {} ON TABLES TO {}"
|
|
281
|
+
).format(
|
|
282
|
+
sql.Identifier(ns["schema"]),
|
|
283
|
+
sql.SQL(permissions),
|
|
284
|
+
sql.Identifier(who),
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
db.execute(
|
|
288
|
+
sql.SQL("GRANT {} ON ALL TABLES IN SCHEMA {} TO {}").format(
|
|
289
|
+
sql.SQL(permissions),
|
|
290
|
+
sql.Identifier(ns["schema"]),
|
|
291
|
+
sql.Identifier(who),
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
db.execute(
|
|
295
|
+
sql.SQL(
|
|
296
|
+
"ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT USAGE, SELECT ON SEQUENCES TO {}"
|
|
297
|
+
).format(sql.Identifier(ns["schema"]), sql.Identifier(who))
|
|
298
|
+
)
|
|
299
|
+
db.execute(
|
|
300
|
+
sql.SQL(
|
|
301
|
+
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {} TO {}"
|
|
302
|
+
).format(sql.Identifier(ns["schema"]), sql.Identifier(who))
|
|
303
|
+
)
|
|
304
|
+
db.execute(
|
|
305
|
+
sql.SQL(
|
|
306
|
+
"ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT SELECT ON TABLES TO {}"
|
|
307
|
+
).format(sql.Identifier(ns["metadata"]), sql.Identifier(manager))
|
|
308
|
+
)
|
|
309
|
+
db.execute(
|
|
310
|
+
sql.SQL(
|
|
311
|
+
"CREATE TABLE IF NOT EXISTS {}.identity(application_id TEXT NOT NULL, owner TEXT NOT NULL)"
|
|
312
|
+
).format(sql.Identifier(ns["metadata"]))
|
|
313
|
+
)
|
|
314
|
+
identity = db.execute(
|
|
315
|
+
sql.SQL("SELECT application_id,owner FROM {}.identity").format(
|
|
316
|
+
sql.Identifier(ns["metadata"])
|
|
317
|
+
)
|
|
318
|
+
).fetchall()
|
|
319
|
+
if identity and identity != [(application_id, owner)]:
|
|
320
|
+
raise PluginError("identity_mismatch", "Store identity differs")
|
|
321
|
+
if not identity:
|
|
322
|
+
db.execute(
|
|
323
|
+
sql.SQL("INSERT INTO {}.identity VALUES(%s,%s)").format(
|
|
324
|
+
sql.Identifier(ns["metadata"])
|
|
325
|
+
),
|
|
326
|
+
(application_id, owner),
|
|
327
|
+
)
|
|
328
|
+
db.execute(
|
|
329
|
+
sql.SQL(
|
|
330
|
+
"CREATE TABLE IF NOT EXISTS {}.migrations(position INTEGER PRIMARY KEY, revision TEXT UNIQUE NOT NULL, checksum TEXT NOT NULL)"
|
|
331
|
+
).format(sql.Identifier(ns["metadata"]))
|
|
332
|
+
)
|
|
333
|
+
db.execute(
|
|
334
|
+
sql.SQL(
|
|
335
|
+
"CREATE TABLE IF NOT EXISTS {}.receipts(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL)"
|
|
336
|
+
).format(sql.Identifier(ns["metadata"]))
|
|
337
|
+
)
|
|
338
|
+
db.execute(
|
|
339
|
+
sql.SQL("GRANT SELECT ON ALL TABLES IN SCHEMA {} TO {}").format(
|
|
340
|
+
sql.Identifier(ns["metadata"]), sql.Identifier(manager)
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
db.execute("RESET ROLE")
|
|
344
|
+
return {
|
|
345
|
+
"TOOLFRAME_POSTGRES_APPLICATION_ID": application_id,
|
|
346
|
+
"TOOLFRAME_POSTGRES_DSN": manager_dsn,
|
|
347
|
+
"TOOLFRAME_POSTGRES_STORES": json.dumps(stored, separators=(",", ":")),
|
|
348
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""A bounded SQL surface for managed calls; PostgreSQL roles enforce ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pglast import ast, parse_sql
|
|
6
|
+
from pglast.stream import RawStream
|
|
7
|
+
from pglast.visitors import Visitor
|
|
8
|
+
from toolframe_sdk import PluginError
|
|
9
|
+
|
|
10
|
+
# These are builtins in pg_catalog, which is first in the fixed search_path.
|
|
11
|
+
FUNCTIONS = frozenset(
|
|
12
|
+
[
|
|
13
|
+
"abs",
|
|
14
|
+
"avg",
|
|
15
|
+
"count",
|
|
16
|
+
"length",
|
|
17
|
+
"char_length",
|
|
18
|
+
"lower",
|
|
19
|
+
"upper",
|
|
20
|
+
"min",
|
|
21
|
+
"max",
|
|
22
|
+
"sum",
|
|
23
|
+
"round",
|
|
24
|
+
"trim",
|
|
25
|
+
"btrim",
|
|
26
|
+
"ltrim",
|
|
27
|
+
"rtrim",
|
|
28
|
+
"substr",
|
|
29
|
+
"substring",
|
|
30
|
+
"replace",
|
|
31
|
+
"concat",
|
|
32
|
+
"concat_ws",
|
|
33
|
+
"greatest",
|
|
34
|
+
"least",
|
|
35
|
+
"nullif",
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
MUTATIONS = (ast.InsertStmt, ast.UpdateStmt, ast.DeleteStmt)
|
|
39
|
+
DDL = (ast.CreateStmt, ast.IndexStmt, ast.AlterTableStmt, ast.DropStmt)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parameters(source: str, count: int) -> str:
|
|
43
|
+
"""Convert SDK qmarks to PostgreSQL AST parameters, respecting SQL quoting."""
|
|
44
|
+
result = []
|
|
45
|
+
i = 0
|
|
46
|
+
position = 0
|
|
47
|
+
while i < len(source):
|
|
48
|
+
char = source[i]
|
|
49
|
+
if source.startswith("--", i):
|
|
50
|
+
end = source.find("\n", i)
|
|
51
|
+
end = len(source) if end < 0 else end
|
|
52
|
+
result.append(source[i:end])
|
|
53
|
+
i = end
|
|
54
|
+
elif source.startswith("/*", i):
|
|
55
|
+
start = i
|
|
56
|
+
depth = 1
|
|
57
|
+
i += 2
|
|
58
|
+
while i < len(source) and depth:
|
|
59
|
+
if source.startswith("/*", i):
|
|
60
|
+
depth += 1
|
|
61
|
+
i += 2
|
|
62
|
+
elif source.startswith("*/", i):
|
|
63
|
+
depth -= 1
|
|
64
|
+
i += 2
|
|
65
|
+
else:
|
|
66
|
+
i += 1
|
|
67
|
+
if depth:
|
|
68
|
+
raise ValueError("Unclosed SQL comment")
|
|
69
|
+
result.append(source[start:i])
|
|
70
|
+
elif char in "\"'":
|
|
71
|
+
start = i
|
|
72
|
+
quote = char
|
|
73
|
+
i += 1
|
|
74
|
+
# Escape-string syntax is outside the SDK parameter contract.
|
|
75
|
+
if quote == "'" and start and source[start - 1] in "eE":
|
|
76
|
+
raise ValueError("Use parameters instead of escape-string literals")
|
|
77
|
+
while i < len(source):
|
|
78
|
+
if source[i] == quote:
|
|
79
|
+
i += 1
|
|
80
|
+
if i < len(source) and source[i] == quote:
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
break
|
|
84
|
+
i += 1
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError("Unclosed SQL literal")
|
|
87
|
+
result.append(source[start:i])
|
|
88
|
+
elif char == "$":
|
|
89
|
+
raise ValueError("Use SDK qmark parameters; dollar quoting is unsupported")
|
|
90
|
+
elif char == "?":
|
|
91
|
+
position += 1
|
|
92
|
+
result.append(f"${position}")
|
|
93
|
+
i += 1
|
|
94
|
+
else:
|
|
95
|
+
result.append(char)
|
|
96
|
+
i += 1
|
|
97
|
+
if position != count:
|
|
98
|
+
raise ValueError("SQL parameter count does not match")
|
|
99
|
+
return "".join(result)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Guard(Visitor):
|
|
103
|
+
def __init__(self, *, read_only=False, migration=False):
|
|
104
|
+
self.read_only, self.migration = read_only, migration
|
|
105
|
+
|
|
106
|
+
def visit(self, ancestors, node):
|
|
107
|
+
if self.read_only and isinstance(node, MUTATIONS):
|
|
108
|
+
raise ValueError("Read-only query cannot mutate")
|
|
109
|
+
if isinstance(node, ast.RangeVar) and (
|
|
110
|
+
node.schemaname
|
|
111
|
+
or node.catalogname
|
|
112
|
+
or node.relname.startswith(("_toolframe", "pg_"))
|
|
113
|
+
or node.relpersistence != "p"
|
|
114
|
+
):
|
|
115
|
+
raise ValueError("Use unqualified permanent store-owned tables")
|
|
116
|
+
if isinstance(node, ast.IndexStmt) and node.concurrent:
|
|
117
|
+
raise ValueError("Concurrent indexes cannot run in a migration transaction")
|
|
118
|
+
if isinstance(node, ast.A_Expr) and node.name and len(node.name) != 1:
|
|
119
|
+
raise ValueError("Qualified operators are outside the SQL contract")
|
|
120
|
+
if isinstance(node, ast.FuncCall):
|
|
121
|
+
parts = [part.sval for part in node.funcname]
|
|
122
|
+
# PostgreSQL lowers standard LIKE ... ESCAPE into this qualified
|
|
123
|
+
# builtin during parsing. It only transforms the search pattern;
|
|
124
|
+
# children still pass through the guard (including bound functions).
|
|
125
|
+
like_escape = (
|
|
126
|
+
parts == ["pg_catalog", "like_escape"] and len(node.args or ()) == 2
|
|
127
|
+
)
|
|
128
|
+
if not like_escape and (
|
|
129
|
+
len(parts) != 1 or parts[0].lower() not in FUNCTIONS
|
|
130
|
+
):
|
|
131
|
+
raise ValueError("Function is outside the managed SQL contract")
|
|
132
|
+
if isinstance(node, ast.TypeName):
|
|
133
|
+
parts = [part.sval for part in node.names]
|
|
134
|
+
if any(
|
|
135
|
+
part
|
|
136
|
+
in {"regclass", "regproc", "regprocedure", "regnamespace", "regrole"}
|
|
137
|
+
for part in parts
|
|
138
|
+
):
|
|
139
|
+
raise ValueError("Catalog identity casts are unsupported")
|
|
140
|
+
if isinstance(node, ast.SelectStmt) and (node.intoClause or node.lockingClause):
|
|
141
|
+
raise ValueError("SELECT INTO and explicit row locking are unsupported")
|
|
142
|
+
if isinstance(node, ast.DropStmt):
|
|
143
|
+
for name in node.objects:
|
|
144
|
+
if len(name) != 1 or name[0].sval.startswith(("_toolframe", "pg_")):
|
|
145
|
+
raise ValueError("Only unqualified store objects may be dropped")
|
|
146
|
+
if isinstance(node, ast.AlterTableCmd) and node.subtype.name not in {
|
|
147
|
+
"AT_AddColumn",
|
|
148
|
+
"AT_DropColumn",
|
|
149
|
+
"AT_AlterColumnType",
|
|
150
|
+
"AT_SetNotNull",
|
|
151
|
+
"AT_DropNotNull",
|
|
152
|
+
"AT_ColumnDefault",
|
|
153
|
+
"AT_AddConstraint",
|
|
154
|
+
"AT_DropConstraint",
|
|
155
|
+
"AT_ValidateConstraint",
|
|
156
|
+
}:
|
|
157
|
+
raise ValueError("Unsupported table alteration")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def prepare(source: str, values, method: str):
|
|
161
|
+
try:
|
|
162
|
+
if not isinstance(source, str) or len(source) > 900_000:
|
|
163
|
+
raise ValueError
|
|
164
|
+
tree = parse_sql(parameters(source, len(values)))
|
|
165
|
+
if len(tree) != 1:
|
|
166
|
+
raise ValueError("Exactly one SQL statement is required")
|
|
167
|
+
statement = tree[0].stmt
|
|
168
|
+
allowed = (ast.SelectStmt,) if method == "query" else MUTATIONS
|
|
169
|
+
if not isinstance(statement, allowed):
|
|
170
|
+
raise TypeError("Unsupported SQL statement")
|
|
171
|
+
Guard(read_only=method == "query")(statement)
|
|
172
|
+
returning = getattr(statement, "returningClause", None)
|
|
173
|
+
if method == "execute_returning" and not returning:
|
|
174
|
+
raise ValueError("Mutation must include RETURNING")
|
|
175
|
+
if method == "execute" and returning:
|
|
176
|
+
raise ValueError("Use execute_returning for returned rows")
|
|
177
|
+
# AsyncRawCursor accepts native $n placeholders and does not reinterpret '%'.
|
|
178
|
+
return RawStream()(statement), list(values)
|
|
179
|
+
except Exception: # noqa: BLE001 - parser diagnostics can contain bound/literal secrets
|
|
180
|
+
raise PluginError(
|
|
181
|
+
"sql_not_supported",
|
|
182
|
+
"SQL is outside the managed PostgreSQL contract or has invalid parameters",
|
|
183
|
+
) from None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def migration_statements(source):
|
|
187
|
+
try:
|
|
188
|
+
statements = parse_sql(source)
|
|
189
|
+
for raw in statements:
|
|
190
|
+
if not isinstance(raw.stmt, (*DDL, *MUTATIONS)):
|
|
191
|
+
raise TypeError
|
|
192
|
+
if isinstance(raw.stmt, ast.DropStmt) and raw.stmt.removeType.name not in {
|
|
193
|
+
"OBJECT_TABLE",
|
|
194
|
+
"OBJECT_INDEX",
|
|
195
|
+
}:
|
|
196
|
+
raise ValueError
|
|
197
|
+
Guard(migration=True)(raw.stmt)
|
|
198
|
+
yield RawStream()(raw.stmt)
|
|
199
|
+
except Exception: # noqa: BLE001 - redact SQL before crossing the RPC boundary
|
|
200
|
+
raise PluginError(
|
|
201
|
+
"migration_sql_invalid",
|
|
202
|
+
"Migration must contain transactional store-local table/index DDL or DML",
|
|
203
|
+
) from None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: toolframe-sql-postgres
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Managed PostgreSQL stores for Toolframe plugins
|
|
5
|
+
Project-URL: Homepage, https://github.com/toolframe/toolframe-sql-postgres
|
|
6
|
+
Project-URL: Repository, https://github.com/toolframe/toolframe-sql-postgres
|
|
7
|
+
Project-URL: Documentation, https://github.com/toolframe/toolframe-sql-postgres#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/toolframe/toolframe-sql-postgres/issues
|
|
9
|
+
Author-email: byfb <62798989+byfb@users.noreply.github.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Requires-Dist: pglast==8.4
|
|
15
|
+
Requires-Dist: psycopg[binary]<4,>=3.3
|
|
16
|
+
Requires-Dist: toolframe-sdk==0.4.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Toolframe PostgreSQL storage
|
|
20
|
+
|
|
21
|
+
`sql-postgres` stores Toolframe plugin data in PostgreSQL. Entry Register,
|
|
22
|
+
Auth, MCP Auth and OIDC Auth use a separate schema per plugin inside one database
|
|
23
|
+
per application environment. Database access is loaded **only from the runtime
|
|
24
|
+
process environment**; credentials do not belong in `toolframe.toml`.
|
|
25
|
+
|
|
26
|
+
This release requires Toolframe SDK 0.4 and Core 0.7. Run one Core per
|
|
27
|
+
application. Core 0.7 can separately store its management journal in a dedicated
|
|
28
|
+
PostgreSQL database; SQLite remains the default. Configuration, private keys and
|
|
29
|
+
artifacts still need a persistent application home. PostgreSQL storage does not
|
|
30
|
+
make Core stateless or enable multiple replicas.
|
|
31
|
+
Existing SQLite data is not imported. Plugins are trusted Python code.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
Use Python 3.14+, PostgreSQL 16+, and `pg_dump` / `pg_restore` with the **same major
|
|
36
|
+
version** as the server. PostgreSQL 16 is the integration-test baseline.
|
|
37
|
+
|
|
38
|
+
Install the host and provider CLI from PyPI:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
uv venv --python 3.14.7 .venv
|
|
42
|
+
uv pip install --python .venv/bin/python \
|
|
43
|
+
toolframe-core==0.7.0 toolframe-sql-postgres==0.2.0
|
|
44
|
+
.venv/bin/toolframe-core --home ./app init
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Read `[application].id` from `app/toolframe.toml`. Supply an administrator DSN from
|
|
48
|
+
your secret manager as `TOOLFRAME_POSTGRES_SETUP_DSN`. It is used only by setup.
|
|
49
|
+
The CLI output file contains generated runtime credentials and must remain private.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
.venv/bin/toolframe-postgres setup \
|
|
53
|
+
--application-id '<id from app/toolframe.toml>' \
|
|
54
|
+
--database toolframe_development \
|
|
55
|
+
--owner entry-register --owner auth --owner mcp-auth --owner oidc-auth \
|
|
56
|
+
--output ./app/.toolframe/postgres-environment.json
|
|
57
|
+
unset TOOLFRAME_POSTGRES_SETUP_DSN
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Add the provider to `app/toolframe.toml`:
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
[plugins.sql-postgres]
|
|
64
|
+
source = "pypi:toolframe-sql-postgres==0.2.0"
|
|
65
|
+
enabled = true
|
|
66
|
+
|
|
67
|
+
[bindings]
|
|
68
|
+
"toolframe.sql" = "sql-postgres"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This configuration sends every SQL consumer to PostgreSQL. Core builds with
|
|
72
|
+
multiple SQL provider support can also run SQLite, using an explicit per-plugin
|
|
73
|
+
override such as `"entry-register/sql" = "sql-sqlite"` in `[bindings]`.
|
|
74
|
+
Choose bindings before initializing stores: changing a retained store's provider
|
|
75
|
+
is rejected and does not copy data. Earlier Core builds require one enabled
|
|
76
|
+
storage provider. The package version alone does not identify multiple-provider
|
|
77
|
+
support; build the matching Core revision when using mixed providers.
|
|
78
|
+
|
|
79
|
+
Install the matching business plugins
|
|
80
|
+
before enabling them: Entry Register 0.4, Auth 0.3, MCP Auth 0.3 and OIDC Auth 0.3.
|
|
81
|
+
Each store owner must be provisioned before Core starts its plugin.
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
.venv/bin/toolframe-postgres run \
|
|
85
|
+
--env-file ./app/.toolframe/postgres-environment.json -- \
|
|
86
|
+
.venv/bin/toolframe-core --home ./app serve
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The `run` helper exports the private document as environment variables and replaces
|
|
90
|
+
itself with Core. It removes setup/test administrator variables. Containers can
|
|
91
|
+
inject the same runtime values directly; the provider does not read that file.
|
|
92
|
+
|
|
93
|
+
- [Setup, environment and recovery](docs/operations.md)
|
|
94
|
+
- [SQL and isolation contract](docs/storage-contract.md)
|
|
95
|
+
- [Development and verification](docs/development.md)
|
|
96
|
+
- [Domain glossary](docs/glossary.md) and [architecture decisions](docs/adr/0001-plugin-storage-boundary.md)
|
|
97
|
+
|
|
98
|
+
For release validation and Trusted Publisher setup, see [releasing](docs/releasing.md).
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
|
103
|
+
|
|
104
|
+
For development hooks and contribution rules, see [Contributing](CONTRIBUTING.md).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
toolframe_sql_postgres/__init__.py,sha256=yxb8kdJcYr5KWyUPPSiS5ESBye1Hqq1tID5GSux87jU,81
|
|
2
|
+
toolframe_sql_postgres/backup.py,sha256=37i1N8YJQNraBHr1J6rAjDLn6ZUe7ytSz3Z1E65nP6o,5538
|
|
3
|
+
toolframe_sql_postgres/cli.py,sha256=0UoyIDX564pNVFZlSi5TS5GbvSu8P1NwkhZP14lCLo0,8079
|
|
4
|
+
toolframe_sql_postgres/config.py,sha256=PZLvDVsMHTMwFxknTQ1KLsG8dD8_UF1R6m_017sD030,3699
|
|
5
|
+
toolframe_sql_postgres/plugin.py,sha256=089XX0DJXcHOp50Ot886pdrmt41SGVzDyHEen0zggCo,23748
|
|
6
|
+
toolframe_sql_postgres/provision.py,sha256=rxrang9Tvgq6dsBJ636lG0GZLrgY8da2G2sDumHZR4Q,14568
|
|
7
|
+
toolframe_sql_postgres/sql.py,sha256=_EsmE8mUCnsgoUJLg-xk377zBCb66t6587vnecYlf20,7805
|
|
8
|
+
toolframe_sql_postgres-0.2.0.dist-info/METADATA,sha256=f0IPQ4hSITRYtEhCByARpdIotfO-Ok-uFMp1T5TJ6EQ,4297
|
|
9
|
+
toolframe_sql_postgres-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
toolframe_sql_postgres-0.2.0.dist-info/entry_points.txt,sha256=tE0jNFs0Go3xlOk0fCPxWM8mlnVmMIqTiCSdwOwAgsI,144
|
|
11
|
+
toolframe_sql_postgres-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
12
|
+
toolframe_sql_postgres-0.2.0.dist-info/licenses/NOTICE,sha256=NJT1YiIiAYUGjix9VOzO18OJRNb8NeCSd5XkbU7VJhw,43
|
|
13
|
+
toolframe_sql_postgres-0.2.0.dist-info/RECORD,,
|