@venturekit/data 0.0.32 → 0.0.33
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.
- package/dist/files/index.d.ts +9 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +8 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/postgres.d.ts +150 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/files/postgres.js +194 -0
- package/dist/files/postgres.js.map +1 -0
- package/dist/idempotency/index.d.ts +9 -0
- package/dist/idempotency/index.d.ts.map +1 -0
- package/dist/idempotency/index.js +8 -0
- package/dist/idempotency/index.js.map +1 -0
- package/dist/idempotency/postgres.d.ts +107 -0
- package/dist/idempotency/postgres.d.ts.map +1 -0
- package/dist/idempotency/postgres.js +145 -0
- package/dist/idempotency/postgres.js.map +1 -0
- package/dist/internal/identifier.d.ts +16 -0
- package/dist/internal/identifier.d.ts.map +1 -0
- package/dist/internal/identifier.js +23 -0
- package/dist/internal/identifier.js.map +1 -0
- package/dist/jobs/index.d.ts +9 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +8 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/postgres.d.ts +197 -0
- package/dist/jobs/postgres.d.ts.map +1 -0
- package/dist/jobs/postgres.js +270 -0
- package/dist/jobs/postgres.js.map +1 -0
- package/dist/outbox/index.d.ts +9 -0
- package/dist/outbox/index.d.ts.map +1 -0
- package/dist/outbox/index.js +8 -0
- package/dist/outbox/index.js.map +1 -0
- package/dist/outbox/postgres.d.ts +124 -0
- package/dist/outbox/postgres.d.ts.map +1 -0
- package/dist/outbox/postgres.js +177 -0
- package/dist/outbox/postgres.js.map +1 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js.map +1 -1
- package/dist/query/secret.d.ts.map +1 -1
- package/dist/query/secret.js +1 -1
- package/dist/query/secret.js.map +1 -1
- package/package.json +18 -2
- package/src/sql/{vk_data_001_tenancy_foundation.sql → 0000_vk_data_foundation.sql} +305 -278
- package/src/sql/vk_data_001_idempotency.sql +48 -0
- package/src/sql/vk_data_002_outbox.sql +99 -0
- package/src/sql/vk_data_003_jobs.sql +114 -0
- package/src/sql/vk_data_004_file_object.sql +112 -0
|
@@ -1,278 +1,305 @@
|
|
|
1
|
-
-- @venturekit/data — multi-tenant Postgres foundation.
|
|
2
|
-
--
|
|
3
|
-
-- Functions created by this migration:
|
|
4
|
-
-- vk_uuid_generate_v7() — time-ordered primary keys
|
|
5
|
-
-- vk_tenant_scope() — every tenant this request may READ
|
|
6
|
-
-- vk_acting_tenant() — the single tenant it may WRITE as
|
|
7
|
-
-- vk_install_tenant_guards(schema, table, role) — forced RLS + policy + grants
|
|
8
|
-
-- vk_install_catalog_guards(schema, table, role) — read-only grants for shared catalogs
|
|
9
|
-
--
|
|
10
|
-
-- # Why this is the framework's job
|
|
11
|
-
--
|
|
12
|
-
-- `runWithTenantScope()` in this package publishes `app.tenant_id` and
|
|
13
|
-
-- `app.tenant_ids` as Postgres GUCs. That is only half a tenancy feature: the
|
|
14
|
-
-- GUCs do nothing until a policy reads them, and until now every consumer had
|
|
15
|
-
-- to write that policy — and the `ENABLE`/`FORCE`/`GRANT` around it — by hand,
|
|
16
|
-
-- once per table. A project with sixty tenant-scoped tables hand-writes ~300
|
|
17
|
-
-- lines in which a single omission is invisible: the table still works, queries
|
|
18
|
-
-- still return rows, and the only symptom is that one table has no isolation at
|
|
19
|
-
-- all. Shipping the reader and the installer next to the writer of the GUCs
|
|
20
|
-
-- makes that omission impossible to write by accident.
|
|
21
|
-
--
|
|
22
|
-
-- Row-level security is also the one guarantee that CANNOT move into
|
|
23
|
-
-- application code: Postgres enforces it against the connection, which is
|
|
24
|
-
-- exactly what makes it hold when a query forgets its WHERE clause.
|
|
25
|
-
--
|
|
26
|
-
-- # The role split RLS depends on
|
|
27
|
-
--
|
|
28
|
-
-- RLS is skipped for superusers, for roles with BYPASSRLS, and for a table's
|
|
29
|
-
-- owner unless FORCE ROW LEVEL SECURITY is set. So:
|
|
30
|
-
--
|
|
31
|
-
-- * migrations run as the OWNER (they must create and alter tables);
|
|
32
|
-
-- * the application connects as the tenant role — `DatabaseIntent.tenantRole`
|
|
33
|
-
-- in `vk.config.ts` — which owns nothing and has neither attribute, and is
|
|
34
|
-
-- granted only DML.
|
|
35
|
-
--
|
|
36
|
-
-- FORCE is set anyway, so even a mistaken connection as the owner is filtered.
|
|
37
|
-
-- Local development against a superuser bypasses all of it, which is why
|
|
38
|
-
-- isolation tests must connect as the tenant role explicitly or they pass while
|
|
39
|
-
-- proving nothing.
|
|
40
|
-
--
|
|
41
|
-
-- Names are `vk_`-prefixed and unqualified, matching `vk_tenants` and
|
|
42
|
-
-- `vk_notifications`: a package-owned object must not collide with a consumer's
|
|
43
|
-
-- own, and creating a schema in someone else's database is not this package's
|
|
44
|
-
-- call to make. A project that prefers its own namespace can wrap these —
|
|
45
|
-
-- `CREATE OR REPLACE FUNCTION sys.tenant_scope() ... SELECT vk_tenant_scope()`
|
|
46
|
-
-- — which keeps one implementation while leaving existing call sites alone.
|
|
47
|
-
--
|
|
48
|
-
--
|
|
49
|
-
--
|
|
50
|
-
|
|
51
|
-
--
|
|
52
|
-
-- `
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
--
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
*
|
|
127
|
-
* of
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
1
|
+
-- @venturekit/data — multi-tenant Postgres foundation.
|
|
2
|
+
--
|
|
3
|
+
-- Functions created by this migration:
|
|
4
|
+
-- vk_uuid_generate_v7() — time-ordered primary keys
|
|
5
|
+
-- vk_tenant_scope() — every tenant this request may READ
|
|
6
|
+
-- vk_acting_tenant() — the single tenant it may WRITE as
|
|
7
|
+
-- vk_install_tenant_guards(schema, table, role) — forced RLS + policy + grants
|
|
8
|
+
-- vk_install_catalog_guards(schema, table, role) — read-only grants for shared catalogs
|
|
9
|
+
--
|
|
10
|
+
-- # Why this is the framework's job
|
|
11
|
+
--
|
|
12
|
+
-- `runWithTenantScope()` in this package publishes `app.tenant_id` and
|
|
13
|
+
-- `app.tenant_ids` as Postgres GUCs. That is only half a tenancy feature: the
|
|
14
|
+
-- GUCs do nothing until a policy reads them, and until now every consumer had
|
|
15
|
+
-- to write that policy — and the `ENABLE`/`FORCE`/`GRANT` around it — by hand,
|
|
16
|
+
-- once per table. A project with sixty tenant-scoped tables hand-writes ~300
|
|
17
|
+
-- lines in which a single omission is invisible: the table still works, queries
|
|
18
|
+
-- still return rows, and the only symptom is that one table has no isolation at
|
|
19
|
+
-- all. Shipping the reader and the installer next to the writer of the GUCs
|
|
20
|
+
-- makes that omission impossible to write by accident.
|
|
21
|
+
--
|
|
22
|
+
-- Row-level security is also the one guarantee that CANNOT move into
|
|
23
|
+
-- application code: Postgres enforces it against the connection, which is
|
|
24
|
+
-- exactly what makes it hold when a query forgets its WHERE clause.
|
|
25
|
+
--
|
|
26
|
+
-- # The role split RLS depends on
|
|
27
|
+
--
|
|
28
|
+
-- RLS is skipped for superusers, for roles with BYPASSRLS, and for a table's
|
|
29
|
+
-- owner unless FORCE ROW LEVEL SECURITY is set. So:
|
|
30
|
+
--
|
|
31
|
+
-- * migrations run as the OWNER (they must create and alter tables);
|
|
32
|
+
-- * the application connects as the tenant role — `DatabaseIntent.tenantRole`
|
|
33
|
+
-- in `vk.config.ts` — which owns nothing and has neither attribute, and is
|
|
34
|
+
-- granted only DML.
|
|
35
|
+
--
|
|
36
|
+
-- FORCE is set anyway, so even a mistaken connection as the owner is filtered.
|
|
37
|
+
-- Local development against a superuser bypasses all of it, which is why
|
|
38
|
+
-- isolation tests must connect as the tenant role explicitly or they pass while
|
|
39
|
+
-- proving nothing.
|
|
40
|
+
--
|
|
41
|
+
-- Names are `vk_`-prefixed and unqualified, matching `vk_tenants` and
|
|
42
|
+
-- `vk_notifications`: a package-owned object must not collide with a consumer's
|
|
43
|
+
-- own, and creating a schema in someone else's database is not this package's
|
|
44
|
+
-- call to make. A project that prefers its own namespace can wrap these —
|
|
45
|
+
-- `CREATE OR REPLACE FUNCTION sys.tenant_scope() ... SELECT <schema>.vk_tenant_scope()`
|
|
46
|
+
-- — which keeps one implementation while leaving existing call sites alone.
|
|
47
|
+
--
|
|
48
|
+
-- # Why the `0000_` filename prefix
|
|
49
|
+
--
|
|
50
|
+
-- The migration runner merges the project's own `.sql` files with every
|
|
51
|
+
-- installed package's and applies them in ALPHABETIC order, which puts the
|
|
52
|
+
-- conventional `vk_<pkg>_<NNN>_*` names *after* a project's `0xx_*` files. That
|
|
53
|
+
-- is right for tables a project only reads, and wrong for these: a consumer's
|
|
54
|
+
-- own migration calls `vk_install_tenant_guards(...)` as it creates each table,
|
|
55
|
+
-- so the functions must already exist. Worse, a `LANGUAGE sql` wrapper around
|
|
56
|
+
-- one of them is validated when it is CREATEd, so a project delegating to these
|
|
57
|
+
-- would fail at migrate time rather than at call time.
|
|
58
|
+
--
|
|
59
|
+
-- So this file takes the same one-character departure, for the same reason, as
|
|
60
|
+
-- `@venturekit-pro/tenancy`'s `0000_vk_tenancy_tenants.sql`: primordial-parent
|
|
61
|
+
-- status. Sorting before `0000_vk_tenancy_*` is incidental - neither depends on
|
|
62
|
+
-- the other.
|
|
63
|
+
--
|
|
64
|
+
-- # Effect on projects already in production
|
|
65
|
+
--
|
|
66
|
+
-- Additive, and safe to pick up late. The runner tracks applied files by name
|
|
67
|
+
-- and applies any it has not seen, so an existing database simply gains these
|
|
68
|
+
-- five functions on its next migrate; the `0000_` name does not imply
|
|
69
|
+
-- re-running anything, and nothing here depends on schema that a later
|
|
70
|
+
-- migration created. No existing file is touched, which is the thing that would
|
|
71
|
+
-- break - `vk migrate` hashes every applied file and raises
|
|
72
|
+
-- `MigrationHashMismatchError` if one changes. `pgcrypto` is already created by
|
|
73
|
+
-- `@venturekit-pro/tenancy`.
|
|
74
|
+
--
|
|
75
|
+
-- Idempotent throughout (`CREATE OR REPLACE`, `IF NOT EXISTS`), so re-applying
|
|
76
|
+
-- after a partial failure never breaks a deploy.
|
|
77
|
+
|
|
78
|
+
-- ─── Extensions ─────────────────────────────────────────────────────────
|
|
79
|
+
-- `pgcrypto` for gen_random_bytes, which the v7 generator below needs.
|
|
80
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
81
|
+
|
|
82
|
+
-- ─── Identifiers ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Time-ordered uuid (RFC 9562 v7).
|
|
86
|
+
*
|
|
87
|
+
* v4 keys scatter inserts across the whole index, so a busy table's hot page
|
|
88
|
+
* set is the entire index rather than its right edge — measurable write
|
|
89
|
+
* amplification once a table is taking millions of rows a day. v7 puts a
|
|
90
|
+
* millisecond timestamp in the leading 48 bits, restoring insert locality while
|
|
91
|
+
* still not exposing a guessable sequence.
|
|
92
|
+
*
|
|
93
|
+
* `clock_timestamp()` rather than `now()`: `now()` is fixed for the whole
|
|
94
|
+
* transaction, so a bulk insert of 5 000 rows would emit 5 000 keys sharing one
|
|
95
|
+
* timestamp prefix and lose the ordering this exists to provide.
|
|
96
|
+
*/
|
|
97
|
+
CREATE OR REPLACE FUNCTION vk_uuid_generate_v7()
|
|
98
|
+
RETURNS uuid
|
|
99
|
+
LANGUAGE plpgsql
|
|
100
|
+
VOLATILE
|
|
101
|
+
PARALLEL SAFE
|
|
102
|
+
AS $$
|
|
103
|
+
DECLARE
|
|
104
|
+
ts_ms bigint := (extract(epoch FROM clock_timestamp()) * 1000)::bigint;
|
|
105
|
+
raw bytea;
|
|
106
|
+
BEGIN
|
|
107
|
+
-- 48-bit big-endian millisecond timestamp, then 80 random bits.
|
|
108
|
+
raw := substring(int8send(ts_ms) FROM 3 FOR 6) || gen_random_bytes(10);
|
|
109
|
+
-- Version 7 in the high nibble of octet 6.
|
|
110
|
+
raw := set_byte(raw, 6, (get_byte(raw, 6) & 15) | 112);
|
|
111
|
+
-- RFC 4122 variant (10xx) in the high bits of octet 8.
|
|
112
|
+
raw := set_byte(raw, 8, (get_byte(raw, 8) & 63) | 128);
|
|
113
|
+
RETURN encode(raw, 'hex')::uuid;
|
|
114
|
+
END;
|
|
115
|
+
$$;
|
|
116
|
+
|
|
117
|
+
COMMENT ON FUNCTION vk_uuid_generate_v7() IS
|
|
118
|
+
'Time-ordered uuid v7 (RFC 9562). Insert-local alternative to gen_random_uuid().';
|
|
119
|
+
|
|
120
|
+
-- ─── Tenancy ────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every tenant the current request may READ, or NULL when un-scoped.
|
|
124
|
+
*
|
|
125
|
+
* Reads `app.tenant_ids`, published by this package's `runWithTenantScope()`.
|
|
126
|
+
* A parent tenant's request carries itself plus its descendants, so "a group
|
|
127
|
+
* owner sees their child tenants" is satisfied by this one policy instead of a
|
|
128
|
+
* branch in every query.
|
|
129
|
+
*
|
|
130
|
+
* NULL is the fail-closed sentinel: `x = ANY (NULL)` is NULL, so an un-scoped
|
|
131
|
+
* request sees nothing rather than everything. The `true` argument to
|
|
132
|
+
* `current_setting` makes an unset GUC return NULL instead of raising, which is
|
|
133
|
+
* what lets a migration or an admin connection run outside any request scope.
|
|
134
|
+
*
|
|
135
|
+
* STABLE, so the planner evaluates it once per statement rather than per row.
|
|
136
|
+
*/
|
|
137
|
+
CREATE OR REPLACE FUNCTION vk_tenant_scope()
|
|
138
|
+
RETURNS uuid[]
|
|
139
|
+
LANGUAGE sql
|
|
140
|
+
STABLE
|
|
141
|
+
PARALLEL SAFE
|
|
142
|
+
AS $$
|
|
143
|
+
SELECT CASE
|
|
144
|
+
WHEN coalesce(current_setting('app.tenant_ids', true), '') = '' THEN NULL::uuid[]
|
|
145
|
+
ELSE string_to_array(current_setting('app.tenant_ids', true), ',')::uuid[]
|
|
146
|
+
END;
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
COMMENT ON FUNCTION vk_tenant_scope() IS
|
|
150
|
+
'Tenants readable by this request (app.tenant_ids). NULL = un-scoped = no rows.';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The single tenant this request ACTS as — `app.tenant_id`, the acting tenant
|
|
154
|
+
* of the scope.
|
|
155
|
+
*
|
|
156
|
+
* Writes are checked against this, not against the whole scope: a parent-tenant
|
|
157
|
+
* administrator reading twelve child tenants should not be able to create a row
|
|
158
|
+
* in one of them by accident. Widening a write to another tenant has to be a
|
|
159
|
+
* deliberate re-scope, which is visible in the code that does it.
|
|
160
|
+
*/
|
|
161
|
+
CREATE OR REPLACE FUNCTION vk_acting_tenant()
|
|
162
|
+
RETURNS uuid
|
|
163
|
+
LANGUAGE sql
|
|
164
|
+
STABLE
|
|
165
|
+
PARALLEL SAFE
|
|
166
|
+
AS $$
|
|
167
|
+
SELECT nullif(current_setting('app.tenant_id', true), '')::uuid;
|
|
168
|
+
$$;
|
|
169
|
+
|
|
170
|
+
COMMENT ON FUNCTION vk_acting_tenant() IS
|
|
171
|
+
'The tenant this request may WRITE as (app.tenant_id). NULL = un-scoped.';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Attach the standard guards to a tenant-scoped table:
|
|
175
|
+
*
|
|
176
|
+
* 1. RLS, ENABLEd and FORCEd;
|
|
177
|
+
* 2. the isolation policy — read across the scope, write only as the acting
|
|
178
|
+
* tenant;
|
|
179
|
+
* 3. DML grants for `p_role`.
|
|
180
|
+
*
|
|
181
|
+
* This is DDL run at migration time, not runtime behaviour: a macro that
|
|
182
|
+
* expands to the statements every tenant-scoped table needs.
|
|
183
|
+
*
|
|
184
|
+
* `p_role` is a parameter rather than a constant because the application role is
|
|
185
|
+
* the consumer's (`DatabaseIntent.tenantRole`), and a framework that hard-coded
|
|
186
|
+
* one would work for exactly one project. It is validated against `pg_roles`
|
|
187
|
+
* and interpolated with `%I`, so it cannot carry SQL.
|
|
188
|
+
*
|
|
189
|
+
* Idempotent, so re-running a migration during development is safe. The table
|
|
190
|
+
* must have a `tenant_id` column, asserted rather than assumed because a table
|
|
191
|
+
* that silently skipped its policy is exactly the failure this prevents.
|
|
192
|
+
*/
|
|
193
|
+
CREATE OR REPLACE FUNCTION vk_install_tenant_guards(
|
|
194
|
+
p_schema text,
|
|
195
|
+
p_table text,
|
|
196
|
+
p_role text
|
|
197
|
+
)
|
|
198
|
+
RETURNS void
|
|
199
|
+
LANGUAGE plpgsql
|
|
200
|
+
AS $$
|
|
201
|
+
DECLARE
|
|
202
|
+
qualified text := format('%I.%I', p_schema, p_table);
|
|
203
|
+
fn_schema text;
|
|
204
|
+
BEGIN
|
|
205
|
+
IF NOT EXISTS (
|
|
206
|
+
SELECT 1 FROM information_schema.columns
|
|
207
|
+
WHERE table_schema = p_schema AND table_name = p_table AND column_name = 'tenant_id'
|
|
208
|
+
) THEN
|
|
209
|
+
RAISE EXCEPTION 'vk_install_tenant_guards: %.% has no tenant_id column', p_schema, p_table;
|
|
210
|
+
END IF;
|
|
211
|
+
|
|
212
|
+
-- A missing role is a misconfiguration, not something to paper over: the
|
|
213
|
+
-- grant would fail anyway, and failing here names the cause.
|
|
214
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = p_role) THEN
|
|
215
|
+
RAISE EXCEPTION 'vk_install_tenant_guards: role % does not exist', p_role;
|
|
216
|
+
END IF;
|
|
217
|
+
|
|
218
|
+
/* The policy must name the scope functions SCHEMA-QUALIFIED.
|
|
219
|
+
A policy expression is re-resolved against the *querying* session's
|
|
220
|
+
`search_path`, not the one in effect when the policy was created. Left
|
|
221
|
+
bare, `vk_tenant_scope()` would fail — or, far worse, resolve to a
|
|
222
|
+
same-named function in a schema earlier on some connection's path, which
|
|
223
|
+
is a tenant-isolation bypass that no test connecting normally would see.
|
|
224
|
+
So resolve where this migration actually put them, once, here. */
|
|
225
|
+
SELECT n.nspname INTO fn_schema
|
|
226
|
+
FROM pg_proc p
|
|
227
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
228
|
+
WHERE p.proname = 'vk_tenant_scope'
|
|
229
|
+
LIMIT 1;
|
|
230
|
+
|
|
231
|
+
IF fn_schema IS NULL THEN
|
|
232
|
+
RAISE EXCEPTION 'vk_install_tenant_guards: vk_tenant_scope() not found — is @venturekit/data''s migration applied?';
|
|
233
|
+
END IF;
|
|
234
|
+
|
|
235
|
+
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', qualified);
|
|
236
|
+
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', qualified);
|
|
237
|
+
|
|
238
|
+
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %s', qualified);
|
|
239
|
+
EXECUTE format(
|
|
240
|
+
'CREATE POLICY tenant_isolation ON %s
|
|
241
|
+
USING (tenant_id = ANY (%I.vk_tenant_scope()))
|
|
242
|
+
WITH CHECK (tenant_id = %I.vk_acting_tenant())',
|
|
243
|
+
qualified, fn_schema, fn_schema);
|
|
244
|
+
|
|
245
|
+
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %s TO %I', qualified, p_role);
|
|
246
|
+
END;
|
|
247
|
+
$$;
|
|
248
|
+
|
|
249
|
+
COMMENT ON FUNCTION vk_install_tenant_guards(text, text, text) IS
|
|
250
|
+
'Attach forced RLS, the isolation policy and DML grants. Call once per tenant-scoped table.';
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Grants for a table that is deliberately NOT tenant-scoped — the shared
|
|
254
|
+
* catalogs every tenant reads and none may edit.
|
|
255
|
+
*
|
|
256
|
+
* Read-only to the application on purpose: these rows are curated centrally,
|
|
257
|
+
* and a tenant-facing service editing a shared catalog is how one tenant's
|
|
258
|
+
* data-entry mistake becomes everyone's.
|
|
259
|
+
*/
|
|
260
|
+
CREATE OR REPLACE FUNCTION vk_install_catalog_guards(
|
|
261
|
+
p_schema text,
|
|
262
|
+
p_table text,
|
|
263
|
+
p_role text
|
|
264
|
+
)
|
|
265
|
+
RETURNS void
|
|
266
|
+
LANGUAGE plpgsql
|
|
267
|
+
AS $$
|
|
268
|
+
DECLARE
|
|
269
|
+
qualified text := format('%I.%I', p_schema, p_table);
|
|
270
|
+
BEGIN
|
|
271
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = p_role) THEN
|
|
272
|
+
RAISE EXCEPTION 'vk_install_catalog_guards: role % does not exist', p_role;
|
|
273
|
+
END IF;
|
|
274
|
+
|
|
275
|
+
EXECUTE format('GRANT SELECT ON %s TO %I', qualified, p_role);
|
|
276
|
+
END;
|
|
277
|
+
$$;
|
|
278
|
+
|
|
279
|
+
COMMENT ON FUNCTION vk_install_catalog_guards(text, text, text) IS
|
|
280
|
+
'Read-only grants for a shared, non-tenant-scoped catalog table.';
|
|
281
|
+
|
|
282
|
+
-- ─── Auditing the result ────────────────────────────────────────────────
|
|
283
|
+
--
|
|
284
|
+
-- "Which tenant-scoped tables are missing RLS?" is a query, not a view, so it
|
|
285
|
+
-- can live in the test that asserts it rather than in a schema object nobody
|
|
286
|
+
-- reads:
|
|
287
|
+
--
|
|
288
|
+
-- SELECT c.relname
|
|
289
|
+
-- FROM pg_class c
|
|
290
|
+
-- JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
291
|
+
-- WHERE n.nspname = ANY ($1::text[])
|
|
292
|
+
-- AND c.relkind = 'r'
|
|
293
|
+
-- AND EXISTS (SELECT 1 FROM information_schema.columns
|
|
294
|
+
-- WHERE table_schema = n.nspname AND table_name = c.relname
|
|
295
|
+
-- AND column_name = 'tenant_id')
|
|
296
|
+
-- AND NOT (c.relrowsecurity AND EXISTS (
|
|
297
|
+
-- SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid));
|
|
298
|
+
--
|
|
299
|
+
-- A table counts as protected when RLS is ENABLEd and at least one policy
|
|
300
|
+
-- exists. FORCE is deliberately not required by that check: it applies the
|
|
301
|
+
-- policy to the table owner too, and trusted processes that legitimately write
|
|
302
|
+
-- across tenants (an event projector, an ingest listener, catalog seeds) run as
|
|
303
|
+
-- the owner. What matters is that the tenant role — the only one serving user
|
|
304
|
+
-- requests, and which owns nothing — is filtered. ENABLE plus a policy
|
|
305
|
+
-- guarantees that.
|