@stratum-hq/lib 0.6.0 → 0.7.0

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.
@@ -0,0 +1,236 @@
1
+ -- Migration 019: Real Postgres row-level security for the SHARED_RLS strategy.
2
+ --
3
+ -- Backs the `isolation_strategy = 'SHARED_RLS'` literal (001_init.sql) with
4
+ -- database-enforced tenant isolation. This is a SECOND, independent layer; the
5
+ -- application control-plane authorization stays the primary enforcement. See
6
+ -- docs/adr/0001-postgres-rls-defense-in-depth.md for the full design.
7
+ --
8
+ -- Context model:
9
+ -- * Tenant context: SET LOCAL app.current_tenant_id (transaction scoped).
10
+ -- * System bypass: SET LOCAL app.bypass_rls = 'on' (control-plane path).
11
+ -- Both are set with set_config(..., true) / SET LOCAL so they cannot leak
12
+ -- across pooled connections.
13
+ --
14
+ -- Every policy is fail closed: with no context set,
15
+ -- NULLIF(current_setting('app.current_tenant_id', true), '')::uuid -> NULL,
16
+ -- so the scope predicate is NULL (not true) and zero rows are visible; a write
17
+ -- with no context is rejected. current_setting(name, true) returns NULL instead
18
+ -- of raising when the GUC is unset.
19
+ --
20
+ -- ENABLE + FORCE: FORCE applies the policy to the table owner too. Note that a
21
+ -- Postgres SUPERUSER or a role with BYPASSRLS always skips RLS regardless of
22
+ -- FORCE; the application role must be neither (see docker/init-db.sql:
23
+ -- stratum_app is NOSUPERUSER NOBYPASSRLS).
24
+ --
25
+ -- DROP POLICY IF EXISTS before CREATE makes the migration safe to re-run.
26
+
27
+ -- Reusable predicate (documented once; inlined per policy below because Postgres
28
+ -- policies cannot reference a shared expression):
29
+ -- current_setting('app.bypass_rls', true) = 'on'
30
+ -- OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
31
+
32
+ -- ---------------------------------------------------------------------------
33
+ -- Direct tenant_id tables: exact-tenant scope.
34
+ -- ---------------------------------------------------------------------------
35
+
36
+ -- config_entries
37
+ ALTER TABLE config_entries ENABLE ROW LEVEL SECURITY;
38
+ ALTER TABLE config_entries FORCE ROW LEVEL SECURITY;
39
+ DROP POLICY IF EXISTS tenant_isolation ON config_entries;
40
+ CREATE POLICY tenant_isolation ON config_entries FOR ALL
41
+ USING (
42
+ current_setting('app.bypass_rls', true) = 'on'
43
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
44
+ )
45
+ WITH CHECK (
46
+ current_setting('app.bypass_rls', true) = 'on'
47
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
48
+ );
49
+
50
+ -- permission_policies
51
+ ALTER TABLE permission_policies ENABLE ROW LEVEL SECURITY;
52
+ ALTER TABLE permission_policies FORCE ROW LEVEL SECURITY;
53
+ DROP POLICY IF EXISTS tenant_isolation ON permission_policies;
54
+ CREATE POLICY tenant_isolation ON permission_policies FOR ALL
55
+ USING (
56
+ current_setting('app.bypass_rls', true) = 'on'
57
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
58
+ )
59
+ WITH CHECK (
60
+ current_setting('app.bypass_rls', true) = 'on'
61
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
62
+ );
63
+
64
+ -- audit_logs (tenant_id nullable: ON DELETE SET NULL; NULL rows visible only under bypass)
65
+ ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
66
+ ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY;
67
+ DROP POLICY IF EXISTS tenant_isolation ON audit_logs;
68
+ CREATE POLICY tenant_isolation ON audit_logs FOR ALL
69
+ USING (
70
+ current_setting('app.bypass_rls', true) = 'on'
71
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
72
+ )
73
+ WITH CHECK (
74
+ current_setting('app.bypass_rls', true) = 'on'
75
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
76
+ );
77
+
78
+ -- webhook_events
79
+ ALTER TABLE webhook_events ENABLE ROW LEVEL SECURITY;
80
+ ALTER TABLE webhook_events FORCE ROW LEVEL SECURITY;
81
+ DROP POLICY IF EXISTS tenant_isolation ON webhook_events;
82
+ CREATE POLICY tenant_isolation ON webhook_events FOR ALL
83
+ USING (
84
+ current_setting('app.bypass_rls', true) = 'on'
85
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
86
+ )
87
+ WITH CHECK (
88
+ current_setting('app.bypass_rls', true) = 'on'
89
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
90
+ );
91
+
92
+ -- webhooks (tenant_id nullable)
93
+ ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY;
94
+ ALTER TABLE webhooks FORCE ROW LEVEL SECURITY;
95
+ DROP POLICY IF EXISTS tenant_isolation ON webhooks;
96
+ CREATE POLICY tenant_isolation ON webhooks FOR ALL
97
+ USING (
98
+ current_setting('app.bypass_rls', true) = 'on'
99
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
100
+ )
101
+ WITH CHECK (
102
+ current_setting('app.bypass_rls', true) = 'on'
103
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
104
+ );
105
+
106
+ -- consent_records
107
+ ALTER TABLE consent_records ENABLE ROW LEVEL SECURITY;
108
+ ALTER TABLE consent_records FORCE ROW LEVEL SECURITY;
109
+ DROP POLICY IF EXISTS tenant_isolation ON consent_records;
110
+ CREATE POLICY tenant_isolation ON consent_records FOR ALL
111
+ USING (
112
+ current_setting('app.bypass_rls', true) = 'on'
113
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
114
+ )
115
+ WITH CHECK (
116
+ current_setting('app.bypass_rls', true) = 'on'
117
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
118
+ );
119
+
120
+ -- abac_policies
121
+ ALTER TABLE abac_policies ENABLE ROW LEVEL SECURITY;
122
+ ALTER TABLE abac_policies FORCE ROW LEVEL SECURITY;
123
+ DROP POLICY IF EXISTS tenant_isolation ON abac_policies;
124
+ CREATE POLICY tenant_isolation ON abac_policies FOR ALL
125
+ USING (
126
+ current_setting('app.bypass_rls', true) = 'on'
127
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
128
+ )
129
+ WITH CHECK (
130
+ current_setting('app.bypass_rls', true) = 'on'
131
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
132
+ );
133
+
134
+ -- api_keys (auth table; tenant_id nullable. Read by the control plane before a
135
+ -- tenant context exists, so it is safe only because the control plane bypasses.
136
+ -- Global keys (tenant_id NULL) are visible only under bypass.)
137
+ ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
138
+ ALTER TABLE api_keys FORCE ROW LEVEL SECURITY;
139
+ DROP POLICY IF EXISTS tenant_isolation ON api_keys;
140
+ CREATE POLICY tenant_isolation ON api_keys FOR ALL
141
+ USING (
142
+ current_setting('app.bypass_rls', true) = 'on'
143
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
144
+ )
145
+ WITH CHECK (
146
+ current_setting('app.bypass_rls', true) = 'on'
147
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
148
+ );
149
+
150
+ -- roles (auth table; tenant_id nullable for global roles, visible only under bypass)
151
+ ALTER TABLE roles ENABLE ROW LEVEL SECURITY;
152
+ ALTER TABLE roles FORCE ROW LEVEL SECURITY;
153
+ DROP POLICY IF EXISTS tenant_isolation ON roles;
154
+ CREATE POLICY tenant_isolation ON roles FOR ALL
155
+ USING (
156
+ current_setting('app.bypass_rls', true) = 'on'
157
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
158
+ )
159
+ WITH CHECK (
160
+ current_setting('app.bypass_rls', true) = 'on'
161
+ OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
162
+ );
163
+
164
+ -- ---------------------------------------------------------------------------
165
+ -- tenants registry: self-scope on id. The ancestry source; no tenant_id column.
166
+ -- A data-plane context reads only its own registry row and cannot enumerate the
167
+ -- tree. All tree / ancestry mutations are control-plane and run under bypass.
168
+ -- ---------------------------------------------------------------------------
169
+ ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
170
+ ALTER TABLE tenants FORCE ROW LEVEL SECURITY;
171
+ DROP POLICY IF EXISTS tenant_isolation ON tenants;
172
+ CREATE POLICY tenant_isolation ON tenants FOR ALL
173
+ USING (
174
+ current_setting('app.bypass_rls', true) = 'on'
175
+ OR id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
176
+ )
177
+ WITH CHECK (
178
+ current_setting('app.bypass_rls', true) = 'on'
179
+ OR id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
180
+ );
181
+
182
+ -- ---------------------------------------------------------------------------
183
+ -- Indirect tables (no tenant_id): scoped through their tenant-bearing parent.
184
+ -- The parent subquery is itself RLS-filtered under a tenant context, which
185
+ -- composes correctly. No policy recursion: neither parent references back.
186
+ -- ---------------------------------------------------------------------------
187
+
188
+ -- webhook_deliveries -> webhook_events.tenant_id
189
+ ALTER TABLE webhook_deliveries ENABLE ROW LEVEL SECURITY;
190
+ ALTER TABLE webhook_deliveries FORCE ROW LEVEL SECURITY;
191
+ DROP POLICY IF EXISTS tenant_isolation ON webhook_deliveries;
192
+ CREATE POLICY tenant_isolation ON webhook_deliveries FOR ALL
193
+ USING (
194
+ current_setting('app.bypass_rls', true) = 'on'
195
+ OR EXISTS (
196
+ SELECT 1 FROM webhook_events we
197
+ WHERE we.id = webhook_deliveries.event_id
198
+ AND we.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
199
+ )
200
+ )
201
+ WITH CHECK (
202
+ current_setting('app.bypass_rls', true) = 'on'
203
+ OR EXISTS (
204
+ SELECT 1 FROM webhook_events we
205
+ WHERE we.id = webhook_deliveries.event_id
206
+ AND we.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
207
+ )
208
+ );
209
+
210
+ -- principal_roles -> roles.tenant_id (global-role assignments visible only under bypass)
211
+ ALTER TABLE principal_roles ENABLE ROW LEVEL SECURITY;
212
+ ALTER TABLE principal_roles FORCE ROW LEVEL SECURITY;
213
+ DROP POLICY IF EXISTS tenant_isolation ON principal_roles;
214
+ CREATE POLICY tenant_isolation ON principal_roles FOR ALL
215
+ USING (
216
+ current_setting('app.bypass_rls', true) = 'on'
217
+ OR EXISTS (
218
+ SELECT 1 FROM roles r
219
+ WHERE r.id = principal_roles.role_id
220
+ AND r.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
221
+ )
222
+ )
223
+ WITH CHECK (
224
+ current_setting('app.bypass_rls', true) = 'on'
225
+ OR EXISTS (
226
+ SELECT 1 FROM roles r
227
+ WHERE r.id = principal_roles.role_id
228
+ AND r.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
229
+ )
230
+ );
231
+
232
+ -- ---------------------------------------------------------------------------
233
+ -- Intentionally NOT protected (documented in the ADR):
234
+ -- * regions -- global infrastructure catalog, no tenant_id, not tenant-private
235
+ -- * _migrations -- internal migration bookkeeping
236
+ -- ---------------------------------------------------------------------------
@@ -1 +1 @@
1
- {"version":3,"file":"pool-helpers.d.ts","sourceRoot":"","sources":["../src/pool-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,wBAAsB,UAAU,CAAC,CAAC,EAChC,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAsB,eAAe,CAAC,CAAC,EACrC,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,CAAC,CAAC,CAaZ"}
1
+ {"version":3,"file":"pool-helpers.d.ts","sourceRoot":"","sources":["../src/pool-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAyBpB,wBAAsB,UAAU,CAAC,CAAC,EAChC,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,CAAC,CAAC,CAcZ;AAED,wBAAsB,eAAe,CAAC,CAAC,EACrC,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,CAAC,CAAC,CAcZ"}
@@ -2,10 +2,39 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withClient = withClient;
4
4
  exports.withTransaction = withTransaction;
5
+ // The Stratum library is the trusted CONTROL PLANE: it manages the whole tenant
6
+ // tree (authenticating keys before any tenant is known, resolving inherited
7
+ // config up the ancestry chain, listing descendants, moving subtrees, cascade
8
+ // ops). By construction it reads and writes across tenant boundaries, so it runs
9
+ // under the RLS bypass.
10
+ //
11
+ // Both helpers open a transaction and issue `SET LOCAL app.bypass_rls = 'on'`
12
+ // before running the caller's work. SET LOCAL (set_config third arg `true`) is
13
+ // transaction scoped, so the flag cannot leak into a later request that reuses
14
+ // the same pooled connection. Row-level security stays enforced on the separate
15
+ // data-plane path (packages/db-adapters, withTenantContext); see
16
+ // docs/adr/0001-postgres-rls-defense-in-depth.md.
17
+ //
18
+ // This is the single chokepoint for all lib database access: every service goes
19
+ // through withClient / withTransaction, so no service function needs to change.
20
+ async function enterBypass(client) {
21
+ // SET LOCAL (not session SET) so the flag is transaction scoped and cannot
22
+ // leak across pooled connections. Equivalent to
23
+ // set_config('app.bypass_rls', 'on', true).
24
+ await client.query("SET LOCAL app.bypass_rls = 'on'");
25
+ }
5
26
  async function withClient(pool, fn) {
6
27
  const client = await pool.connect();
7
28
  try {
8
- return await fn(client);
29
+ await client.query("BEGIN");
30
+ await enterBypass(client);
31
+ const result = await fn(client);
32
+ await client.query("COMMIT");
33
+ return result;
34
+ }
35
+ catch (err) {
36
+ await client.query("ROLLBACK");
37
+ throw err;
9
38
  }
10
39
  finally {
11
40
  client.release();
@@ -15,6 +44,7 @@ async function withTransaction(pool, fn) {
15
44
  const client = await pool.connect();
16
45
  try {
17
46
  await client.query("BEGIN");
47
+ await enterBypass(client);
18
48
  const result = await fn(client);
19
49
  await client.query("COMMIT");
20
50
  return result;
@@ -1 +1 @@
1
- {"version":3,"file":"pool-helpers.js","sourceRoot":"","sources":["../src/pool-helpers.ts"],"names":[],"mappings":";;AAEA,gCAUC;AAED,0CAgBC;AA5BM,KAAK,UAAU,UAAU,CAC9B,IAAa,EACb,EAAyC;IAEzC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,OAAO,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,eAAe,CACnC,IAAa,EACb,EAAyC;IAEzC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC5B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC;QAChC,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAC/B,MAAM,GAAG,CAAC;IACZ,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"pool-helpers.js","sourceRoot":"","sources":["../src/pool-helpers.ts"],"names":[],"mappings":";;AAyBA,gCAiBC;AAED,0CAiBC;AA3DD,gFAAgF;AAChF,4EAA4E;AAC5E,8EAA8E;AAC9E,iFAAiF;AACjF,wBAAwB;AACxB,EAAE;AACF,8EAA8E;AAC9E,+EAA+E;AAC/E,+EAA+E;AAC/E,gFAAgF;AAChF,iEAAiE;AACjE,kDAAkD;AAClD,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAEhF,KAAK,UAAU,WAAW,CAAC,MAAqB;IAC9C,2EAA2E;IAC3E,gDAAgD;IAChD,4CAA4C;IAC5C,MAAM,MAAM,CAAC,KAAK,CAAC,iCAAiC,CAAC,CAAC;AACxD,CAAC;AAEM,KAAK,UAAU,UAAU,CAC9B,IAAa,EACb,EAAyC;IAEzC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC5B,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;QAC1B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC;QAChC,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAC/B,MAAM,GAAG,CAAC;IACZ,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,eAAe,CACnC,IAAa,EACb,EAAyC;IAEzC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC5B,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;QAC1B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC;QAChC,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAC/B,MAAM,GAAG,CAAC;IACZ,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratum-hq/lib",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Stratum tenant management library - framework-agnostic business logic",
5
5
  "keywords": [
6
6
  "multi-tenancy",