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