@fayz-ai/db 0.11.0 → 0.12.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,474 @@
1
+ -- ============================================================================
2
+ -- 040_resource_grants.sql — the exception to the default, written down.
3
+ --
4
+ -- Unit scoping (039) answers "which units do I reach". It cannot answer the two
5
+ -- questions the product actually asks:
6
+ --
7
+ -- • "Botox is sold in 2 of our 15 units."
8
+ -- • "This patient record is the psychologist's, not the reception's — unless
9
+ -- she shares it with the other psychologists."
10
+ --
11
+ -- Both are the SAME shape: a record, a recipient, a level. That is one table,
12
+ -- `resource_grants`, modelled on the `__Share` rows Salesforce has used for
13
+ -- twenty years. The alternative — a `product_units` join here, a
14
+ -- `document_shares` there — is three mechanisms, three predicates and three
15
+ -- screens to debug.
16
+ --
17
+ -- WHAT DECIDES WHEN NO GRANT EXISTS is `resource_visibility_defaults`: per
18
+ -- tenant, per record type, one of
19
+ --
20
+ -- tenant everyone in the org (today's behaviour, the default)
21
+ -- unit only the record's unit
22
+ -- owner only whoever owns it (the patient-record case)
23
+ --
24
+ -- Salesforce calls this the org-wide default, and having it is the difference
25
+ -- between "sharing" and "a checkbox on every form".
26
+ --
27
+ -- `scoped_resources` is the registry both of them key off — one row per table
28
+ -- that participates, naming its unit and owner columns. It exists so that 041
29
+ -- (which adds the columns) and 042 (which emits the policies) and the settings
30
+ -- screen all read ONE list instead of three copies of an array that will drift.
31
+ --
32
+ -- WHY THESE POLICIES ARE INLINE, AND WHY THE STRICT ONES ARE RESTRICTIVE.
33
+ -- 002's DO block sweeps every public table carrying a `tenant_id` and gives it
34
+ -- the canonical policy. On a fresh pool it runs long before this file, so the
35
+ -- tables here would be born with RLS enabled and no policy at all — hence the
36
+ -- inline ones.
37
+ --
38
+ -- The sharper half: 002 is re-applied whenever its checksum moves or a pool is
39
+ -- rebuilt, and on THAT pass it does discover these tables and adds a PERMISSIVE
40
+ -- `tenant_isolation_insert` to each. Permissive policies OR together, so that
41
+ -- one policy would reopen every gate below — any member of the tenant could
42
+ -- write themselves a grant. The bench caught exactly this on its replay pass.
43
+ --
44
+ -- So the rule for every table in this file: a permissive policy carries the
45
+ -- tenant clause (which makes 002's sweep a no-op instead of a surprise), and
46
+ -- the ACTUAL rules are RESTRICTIVE. A restrictive policy cannot be widened by
47
+ -- anything added later, which is the only property worth having here.
48
+ --
49
+ -- Idempotent: safe to re-run, no-ops when already applied.
50
+ -- ============================================================================
51
+
52
+ -- ── The registry ────────────────────────────────────────────────────────────
53
+ -- Platform-owned, not tenant-owned: which tables carry a unit and an owner is a
54
+ -- property of the schema, and a tenant cannot opt a table in or out.
55
+ CREATE TABLE IF NOT EXISTS public.scoped_resources (
56
+ resource_table text PRIMARY KEY,
57
+ -- Named rather than assumed: `orders` and `schedules` have carried
58
+ -- `location_id` since 004_archetypes and that column IS the unit. Adding a
59
+ -- second one to the busiest tables in the product to satisfy a naming
60
+ -- preference would be the wrong trade.
61
+ unit_column text NOT NULL DEFAULT 'unit_id',
62
+ owner_column text NOT NULL DEFAULT 'owner_id',
63
+ -- A ledger is unit-scoped but not shareable: you do not hand someone a single
64
+ -- financial movement.
65
+ shareable boolean NOT NULL DEFAULT true,
66
+ -- The factory default a tenant starts from; per-tenant overrides live in
67
+ -- resource_visibility_defaults.
68
+ default_visibility text NOT NULL DEFAULT 'tenant'
69
+ CHECK (default_visibility IN ('tenant', 'unit', 'owner')),
70
+ label text
71
+ );
72
+
73
+ ALTER TABLE public.scoped_resources ENABLE ROW LEVEL SECURITY;
74
+
75
+ -- Readable by anyone signed in (the visibility settings screen lists it),
76
+ -- writable by nobody but the platform. There is deliberately no manage policy:
77
+ -- a row here is created by a migration, because adding a table to the security
78
+ -- model is a schema change.
79
+ DROP POLICY IF EXISTS "scoped_resources_select" ON public.scoped_resources;
80
+ CREATE POLICY "scoped_resources_select" ON public.scoped_resources FOR SELECT TO authenticated
81
+ USING (true);
82
+
83
+ GRANT SELECT ON public.scoped_resources TO authenticated;
84
+ GRANT ALL ON public.scoped_resources TO service_role;
85
+
86
+ -- ── The per-tenant default ──────────────────────────────────────────────────
87
+ CREATE TABLE IF NOT EXISTS public.resource_visibility_defaults (
88
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
89
+ resource_table text NOT NULL REFERENCES public.scoped_resources(resource_table) ON DELETE CASCADE,
90
+ default_visibility text NOT NULL CHECK (default_visibility IN ('tenant', 'unit', 'owner')),
91
+ updated_by uuid,
92
+ updated_at timestamptz NOT NULL DEFAULT now(),
93
+ PRIMARY KEY (tenant_id, resource_table)
94
+ );
95
+
96
+ ALTER TABLE public.resource_visibility_defaults ENABLE ROW LEVEL SECURITY;
97
+
98
+ -- Everyone in the tenant reads it — a screen has to be able to say "clientes:
99
+ -- toda a organização" to the person looking at it. Only an admin changes it:
100
+ -- this is the setting that decides who sees what.
101
+ DROP POLICY IF EXISTS "rvd_select" ON public.resource_visibility_defaults;
102
+ CREATE POLICY "rvd_select" ON public.resource_visibility_defaults FOR SELECT TO authenticated
103
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
104
+
105
+ DROP POLICY IF EXISTS "rvd_manage" ON public.resource_visibility_defaults;
106
+ CREATE POLICY "rvd_manage" ON public.resource_visibility_defaults FOR ALL TO authenticated
107
+ USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))
108
+ WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
109
+
110
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.resource_visibility_defaults TO authenticated;
111
+ GRANT ALL ON public.resource_visibility_defaults TO service_role;
112
+
113
+ -- ── Teams ───────────────────────────────────────────────────────────────────
114
+ -- A named recipient, so "os psicólogos" is written once instead of re-picking
115
+ -- five people on every record. A team has NO permissions of its own — it is a
116
+ -- grant target and nothing else, which is what keeps it from quietly becoming a
117
+ -- second, competing role system.
118
+ CREATE TABLE IF NOT EXISTS public.teams (
119
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
120
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
121
+ name text NOT NULL,
122
+ color text,
123
+ created_at timestamptz NOT NULL DEFAULT now(),
124
+ UNIQUE (tenant_id, name)
125
+ );
126
+
127
+ CREATE TABLE IF NOT EXISTS public.team_members (
128
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
129
+ team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
130
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
131
+ -- CASCADE and not SET NULL: a membership row whose person no longer exists is
132
+ -- a grant to nobody that still widens every predicate reading this table.
133
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
134
+ UNIQUE (team_id, user_id)
135
+ );
136
+
137
+ CREATE INDEX IF NOT EXISTS team_members_user_idx ON public.team_members (user_id, team_id);
138
+
139
+ ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY;
140
+ ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY;
141
+
142
+ DROP POLICY IF EXISTS "teams_select" ON public.teams;
143
+ CREATE POLICY "teams_select" ON public.teams FOR SELECT TO authenticated
144
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
145
+
146
+ DROP POLICY IF EXISTS "teams_manage" ON public.teams;
147
+ CREATE POLICY "teams_manage" ON public.teams FOR ALL TO authenticated
148
+ USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))
149
+ WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
150
+
151
+ DROP POLICY IF EXISTS "team_members_select" ON public.team_members;
152
+ CREATE POLICY "team_members_select" ON public.team_members FOR SELECT TO authenticated
153
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
154
+
155
+ DROP POLICY IF EXISTS "team_members_manage" ON public.team_members;
156
+ CREATE POLICY "team_members_manage" ON public.team_members FOR ALL TO authenticated
157
+ USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))
158
+ WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
159
+
160
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.teams, public.team_members TO authenticated;
161
+ GRANT ALL ON public.teams, public.team_members TO service_role;
162
+
163
+ -- ── The grants ──────────────────────────────────────────────────────────────
164
+ CREATE TABLE IF NOT EXISTS public.resource_grants (
165
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
166
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
167
+ resource_table text NOT NULL REFERENCES public.scoped_resources(resource_table) ON DELETE CASCADE,
168
+ resource_id uuid NOT NULL,
169
+ -- uuid and not text, and no 'role' member: a role grant would duplicate what
170
+ -- the per-unit role in 039 and the 'tenant' default already express, and a
171
+ -- text key would cost a cast on every index probe.
172
+ grantee_type text NOT NULL CHECK (grantee_type IN ('user', 'team', 'unit')),
173
+ grantee_id uuid NOT NULL,
174
+ access text NOT NULL DEFAULT 'read' CHECK (access IN ('read', 'write', 'full')),
175
+ -- 'manual' (someone clicked Compartilhar) vs 'catalog' (this product is sold
176
+ -- in these units). Same row, different story to tell on screen.
177
+ reason text NOT NULL DEFAULT 'manual',
178
+ -- SET NULL and not CASCADE: who handed the access out is history, and the
179
+ -- access itself must not disappear because that person left the company.
180
+ granted_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
181
+ created_at timestamptz NOT NULL DEFAULT now()
182
+ );
183
+
184
+ -- The hot path: "what has been granted to me / to my units / to my teams".
185
+ CREATE INDEX IF NOT EXISTS resource_grants_grantee_idx
186
+ ON public.resource_grants (grantee_type, grantee_id, resource_table, resource_id);
187
+
188
+ -- The panel's direction ("who can see this record?") and the dedupe rule, in
189
+ -- one object. One grant per (record, recipient) — re-sharing changes the level,
190
+ -- it does not stack.
191
+ CREATE UNIQUE INDEX IF NOT EXISTS resource_grants_record_uniq
192
+ ON public.resource_grants (resource_table, resource_id, grantee_type, grantee_id);
193
+
194
+ -- Answers restricted_record_ids(): which rows of this table carry ANY unit
195
+ -- grant, i.e. which ones stopped meaning "available everywhere".
196
+ CREATE INDEX IF NOT EXISTS resource_grants_table_unit_idx
197
+ ON public.resource_grants (resource_table, resource_id)
198
+ WHERE grantee_type = 'unit';
199
+
200
+ ALTER TABLE public.resource_grants ENABLE ROW LEVEL SECURITY;
201
+
202
+ -- ── Helpers ─────────────────────────────────────────────────────────────────
203
+ -- Same discipline as 039: one argument at most, and it is a CONSTANT baked into
204
+ -- the policy text by 042's generator, never a column. A helper taking the row's
205
+ -- own values would be correlated and re-run per row.
206
+ --
207
+ -- SECURITY DEFINER because they read `resource_grants`, whose own policies
208
+ -- resolve through them — invoker semantics would recurse.
209
+
210
+ CREATE OR REPLACE FUNCTION public.shareable_tables()
211
+ RETURNS SETOF text
212
+ LANGUAGE sql STABLE SECURITY DEFINER
213
+ SET search_path = public, pg_temp
214
+ AS $$
215
+ SELECT resource_table FROM public.scoped_resources WHERE shareable;
216
+ $$;
217
+
218
+ -- Everything in `p_table` explicitly granted to me: to me by name, to a unit I
219
+ -- reach, or to a team I am in.
220
+ CREATE OR REPLACE FUNCTION public.granted_record_ids(p_table text)
221
+ RETURNS SETOF uuid
222
+ LANGUAGE sql STABLE SECURITY DEFINER
223
+ SET search_path = public, pg_temp
224
+ AS $$
225
+ SELECT g.resource_id
226
+ FROM public.resource_grants g
227
+ WHERE g.resource_table = p_table
228
+ AND (
229
+ (g.grantee_type = 'user' AND g.grantee_id = auth.uid())
230
+ OR (g.grantee_type = 'unit' AND g.grantee_id IN (SELECT public.user_unit_ids()))
231
+ OR (g.grantee_type = 'team' AND g.grantee_id IN (
232
+ SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = auth.uid()))
233
+ );
234
+ $$;
235
+
236
+ -- The rows of `p_table` that carry at least one UNIT grant.
237
+ --
238
+ -- This is what makes "Botox in 2 of 15" work without a second mechanism: a
239
+ -- product with no unit grant is sold everywhere, and the moment it gets two it
240
+ -- is sold in exactly those two. The predicate in 042 reads it as "a NULL unit
241
+ -- means the whole org, UNLESS somebody named the units".
242
+ CREATE OR REPLACE FUNCTION public.restricted_record_ids(p_table text)
243
+ RETURNS SETOF uuid
244
+ LANGUAGE sql STABLE SECURITY DEFINER
245
+ SET search_path = public, pg_temp
246
+ AS $$
247
+ SELECT DISTINCT g.resource_id
248
+ FROM public.resource_grants g
249
+ WHERE g.resource_table = p_table
250
+ AND g.grantee_type = 'unit';
251
+ $$;
252
+
253
+ -- Is this person one of ours? Used by the write gate to stop a grant pointing
254
+ -- at somebody outside the tenant.
255
+ --
256
+ -- SECURITY DEFINER and not an inline subquery on `tenant_members` on purpose:
257
+ -- that table's SELECT policy is `user_id = auth.uid()` on any pool that has not
258
+ -- taken 023, so an inline read would see only the caller and the gate would
259
+ -- silently allow granting to nobody but yourself.
260
+ CREATE OR REPLACE FUNCTION public.is_tenant_member(p_tenant_id uuid, p_user_id uuid)
261
+ RETURNS boolean
262
+ LANGUAGE sql STABLE SECURITY DEFINER
263
+ SET search_path = public, pg_temp
264
+ AS $$
265
+ SELECT EXISTS (
266
+ SELECT 1 FROM public.tenant_members tm
267
+ WHERE tm.tenant_id = p_tenant_id AND tm.user_id = p_user_id
268
+ );
269
+ $$;
270
+
271
+ -- Am I the owner of this one row? Used only in the write gate below, once per
272
+ -- inserted row.
273
+ --
274
+ -- The dynamic EXECUTE is safe because `p_table` is checked against the registry
275
+ -- FIRST — the table name comes from a controlled list, not from the caller. And
276
+ -- SECURITY DEFINER leaks nothing: the only thing this can ever tell you is
277
+ -- whether a row is yours, which you already know.
278
+ CREATE OR REPLACE FUNCTION public.owns_record(p_table text, p_id uuid)
279
+ RETURNS boolean
280
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
281
+ SET search_path = public, pg_temp
282
+ AS $$
283
+ DECLARE
284
+ v_owner_column text;
285
+ v_owner uuid;
286
+ BEGIN
287
+ IF auth.uid() IS NULL THEN
288
+ RETURN false;
289
+ END IF;
290
+
291
+ SELECT owner_column INTO v_owner_column
292
+ FROM public.scoped_resources WHERE resource_table = p_table;
293
+ IF v_owner_column IS NULL THEN
294
+ RETURN false;
295
+ END IF;
296
+
297
+ EXECUTE format('SELECT %I FROM public.%I WHERE id = $1', v_owner_column, p_table)
298
+ INTO v_owner USING p_id;
299
+
300
+ RETURN v_owner IS NOT NULL AND v_owner = auth.uid();
301
+ EXCEPTION WHEN OTHERS THEN
302
+ -- A registry row pointing at a table that has not been provisioned yet must
303
+ -- deny, not abort the caller's INSERT.
304
+ RETURN false;
305
+ END;
306
+ $$;
307
+
308
+ GRANT EXECUTE ON FUNCTION public.is_tenant_member(uuid, uuid) TO authenticated, service_role;
309
+ GRANT EXECUTE ON FUNCTION public.shareable_tables() TO authenticated, service_role;
310
+ GRANT EXECUTE ON FUNCTION public.granted_record_ids(text) TO authenticated, service_role;
311
+ GRANT EXECUTE ON FUNCTION public.restricted_record_ids(text) TO authenticated, service_role;
312
+ GRANT EXECUTE ON FUNCTION public.owns_record(text, uuid) TO authenticated, service_role;
313
+
314
+ -- ── Policies on the grants themselves ───────────────────────────────────────
315
+ -- This is where a sharing model is won or lost. If anyone can insert a row
316
+ -- here, anyone can grant themselves anything, and every predicate in 042 is
317
+ -- decoration.
318
+ --
319
+ -- NOTE, and do not "simplify" it away: none of these policies may call
320
+ -- granted_record_ids(). That function reads this table, and this table's
321
+ -- policies would then resolve through it — the self-loop 023's header warns
322
+ -- about, one table over.
323
+ DROP POLICY IF EXISTS "resource_grants_select" ON public.resource_grants;
324
+ CREATE POLICY "resource_grants_select" ON public.resource_grants FOR SELECT TO authenticated
325
+ USING (
326
+ tenant_id IN (SELECT public.user_tenant_ids())
327
+ AND (
328
+ tenant_id IN (SELECT public.user_admin_tenant_ids())
329
+ OR (grantee_type = 'user' AND grantee_id = (SELECT auth.uid()))
330
+ OR (grantee_type = 'unit' AND grantee_id IN (SELECT public.user_unit_ids()))
331
+ OR (grantee_type = 'team' AND grantee_id IN (
332
+ SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = (SELECT auth.uid())))
333
+ OR granted_by = (SELECT auth.uid())
334
+ )
335
+ );
336
+
337
+ -- The gate. Four conditions, each closing a different door:
338
+ --
339
+ -- (a) granted_by = me — you cannot write a grant that claims someone else
340
+ -- handed it out.
341
+ -- (b) the table is in the registry — without this, `resource_table` is free
342
+ -- text and someone grants themselves a row in a table the model never
343
+ -- meant to cover.
344
+ -- (c) owner-or-admin — deliberately NOT "anyone with write access". Answering
345
+ -- "may I write row R of table T?" needs dynamic SQL evaluated under that
346
+ -- table's own RLS, and that is exactly where subtle escalations live.
347
+ -- (d) the recipient belongs to this tenant — no granting across the boundary
348
+ -- that the whole product rests on.
349
+ DROP POLICY IF EXISTS "resource_grants_insert" ON public.resource_grants;
350
+ CREATE POLICY "resource_grants_insert" ON public.resource_grants FOR INSERT TO authenticated
351
+ WITH CHECK (
352
+ tenant_id IN (SELECT public.user_tenant_ids())
353
+ AND granted_by = (SELECT auth.uid())
354
+ AND resource_table IN (SELECT public.shareable_tables())
355
+ AND (
356
+ tenant_id IN (SELECT public.user_admin_tenant_ids())
357
+ OR public.owns_record(resource_table, resource_id)
358
+ )
359
+ AND (
360
+ (grantee_type = 'user' AND public.is_tenant_member(resource_grants.tenant_id, grantee_id))
361
+ OR (grantee_type = 'unit' AND grantee_id IN (
362
+ SELECT l.id FROM public.locations l WHERE l.tenant_id = resource_grants.tenant_id))
363
+ OR (grantee_type = 'team' AND grantee_id IN (
364
+ SELECT t.id FROM public.teams t WHERE t.tenant_id = resource_grants.tenant_id))
365
+ )
366
+ );
367
+
368
+ -- No UPDATE policy, on purpose. A grant is created or revoked, never edited:
369
+ -- mutating `access` in place makes the record's sharing history unreadable, the
370
+ -- same reason 026 keeps the audit trail append-only. Changing a level is a
371
+ -- delete and an insert, and both are visible.
372
+ DROP POLICY IF EXISTS "resource_grants_delete" ON public.resource_grants;
373
+ CREATE POLICY "resource_grants_delete" ON public.resource_grants FOR DELETE TO authenticated
374
+ USING (
375
+ tenant_id IN (SELECT public.user_tenant_ids())
376
+ AND (
377
+ tenant_id IN (SELECT public.user_admin_tenant_ids())
378
+ OR granted_by = (SELECT auth.uid())
379
+ OR public.owns_record(resource_table, resource_id)
380
+ )
381
+ );
382
+
383
+ GRANT SELECT, INSERT, DELETE ON public.resource_grants TO authenticated;
384
+ GRANT ALL ON public.resource_grants TO service_role;
385
+
386
+ -- ── The guards ──────────────────────────────────────────────────────────────
387
+ -- Everything above is PERMISSIVE, and permissive policies OR together — so any
388
+ -- policy added to these tables later (002's sweep is the concrete case, see the
389
+ -- header) silently widens them. These restrictive twins cannot be widened by
390
+ -- anything, ever, and they are what actually holds the line.
391
+ --
392
+ -- Written per command rather than as one FOR ALL: a restrictive policy's USING
393
+ -- clause applies to SELECT too, so a single FOR ALL admin guard would also stop
394
+ -- ordinary members from READING their own team list.
395
+ DO $$
396
+ DECLARE
397
+ t text;
398
+ admin_managed text[] := ARRAY['teams', 'team_members', 'resource_visibility_defaults'];
399
+ BEGIN
400
+ FOREACH t IN ARRAY admin_managed LOOP
401
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_insert_guard', t);
402
+ EXECUTE format(
403
+ 'CREATE POLICY %I ON public.%I AS RESTRICTIVE FOR INSERT TO authenticated
404
+ WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()))',
405
+ t || '_insert_guard', t);
406
+
407
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_update_guard', t);
408
+ EXECUTE format(
409
+ 'CREATE POLICY %I ON public.%I AS RESTRICTIVE FOR UPDATE TO authenticated
410
+ USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))',
411
+ t || '_update_guard', t);
412
+
413
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_delete_guard', t);
414
+ EXECUTE format(
415
+ 'CREATE POLICY %I ON public.%I AS RESTRICTIVE FOR DELETE TO authenticated
416
+ USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))',
417
+ t || '_delete_guard', t);
418
+ END LOOP;
419
+ END $$;
420
+
421
+ -- The grant gate itself, restated so it cannot be ORed away. This is the one
422
+ -- that matters: without it, a swept-in permissive INSERT policy lets any member
423
+ -- of the tenant write themselves access to any record.
424
+ DROP POLICY IF EXISTS "resource_grants_insert_guard" ON public.resource_grants;
425
+ CREATE POLICY "resource_grants_insert_guard" ON public.resource_grants
426
+ AS RESTRICTIVE FOR INSERT TO authenticated
427
+ WITH CHECK (
428
+ granted_by = (SELECT auth.uid())
429
+ AND resource_table IN (SELECT public.shareable_tables())
430
+ AND (
431
+ tenant_id IN (SELECT public.user_admin_tenant_ids())
432
+ OR public.owns_record(resource_table, resource_id)
433
+ )
434
+ AND (
435
+ (grantee_type = 'user' AND public.is_tenant_member(resource_grants.tenant_id, grantee_id))
436
+ OR (grantee_type = 'unit' AND grantee_id IN (
437
+ SELECT l.id FROM public.locations l WHERE l.tenant_id = resource_grants.tenant_id))
438
+ OR (grantee_type = 'team' AND grantee_id IN (
439
+ SELECT t.id FROM public.teams t WHERE t.tenant_id = resource_grants.tenant_id))
440
+ )
441
+ );
442
+
443
+ -- A grant is created or revoked, never edited (see the note above). Stated as a
444
+ -- restrictive `false` rather than as the absence of a policy, because absence is
445
+ -- exactly what a later sweep fills in.
446
+ DROP POLICY IF EXISTS "resource_grants_no_update" ON public.resource_grants;
447
+ CREATE POLICY "resource_grants_no_update" ON public.resource_grants
448
+ AS RESTRICTIVE FOR UPDATE TO authenticated
449
+ USING (false);
450
+
451
+ DROP POLICY IF EXISTS "resource_grants_delete_guard" ON public.resource_grants;
452
+ CREATE POLICY "resource_grants_delete_guard" ON public.resource_grants
453
+ AS RESTRICTIVE FOR DELETE TO authenticated
454
+ USING (
455
+ tenant_id IN (SELECT public.user_admin_tenant_ids())
456
+ OR granted_by = (SELECT auth.uid())
457
+ OR public.owns_record(resource_table, resource_id)
458
+ );
459
+
460
+ -- Who may even SEE that a record was shared. Restrictive for the same reason:
461
+ -- a swept-in permissive SELECT would turn the grant table into a map of every
462
+ -- private record in the tenant and who can read it.
463
+ DROP POLICY IF EXISTS "resource_grants_select_guard" ON public.resource_grants;
464
+ CREATE POLICY "resource_grants_select_guard" ON public.resource_grants
465
+ AS RESTRICTIVE FOR SELECT TO authenticated
466
+ USING (
467
+ tenant_id IN (SELECT public.user_admin_tenant_ids())
468
+ OR (grantee_type = 'user' AND grantee_id = (SELECT auth.uid()))
469
+ OR (grantee_type = 'unit' AND grantee_id IN (SELECT public.user_unit_ids()))
470
+ OR (grantee_type = 'team' AND grantee_id IN (
471
+ SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = (SELECT auth.uid())))
472
+ OR granted_by = (SELECT auth.uid())
473
+ OR public.owns_record(resource_table, resource_id)
474
+ );
@@ -0,0 +1,192 @@
1
+ -- ============================================================================
2
+ -- 041_scoped_columns.sql — the signature every record starts carrying.
3
+ --
4
+ -- 025 gave every core table a `created_by`, because "who registered this?" had
5
+ -- no answer anywhere. This file adds the other two halves of the same question:
6
+ --
7
+ -- unit_id WHERE it happened / which unit it belongs to. NULL is a real and
8
+ -- permanent answer: "the whole organization". A brand-level product
9
+ -- has no unit and never will.
10
+ -- owner_id WHO answers for it TODAY. Deliberately not the same column as
11
+ -- created_by: provenance is not responsibility. João registered the
12
+ -- sourdough; if João leaves, Ana owns it, and the history still says
13
+ -- João made it.
14
+ --
15
+ -- WHAT ALREADY EXISTS AND IS REUSED. `orders`, `appointments` and `schedules`
16
+ -- have carried `location_id` since 004_archetypes, pointing at the very table
17
+ -- 039 turned into the unit tree. That column IS the unit. Adding a second one
18
+ -- to the three busiest tables in the product to satisfy a naming preference
19
+ -- would be the wrong trade, so the registry names the column per table instead.
20
+ --
21
+ -- WHAT IS DELIBERATELY LEFT OUT:
22
+ -- locations it IS the tree; a unit does not belong to a unit
23
+ -- order_items, their policies already resolve through the parent row
24
+ -- appointment_items (006_archetype_rls), so they inherit for free
25
+ -- addresses already has an `owner_id` with completely different,
26
+ -- polymorphic meaning (shop 0012: owner_type + owner_id).
27
+ -- Two columns with one name and two meanings in one schema
28
+ -- is a bug waiting for a maintainer.
29
+ -- plg_* plugin-owned tables register themselves from their own
30
+ -- migrations. `documents` is the exception: 002 already
31
+ -- lists it among the core tables, and it is the patient
32
+ -- record the whole owner-visibility mode exists for.
33
+ --
34
+ -- FACTORY VISIBILITY, and why it is not uniform: a transaction belongs to the
35
+ -- unit where it happened; a catalogue belongs to the brand. So appointments,
36
+ -- orders, transactions and schedules start at 'unit' and everything else starts
37
+ -- at 'tenant'. A tenant that never turns unit scoping on is unaffected either
38
+ -- way — nothing in this file reads these values until 042, and 042 short
39
+ -- circuits while the switch is off.
40
+ --
41
+ -- Additive and idempotent. Nullable ADD COLUMN does not rewrite a table on
42
+ -- PG 11+, so this is safe on a populated pool.
43
+ -- ============================================================================
44
+
45
+ -- ── Columns, indexes and the stamping trigger, per table ────────────────────
46
+ DO $$
47
+ DECLARE
48
+ r record;
49
+ spec jsonb := '[
50
+ {"t": "people", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Pessoas"},
51
+ {"t": "categories", "unit": "unit_id", "vis": "tenant", "share": false, "label": "Categorias"},
52
+ {"t": "products", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Produtos"},
53
+ {"t": "services", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Serviços"},
54
+ {"t": "documents", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Documentos"},
55
+ {"t": "orders", "unit": "location_id", "vis": "unit", "share": false, "label": "Pedidos"},
56
+ {"t": "transactions", "unit": "unit_id", "vis": "unit", "share": false, "label": "Lançamentos"},
57
+ {"t": "appointments", "unit": "location_id", "vis": "unit", "share": false, "label": "Agendamentos"},
58
+ {"t": "schedules", "unit": "location_id", "vis": "unit", "share": false, "label": "Horários"}
59
+ ]'::jsonb;
60
+ v_t text;
61
+ v_unit text;
62
+ v_has_author boolean;
63
+ v_owner_default text;
64
+ BEGIN
65
+ FOR r IN SELECT * FROM jsonb_array_elements(spec) AS e(v) LOOP
66
+ v_t := r.v ->> 't';
67
+ v_unit := r.v ->> 'unit';
68
+
69
+ -- The direct answer to "the ledger is not to be trusted": a pool may or may
70
+ -- not have this table regardless of what any migration record claims.
71
+ IF to_regclass('public.' || v_t) IS NULL THEN
72
+ CONTINUE;
73
+ END IF;
74
+
75
+ -- ON DELETE SET NULL, not CASCADE: closing a unit must not delete its
76
+ -- history. The rows fall back to "the whole organization", which is the
77
+ -- honest state for a record whose unit no longer exists.
78
+ EXECUTE format(
79
+ 'ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS %I uuid REFERENCES public.locations(id) ON DELETE SET NULL',
80
+ v_t, v_unit);
81
+
82
+ EXECUTE format(
83
+ 'ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL',
84
+ v_t);
85
+
86
+ -- tenant_id leads because every app query already carries it. These indexes
87
+ -- are for the APPLICATION's own filters, not for the policy: a disjunction
88
+ -- in an RLS predicate can never be index-driven (the rewriter puts it in
89
+ -- securityQuals, where a sublink is never turned into a semijoin), so it is
90
+ -- always a filter. Do not add more indexes hoping to speed the policy up.
91
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I (tenant_id, %I)',
92
+ v_t || '_unit_idx', v_t, v_unit);
93
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I (tenant_id, owner_id) WHERE owner_id IS NOT NULL',
94
+ v_t || '_owner_idx', v_t);
95
+
96
+ -- `created_by` arrives with 025 for the archetypes and with plugin-forms
97
+ -- for `documents`. Checked rather than assumed: a pool assembled by hand
98
+ -- may be missing it, and a migration that fails on a drifted pool is a
99
+ -- migration nobody can apply.
100
+ SELECT EXISTS (
101
+ SELECT 1 FROM information_schema.columns
102
+ WHERE table_schema = 'public' AND table_name = v_t AND column_name = 'created_by'
103
+ ) INTO v_has_author;
104
+
105
+ -- Existing rows get the author as owner where there is one. Rows that
106
+ -- predate 025 have no author and stay ownerless — inventing one would
107
+ -- fabricate responsibility, and 042 reads a NULL owner as "nobody owns
108
+ -- this", which is exactly right: an ownerless record can never be private.
109
+ IF v_has_author THEN
110
+ EXECUTE format('UPDATE public.%I SET owner_id = created_by WHERE owner_id IS NULL AND created_by IS NOT NULL', v_t);
111
+ END IF;
112
+
113
+ v_owner_default := CASE WHEN v_has_author THEN 'COALESCE(NEW.created_by, auth.uid())' ELSE 'auth.uid()' END;
114
+
115
+ -- One generated trigger function per table instead of one shared function
116
+ -- reading TG_ARGV: assigning to a column whose name is only known at
117
+ -- runtime means a to_jsonb/jsonb_populate_record round trip through every
118
+ -- inserted row, and that round trip is exactly where a text[] or a numeric
119
+ -- quietly changes shape. Baking the two column names in keeps it a plain
120
+ -- field assignment.
121
+ --
122
+ -- IT MUST NEVER RAISE. place_order, fn_invoice_from_order_internal and
123
+ -- every agent_* RPC are SECURITY DEFINER and insert into these tables. RLS
124
+ -- does not apply to them — but a BEFORE INSERT trigger still fires, so a
125
+ -- trigger that complains takes down anonymous checkout. Validation is the
126
+ -- WITH CHECK clause's job in 042, which those paths legitimately bypass.
127
+ EXECUTE format($f$
128
+ CREATE OR REPLACE FUNCTION public.%1$I() RETURNS trigger
129
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $body$
130
+ BEGIN
131
+ IF NEW.%2$I IS NULL THEN
132
+ NEW.%2$I := public.requested_unit();
133
+ END IF;
134
+ IF NEW.owner_id IS NULL THEN
135
+ NEW.owner_id := %3$s;
136
+ END IF;
137
+ RETURN NEW;
138
+ EXCEPTION WHEN OTHERS THEN
139
+ RETURN NEW;
140
+ END $body$;
141
+ $f$, 'stamp_scope_' || v_t, v_unit, v_owner_default);
142
+
143
+ EXECUTE format('REVOKE ALL ON FUNCTION public.%I() FROM public, anon', 'stamp_scope_' || v_t);
144
+
145
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', v_t || '_scope_stamp', v_t);
146
+ EXECUTE format(
147
+ 'CREATE TRIGGER %I BEFORE INSERT ON public.%I FOR EACH ROW EXECUTE FUNCTION public.%I()',
148
+ v_t || '_scope_stamp', v_t, 'stamp_scope_' || v_t);
149
+
150
+ -- The registry row. This is the list 042 loops over and the settings screen
151
+ -- reads — one source of truth instead of the same array copied into three
152
+ -- files that will drift apart on the first table anyone adds.
153
+ INSERT INTO public.scoped_resources
154
+ (resource_table, unit_column, owner_column, shareable, default_visibility, label)
155
+ VALUES
156
+ (v_t, v_unit, 'owner_id', (r.v ->> 'share')::boolean, r.v ->> 'vis', r.v ->> 'label')
157
+ ON CONFLICT (resource_table) DO UPDATE
158
+ SET unit_column = EXCLUDED.unit_column,
159
+ owner_column = EXCLUDED.owner_column,
160
+ shareable = EXCLUDED.shareable,
161
+ label = EXCLUDED.label;
162
+ -- default_visibility is NOT updated on conflict: it is the factory setting,
163
+ -- and re-running a migration must not walk back a decision an operator made
164
+ -- (a tenant's own choice lives in resource_visibility_defaults regardless).
165
+ END LOOP;
166
+ END $$;
167
+
168
+ -- ── The effective visibility of a record type, per tenant ───────────────────
169
+ -- The tenant's own choice wins over the factory setting. Restricted to tenants
170
+ -- that actually turned unit scoping on, because that is the only set 042 ever
171
+ -- asks about — and it keeps this set small enough to hash once per query.
172
+ --
173
+ -- Two arguments, both constants baked into the policy text by 042's generator,
174
+ -- so it stays uncorrelated: one InitPlan per query, not one call per row.
175
+ CREATE OR REPLACE FUNCTION public.resource_mode_tenants(p_table text, p_mode text)
176
+ RETURNS SETOF uuid
177
+ LANGUAGE sql STABLE SECURITY DEFINER
178
+ SET search_path = public, pg_temp
179
+ AS $$
180
+ SELECT t.id
181
+ FROM public.tenants t
182
+ LEFT JOIN public.resource_visibility_defaults d
183
+ ON d.tenant_id = t.id AND d.resource_table = p_table
184
+ WHERE t.unit_scoping_enabled
185
+ AND COALESCE(
186
+ d.default_visibility,
187
+ (SELECT sr.default_visibility FROM public.scoped_resources sr
188
+ WHERE sr.resource_table = p_table)
189
+ ) = p_mode;
190
+ $$;
191
+
192
+ GRANT EXECUTE ON FUNCTION public.resource_mode_tenants(text, text) TO authenticated, service_role;