@fayz-ai/db 0.12.0 → 0.13.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.
Files changed (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,330 @@
1
+ -- ============================================================================
2
+ -- 051_authz_helpers.sql — the RLS helpers (§7) and effective role (§8).
3
+ --
4
+ -- All `STABLE SECURITY DEFINER SET search_path = ''`, meant to be called wrapped
5
+ -- in `(select …)` so the planner caches them per statement (initPlan). They
6
+ -- read the authz tables with the migration role's privileges (no recursion into
7
+ -- RLS) and never raise: an RLS policy is not a place to throw, so junk resolves
8
+ -- to "no access".
9
+ --
10
+ -- Also here: the RLS policies of the authz tables themselves (040 could not
11
+ -- create them before the helpers existed).
12
+ -- ============================================================================
13
+
14
+ -- ── current_tenant_id() ─────────────────────────────────────────────────
15
+ -- The tenant this request acts in. Same claim semantics as public.user_tenant_ids()
16
+ -- (028): a top-level `tenant_id` JWT claim NARROWS and never widens — with a
17
+ -- signed-in session it must match an active membership; a session-less signed
18
+ -- token is scoped by the claim alone (only we can mint one). Without a claim,
19
+ -- a user with exactly one active membership acts in it; a multi-tenant user
20
+ -- without a claim acts in none (fail closed — the app sets the claim / active
21
+ -- tenant). Only tenant_id (and, later, the active unit) live in the JWT:
22
+ -- permissions and unit scope always come from tables (revocation is instant).
23
+ CREATE OR REPLACE FUNCTION app.current_tenant_id()
24
+ RETURNS uuid
25
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
26
+ SET search_path = ''
27
+ AS $$
28
+ DECLARE
29
+ v_uid uuid := auth.uid();
30
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
31
+ v_raw text;
32
+ v_tenant uuid;
33
+ v_n integer;
34
+ BEGIN
35
+ IF jsonb_exists(v_jwt, 'tenant_id') THEN
36
+ v_raw := v_jwt ->> 'tenant_id';
37
+ -- Presence expresses intent to be narrowed; an unparseable value is no tenant.
38
+ IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
39
+ RETURN NULL;
40
+ END IF;
41
+ v_tenant := v_raw::uuid;
42
+ IF v_uid IS NULL THEN
43
+ RETURN v_tenant;
44
+ END IF;
45
+ IF EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
46
+ RETURN v_tenant;
47
+ END IF;
48
+ RETURN NULL;
49
+ END IF;
50
+
51
+ IF v_uid IS NULL THEN
52
+ RETURN NULL;
53
+ END IF;
54
+ SELECT count(*), (array_agg(m.tenant_id))[1] INTO v_n, v_tenant
55
+ FROM app.memberships m WHERE m.user_id = v_uid AND m.active;
56
+ IF v_n = 1 THEN
57
+ RETURN v_tenant;
58
+ END IF;
59
+ RETURN NULL;
60
+ END $$;
61
+
62
+ -- ── unit_scoping_on() — the per-account switch main already had ─────────
63
+ -- `039_unit_tree` gave every tenant a `unit_scoping_enabled`, off by default,
64
+ -- and that is a better design than the always-on this chain originally shipped:
65
+ -- a network turns it on when it is ready, and turning it off gives everything
66
+ -- back without moving a row. The policy template honours that switch instead of
67
+ -- replacing it.
68
+ --
69
+ -- Switch off ⇒ every unit of the account is yours, which is exactly the
70
+ -- behaviour from before units existed.
71
+ CREATE OR REPLACE FUNCTION app.unit_scoping_on()
72
+ RETURNS boolean
73
+ LANGUAGE sql STABLE SECURITY DEFINER
74
+ SET search_path = ''
75
+ AS $$
76
+ SELECT coalesce((SELECT t.unit_scoping_enabled FROM public.tenants t
77
+ WHERE t.id = app.current_tenant_id()), false);
78
+ $$;
79
+ REVOKE ALL ON FUNCTION app.unit_scoping_on() FROM public, anon;
80
+ GRANT EXECUTE ON FUNCTION app.unit_scoping_on() TO authenticated, service_role;
81
+
82
+ -- ── has_unit(unit) ──────────────────────────────────────────────────────
83
+ -- O(1) on the share table's PK. A NULL unit is a tenant-wide resource: the
84
+ -- template checks `unit_id IS NULL OR has_unit(unit_id)` itself.
85
+ CREATE OR REPLACE FUNCTION app.has_unit(p_unit uuid)
86
+ RETURNS boolean
87
+ LANGUAGE sql STABLE SECURITY DEFINER
88
+ SET search_path = ''
89
+ AS $$
90
+ SELECT p_unit IS NOT NULL
91
+ AND CASE WHEN app.unit_scoping_on()
92
+ THEN EXISTS (SELECT 1 FROM app.user_unit_access a
93
+ WHERE a.tenant_id = app.current_tenant_id()
94
+ AND a.user_id = auth.uid()
95
+ AND a.unit_id = p_unit)
96
+ -- switch off: every unit of the account is yours
97
+ ELSE EXISTS (SELECT 1 FROM app.units u
98
+ WHERE u.id = p_unit AND u.tenant_id = app.current_tenant_id())
99
+ END;
100
+ $$;
101
+
102
+ -- ── effective_role_id(tenant, user, unit) ───────────────────────────────
103
+ -- §8: membership_units.role_override_id when present for (user, unit), else
104
+ -- memberships.role_id. Inactive membership ⇒ no role.
105
+ CREATE OR REPLACE FUNCTION app.effective_role_id(p_tenant uuid, p_user uuid, p_unit uuid DEFAULT NULL)
106
+ RETURNS uuid
107
+ LANGUAGE sql STABLE SECURITY DEFINER
108
+ SET search_path = ''
109
+ AS $$
110
+ SELECT coalesce(
111
+ (SELECT mu.role_override_id
112
+ FROM app.membership_units mu
113
+ WHERE p_unit IS NOT NULL
114
+ AND mu.tenant_id = p_tenant AND mu.user_id = p_user AND mu.unit_id = p_unit
115
+ AND mu.role_override_id IS NOT NULL),
116
+ (SELECT m.role_id
117
+ FROM app.memberships m
118
+ WHERE m.tenant_id = p_tenant AND m.user_id = p_user AND m.active)
119
+ );
120
+ $$;
121
+
122
+ -- ── has_permission(perm[, unit]) ────────────────────────────────────────
123
+ -- Resolves against the effective role for the row's unit when one is given,
124
+ -- else the base role. `{feature}.manage` implies every `{feature}.{action}`
125
+ -- (the SDK's resolveAccess semantics, mirrored — same source, no drift). No
126
+ -- owner bypass: an owner is a role whose permission set is the whole catalog.
127
+ CREATE OR REPLACE FUNCTION app.has_permission(p_perm text, p_unit uuid DEFAULT NULL)
128
+ RETURNS boolean
129
+ LANGUAGE sql STABLE SECURITY DEFINER
130
+ SET search_path = ''
131
+ AS $$
132
+ SELECT EXISTS (
133
+ SELECT 1
134
+ FROM app.role_permissions rp
135
+ WHERE rp.tenant_id = app.current_tenant_id()
136
+ AND rp.role_id = app.effective_role_id(app.current_tenant_id(), auth.uid(), p_unit)
137
+ AND rp.permission IN (p_perm, regexp_replace(p_perm, '\.[^.]+$', '.manage'))
138
+ );
139
+ $$;
140
+
141
+ -- Does the user hold the permission in ANY of their roles (base or any unit
142
+ -- override)? For UI affordances and tenant-wide actions, not for row scope.
143
+ CREATE OR REPLACE FUNCTION app.has_permission_anywhere(p_perm text)
144
+ RETURNS boolean
145
+ LANGUAGE sql STABLE SECURITY DEFINER
146
+ SET search_path = ''
147
+ AS $$
148
+ SELECT EXISTS (
149
+ SELECT 1
150
+ FROM app.role_permissions rp
151
+ WHERE rp.tenant_id = app.current_tenant_id()
152
+ AND rp.permission IN (p_perm, regexp_replace(p_perm, '\.[^.]+$', '.manage'))
153
+ AND rp.role_id IN (
154
+ SELECT m.role_id FROM app.memberships m
155
+ WHERE m.tenant_id = rp.tenant_id AND m.user_id = auth.uid() AND m.active
156
+ UNION
157
+ SELECT mu.role_override_id FROM app.membership_units mu
158
+ WHERE mu.tenant_id = rp.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL
159
+ )
160
+ );
161
+ $$;
162
+
163
+ -- ── has_grant(type, id, relations[]) ────────────────────────────────────
164
+ -- resource_grants where the subject is me, one of my units, or one of my roles.
165
+ CREATE OR REPLACE FUNCTION app.has_grant(p_type text, p_id uuid, p_relations text[])
166
+ RETURNS boolean
167
+ LANGUAGE sql STABLE SECURITY DEFINER
168
+ SET search_path = ''
169
+ AS $$
170
+ SELECT EXISTS (
171
+ SELECT 1
172
+ FROM app.resource_grants g
173
+ WHERE g.tenant_id = app.current_tenant_id()
174
+ AND g.resource_type = p_type
175
+ AND g.resource_id = p_id
176
+ AND g.relation = ANY (p_relations)
177
+ AND (
178
+ (g.subject_type = 'user' AND g.subject_id = auth.uid())
179
+ OR (g.subject_type = 'unit' AND EXISTS (
180
+ SELECT 1 FROM app.user_unit_access a
181
+ WHERE a.tenant_id = g.tenant_id AND a.user_id = auth.uid() AND a.unit_id = g.subject_id))
182
+ OR (g.subject_type = 'role' AND g.subject_id IN (
183
+ SELECT m.role_id FROM app.memberships m
184
+ WHERE m.tenant_id = g.tenant_id AND m.user_id = auth.uid() AND m.active
185
+ UNION
186
+ SELECT mu.role_override_id FROM app.membership_units mu
187
+ WHERE mu.tenant_id = g.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL))
188
+ )
189
+ );
190
+ $$;
191
+
192
+ -- "No available_in tuples ⇒ available everywhere" (§9.1): a tenant-wide
193
+ -- resource is visible in a unit unless the resource has been restricted, in
194
+ -- which case the unit must be one of the granted ones.
195
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
196
+ RETURNS boolean
197
+ LANGUAGE sql STABLE SECURITY DEFINER
198
+ SET search_path = ''
199
+ AS $$
200
+ SELECT NOT EXISTS (
201
+ SELECT 1 FROM app.resource_grants g
202
+ WHERE g.tenant_id = app.current_tenant_id()
203
+ AND g.resource_type = p_type AND g.resource_id = p_id AND g.relation = 'available_in')
204
+ OR app.has_grant(p_type, p_id, ARRAY['available_in']);
205
+ $$;
206
+
207
+ -- ── is_member(tenant) ───────────────────────────────────────────────────
208
+ -- Plain active membership, no permission — what the authz tables' own read
209
+ -- policies need ("members see their tenant's units and roles").
210
+ CREATE OR REPLACE FUNCTION app.is_member(p_tenant uuid)
211
+ RETURNS boolean
212
+ LANGUAGE sql STABLE SECURITY DEFINER
213
+ SET search_path = ''
214
+ AS $$
215
+ SELECT EXISTS (
216
+ SELECT 1 FROM app.memberships m
217
+ WHERE m.tenant_id = p_tenant AND m.user_id = auth.uid() AND m.active
218
+ );
219
+ $$;
220
+
221
+ REVOKE ALL ON FUNCTION app.current_tenant_id() FROM public;
222
+ REVOKE ALL ON FUNCTION app.has_unit(uuid) FROM public;
223
+ REVOKE ALL ON FUNCTION app.effective_role_id(uuid, uuid, uuid) FROM public;
224
+ REVOKE ALL ON FUNCTION app.has_permission(text, uuid) FROM public;
225
+ REVOKE ALL ON FUNCTION app.has_permission_anywhere(text) FROM public;
226
+ REVOKE ALL ON FUNCTION app.has_grant(text, uuid, text[]) FROM public;
227
+ REVOKE ALL ON FUNCTION app.available_in_my_units(text, uuid) FROM public;
228
+ REVOKE ALL ON FUNCTION app.is_member(uuid) FROM public;
229
+ GRANT EXECUTE ON FUNCTION app.current_tenant_id() TO authenticated, service_role;
230
+ GRANT EXECUTE ON FUNCTION app.has_unit(uuid) TO authenticated, service_role;
231
+ GRANT EXECUTE ON FUNCTION app.effective_role_id(uuid, uuid, uuid) TO authenticated, service_role;
232
+ GRANT EXECUTE ON FUNCTION app.has_permission(text, uuid) TO authenticated, service_role;
233
+ GRANT EXECUTE ON FUNCTION app.has_permission_anywhere(text) TO authenticated, service_role;
234
+ GRANT EXECUTE ON FUNCTION app.has_grant(text, uuid, text[]) TO authenticated, service_role;
235
+ GRANT EXECUTE ON FUNCTION app.available_in_my_units(text, uuid) TO authenticated, service_role;
236
+ GRANT EXECUTE ON FUNCTION app.is_member(uuid) TO authenticated, service_role;
237
+
238
+ -- ═══════════════════════ RLS on the authz tables ══════════════════════════
239
+ -- Reads: any active member of the tenant sees the tenant's units, roles and
240
+ -- role permissions (the UI needs them to render); memberships and unit rows are
241
+ -- visible to the member herself and to holders of members.read; the share table
242
+ -- to the user herself and to members.read; grants to members of the tenant.
243
+ -- Writes: platform permission keys — no owner/admin literal anywhere.
244
+
245
+ -- units
246
+ DROP POLICY IF EXISTS units_select ON app.units;
247
+ CREATE POLICY units_select ON app.units FOR SELECT TO authenticated
248
+ USING ((select app.is_member(tenant_id)));
249
+ DROP POLICY IF EXISTS units_insert ON app.units;
250
+ CREATE POLICY units_insert ON app.units FOR INSERT TO authenticated
251
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
252
+ DROP POLICY IF EXISTS units_update ON app.units;
253
+ CREATE POLICY units_update ON app.units FOR UPDATE TO authenticated
254
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')))
255
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
256
+ DROP POLICY IF EXISTS units_delete ON app.units;
257
+ CREATE POLICY units_delete ON app.units FOR DELETE TO authenticated
258
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
259
+
260
+ -- roles
261
+ DROP POLICY IF EXISTS roles_select ON app.roles;
262
+ CREATE POLICY roles_select ON app.roles FOR SELECT TO authenticated
263
+ USING ((select app.is_member(tenant_id)));
264
+ DROP POLICY IF EXISTS roles_insert ON app.roles;
265
+ CREATE POLICY roles_insert ON app.roles FOR INSERT TO authenticated
266
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')) AND NOT is_system);
267
+ DROP POLICY IF EXISTS roles_update ON app.roles;
268
+ CREATE POLICY roles_update ON app.roles FOR UPDATE TO authenticated
269
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')))
270
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')));
271
+ DROP POLICY IF EXISTS roles_delete ON app.roles;
272
+ CREATE POLICY roles_delete ON app.roles FOR DELETE TO authenticated
273
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')) AND NOT is_system);
274
+
275
+ -- role_permissions
276
+ DROP POLICY IF EXISTS role_permissions_select ON app.role_permissions;
277
+ CREATE POLICY role_permissions_select ON app.role_permissions FOR SELECT TO authenticated
278
+ USING ((select app.is_member(tenant_id)));
279
+ DROP POLICY IF EXISTS role_permissions_write ON app.role_permissions;
280
+ CREATE POLICY role_permissions_write ON app.role_permissions FOR ALL TO authenticated
281
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')))
282
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')));
283
+
284
+ -- memberships: yourself, or members.read; writes need members.manage and can
285
+ -- never be a self-insert (the P0 of the old model) — creation of the first
286
+ -- member is the create_tenant RPC's job (#134).
287
+ DROP POLICY IF EXISTS memberships_select ON app.memberships;
288
+ CREATE POLICY memberships_select ON app.memberships FOR SELECT TO authenticated
289
+ USING (user_id = (select auth.uid()) OR (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read'))));
290
+ DROP POLICY IF EXISTS memberships_insert ON app.memberships;
291
+ CREATE POLICY memberships_insert ON app.memberships FOR INSERT TO authenticated
292
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')) AND user_id <> (select auth.uid()));
293
+ DROP POLICY IF EXISTS memberships_update ON app.memberships;
294
+ CREATE POLICY memberships_update ON app.memberships FOR UPDATE TO authenticated
295
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
296
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
297
+ DROP POLICY IF EXISTS memberships_delete ON app.memberships;
298
+ CREATE POLICY memberships_delete ON app.memberships FOR DELETE TO authenticated
299
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')) AND user_id <> (select auth.uid()));
300
+
301
+ -- membership_units
302
+ DROP POLICY IF EXISTS membership_units_select ON app.membership_units;
303
+ CREATE POLICY membership_units_select ON app.membership_units FOR SELECT TO authenticated
304
+ USING (user_id = (select auth.uid()) OR (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read'))));
305
+ DROP POLICY IF EXISTS membership_units_write ON app.membership_units;
306
+ CREATE POLICY membership_units_write ON app.membership_units FOR ALL TO authenticated
307
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
308
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
309
+
310
+ -- user_unit_access: read-only surface (the share table)
311
+ DROP POLICY IF EXISTS user_unit_access_select ON app.user_unit_access;
312
+ CREATE POLICY user_unit_access_select ON app.user_unit_access FOR SELECT TO authenticated
313
+ USING (user_id = (select auth.uid()) OR (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read'))));
314
+
315
+ -- resource_grants: members read; writing a tuple needs the platform key
316
+ -- authz.grants.manage or the resource's own '{plugin}.manage' (§12.9 default).
317
+ DROP POLICY IF EXISTS resource_grants_select ON app.resource_grants;
318
+ CREATE POLICY resource_grants_select ON app.resource_grants FOR SELECT TO authenticated
319
+ USING ((select app.is_member(tenant_id)));
320
+ DROP POLICY IF EXISTS resource_grants_write ON app.resource_grants;
321
+ CREATE POLICY resource_grants_write ON app.resource_grants FOR ALL TO authenticated
322
+ USING (tenant_id = (select app.current_tenant_id())
323
+ AND ((select app.has_permission('authz.grants.manage')) OR (select app.has_permission(split_part(resource_type, '.', 1) || '.manage'))))
324
+ WITH CHECK (tenant_id = (select app.current_tenant_id())
325
+ AND ((select app.has_permission('authz.grants.manage')) OR (select app.has_permission(split_part(resource_type, '.', 1) || '.manage'))));
326
+
327
+ -- reconciliation log: readable by members.read of the tenant
328
+ DROP POLICY IF EXISTS authz_reconciliation_log_select ON app.authz_reconciliation_log;
329
+ CREATE POLICY authz_reconciliation_log_select ON app.authz_reconciliation_log FOR SELECT TO authenticated
330
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read')));
@@ -0,0 +1,253 @@
1
+ -- ============================================================================
2
+ -- 052_authz_fanout.sql — user_unit_access maintenance (§5) + reconciliation (§6).
3
+ --
4
+ -- The share table is DERIVED from memberships × membership_units × units. Rather
5
+ -- than hand-writing an add/remove per event and getting one of the four fan-out
6
+ -- cases wrong, every trigger rebuilds the affected users' rows from the source
7
+ -- of truth (app.compute_user_unit_access) inside the same transaction. The
8
+ -- reconciliation job is the same rebuild for a whole tenant, with a diff-log.
9
+ --
10
+ -- Decisions taken as the spec's defaults (grill items 1, 2 and 8): access at a
11
+ -- parent unit PROPAGATES to its subtree; a deactivated unit's rows are REMOVED
12
+ -- (reconciliation restores them on reactivation); `via` provenance is kept.
13
+ --
14
+ -- The four cases:
15
+ -- F1 membership_units insert/delete/update → rebuild that user
16
+ -- F2 memberships insert/update(all_units, active, role)/delete → rebuild that user
17
+ -- F3 units insert (active) → rebuild every all_units member and
18
+ -- every user with explicit access to
19
+ -- an ancestor of the new unit
20
+ -- F4 units.parent_unit_id / active change → rebuild the tenant's affected users
21
+ -- (all_units + anyone with explicit
22
+ -- access anywhere in the tenant)
23
+ -- ============================================================================
24
+
25
+ -- What (unit, via) a user should have. Precedence when several paths reach the
26
+ -- same unit: all_units > explicit > subtree.
27
+ CREATE OR REPLACE FUNCTION app.compute_user_unit_access(p_tenant uuid, p_user uuid)
28
+ RETURNS TABLE (unit_id uuid, via text)
29
+ LANGUAGE sql STABLE SECURITY DEFINER
30
+ SET search_path = ''
31
+ AS $$
32
+ WITH m AS (
33
+ SELECT all_units FROM app.memberships
34
+ WHERE tenant_id = p_tenant AND user_id = p_user AND active
35
+ ),
36
+ explicit AS (
37
+ SELECT mu.unit_id
38
+ FROM app.membership_units mu
39
+ JOIN app.units u ON u.tenant_id = mu.tenant_id AND u.id = mu.unit_id AND u.active
40
+ WHERE mu.tenant_id = p_tenant AND mu.user_id = p_user
41
+ AND EXISTS (SELECT 1 FROM m)
42
+ ),
43
+ subtree AS (
44
+ -- descendants of every explicit unit (parent access ⇒ subtree access)
45
+ WITH RECURSIVE d AS (
46
+ SELECT u.id, u.parent_unit_id, 0 AS depth
47
+ FROM app.units u
48
+ WHERE u.tenant_id = p_tenant AND u.id IN (SELECT unit_id FROM explicit)
49
+ UNION ALL
50
+ SELECT c.id, c.parent_unit_id, d.depth + 1
51
+ FROM app.units c
52
+ JOIN d ON c.parent_unit_id = d.id
53
+ WHERE c.tenant_id = p_tenant AND c.active AND d.depth < 32
54
+ )
55
+ SELECT DISTINCT id AS unit_id FROM d WHERE depth > 0
56
+ ),
57
+ everything AS (
58
+ SELECT u.id AS unit_id FROM app.units u
59
+ WHERE u.tenant_id = p_tenant AND u.active
60
+ AND EXISTS (SELECT 1 FROM m WHERE all_units)
61
+ ),
62
+ ranked AS (
63
+ SELECT unit_id, 'all_units'::text AS via, 1 AS prio FROM everything
64
+ UNION ALL
65
+ SELECT unit_id, 'explicit', 2 FROM explicit
66
+ UNION ALL
67
+ SELECT unit_id, 'subtree', 3 FROM subtree
68
+ )
69
+ SELECT DISTINCT ON (unit_id) unit_id, via
70
+ FROM ranked
71
+ ORDER BY unit_id, prio;
72
+ $$;
73
+
74
+ -- Rebuild one user's rows in place. Returns (added, removed). Two statements on
75
+ -- purpose: a row whose `via` changed is deleted by the first and re-inserted by
76
+ -- the second — a single data-modifying CTE cannot delete and upsert the same
77
+ -- row in one command.
78
+ CREATE OR REPLACE FUNCTION app.refresh_user_unit_access(p_tenant uuid, p_user uuid, OUT added integer, OUT removed integer)
79
+ LANGUAGE plpgsql SECURITY DEFINER
80
+ SET search_path = ''
81
+ AS $$
82
+ BEGIN
83
+ WITH desired AS (
84
+ SELECT unit_id, via FROM app.compute_user_unit_access(p_tenant, p_user)
85
+ ), del AS (
86
+ DELETE FROM app.user_unit_access a
87
+ WHERE a.tenant_id = p_tenant AND a.user_id = p_user
88
+ AND NOT EXISTS (SELECT 1 FROM desired d WHERE d.unit_id = a.unit_id AND d.via = a.via)
89
+ RETURNING 1
90
+ )
91
+ SELECT count(*) INTO removed FROM del;
92
+
93
+ WITH ins AS (
94
+ INSERT INTO app.user_unit_access (tenant_id, user_id, unit_id, via)
95
+ SELECT p_tenant, p_user, d.unit_id, d.via FROM app.compute_user_unit_access(p_tenant, p_user) d
96
+ ON CONFLICT (tenant_id, user_id, unit_id) DO NOTHING
97
+ RETURNING 1
98
+ )
99
+ SELECT count(*) INTO added FROM ins;
100
+ END $$;
101
+
102
+ -- The reconciliation job (§6): rebuild a whole tenant, log the diff, return it.
103
+ -- Runs after every authz migration (source='migration'), nightly per tenant
104
+ -- (source='scheduled'), on demand (source='manual'). A non-zero diff outside a
105
+ -- migration is an incident: v_authz_reconciliation_alerts (043) surfaces it.
106
+ CREATE OR REPLACE FUNCTION app.recompute_user_unit_access(p_tenant uuid, p_source text DEFAULT 'manual')
107
+ RETURNS TABLE (added integer, removed integer)
108
+ LANGUAGE plpgsql SECURITY DEFINER
109
+ SET search_path = ''
110
+ AS $$
111
+ DECLARE
112
+ v_added integer := 0;
113
+ v_removed integer := 0;
114
+ v_details jsonb := '[]'::jsonb;
115
+ r record;
116
+ BEGIN
117
+ IF p_source NOT IN ('migration', 'scheduled', 'manual', 'test') THEN
118
+ RAISE EXCEPTION 'recompute_user_unit_access: unknown source %', p_source;
119
+ END IF;
120
+
121
+ -- Rows for users who no longer hold a membership at all cannot exist (FK
122
+ -- cascade); rows for members are rebuilt one by one.
123
+ FOR r IN SELECT m.user_id FROM app.memberships m WHERE m.tenant_id = p_tenant LOOP
124
+ DECLARE a integer; d integer;
125
+ BEGIN
126
+ SELECT ra.added, ra.removed INTO a, d FROM app.refresh_user_unit_access(p_tenant, r.user_id) ra;
127
+ IF a <> 0 OR d <> 0 THEN
128
+ v_details := v_details || jsonb_build_object('user_id', r.user_id, 'added', a, 'removed', d);
129
+ END IF;
130
+ v_added := v_added + a;
131
+ v_removed := v_removed + d;
132
+ END;
133
+ END LOOP;
134
+
135
+ INSERT INTO app.authz_reconciliation_log (tenant_id, source, added, removed, details)
136
+ VALUES (p_tenant, p_source, v_added, v_removed, v_details);
137
+
138
+ added := v_added; removed := v_removed;
139
+ RETURN NEXT;
140
+ END $$;
141
+
142
+ -- Every tenant (used by migrations touching authz tables, and by the bench).
143
+ CREATE OR REPLACE FUNCTION app.recompute_all_user_unit_access(p_source text DEFAULT 'migration')
144
+ RETURNS TABLE (tenant_id uuid, added integer, removed integer)
145
+ LANGUAGE plpgsql SECURITY DEFINER
146
+ SET search_path = ''
147
+ AS $$
148
+ DECLARE r record; a integer; d integer;
149
+ BEGIN
150
+ FOR r IN SELECT t.id FROM public.tenants t LOOP
151
+ SELECT x.added, x.removed INTO a, d FROM app.recompute_user_unit_access(r.id, p_source) x;
152
+ tenant_id := r.id; added := a; removed := d;
153
+ RETURN NEXT;
154
+ END LOOP;
155
+ END $$;
156
+
157
+ REVOKE ALL ON FUNCTION app.compute_user_unit_access(uuid, uuid) FROM public;
158
+ REVOKE ALL ON FUNCTION app.refresh_user_unit_access(uuid, uuid) FROM public;
159
+ REVOKE ALL ON FUNCTION app.recompute_user_unit_access(uuid, text) FROM public;
160
+ REVOKE ALL ON FUNCTION app.recompute_all_user_unit_access(text) FROM public;
161
+ GRANT EXECUTE ON FUNCTION app.recompute_user_unit_access(uuid, text) TO service_role;
162
+ GRANT EXECUTE ON FUNCTION app.recompute_all_user_unit_access(text) TO service_role;
163
+ -- An admin may reconcile her own tenant on demand (the function still needs the
164
+ -- membership: it is gated inside the wrapper below).
165
+ CREATE OR REPLACE FUNCTION public.authz_reconcile_my_tenant()
166
+ RETURNS TABLE (added integer, removed integer)
167
+ LANGUAGE plpgsql SECURITY DEFINER
168
+ SET search_path = ''
169
+ AS $$
170
+ DECLARE v_tenant uuid := app.current_tenant_id();
171
+ BEGIN
172
+ IF v_tenant IS NULL OR NOT app.has_permission('members.manage') THEN
173
+ RAISE EXCEPTION 'authz_reconcile_my_tenant: not allowed' USING ERRCODE = '42501';
174
+ END IF;
175
+ RETURN QUERY SELECT x.added, x.removed FROM app.recompute_user_unit_access(v_tenant, 'manual') x;
176
+ END $$;
177
+ REVOKE ALL ON FUNCTION public.authz_reconcile_my_tenant() FROM public, anon;
178
+ GRANT EXECUTE ON FUNCTION public.authz_reconcile_my_tenant() TO authenticated, service_role;
179
+
180
+ -- ═════════════════════════════ triggers ═══════════════════════════════════
181
+
182
+ -- F1 — membership_units
183
+ CREATE OR REPLACE FUNCTION app.trg_membership_units_fanout()
184
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
185
+ BEGIN
186
+ IF TG_OP IN ('INSERT', 'UPDATE') THEN
187
+ PERFORM app.refresh_user_unit_access(NEW.tenant_id, NEW.user_id);
188
+ END IF;
189
+ IF TG_OP IN ('DELETE', 'UPDATE') AND (TG_OP = 'DELETE' OR OLD.user_id <> NEW.user_id OR OLD.tenant_id <> NEW.tenant_id) THEN
190
+ PERFORM app.refresh_user_unit_access(OLD.tenant_id, OLD.user_id);
191
+ END IF;
192
+ RETURN NULL;
193
+ END $$;
194
+ DROP TRIGGER IF EXISTS membership_units_fanout ON app.membership_units;
195
+ CREATE TRIGGER membership_units_fanout
196
+ AFTER INSERT OR UPDATE OR DELETE ON app.membership_units
197
+ FOR EACH ROW EXECUTE FUNCTION app.trg_membership_units_fanout();
198
+
199
+ -- F2 — memberships (insert / all_units toggle / activate / deactivate / delete)
200
+ CREATE OR REPLACE FUNCTION app.trg_memberships_fanout()
201
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
202
+ BEGIN
203
+ IF TG_OP = 'DELETE' THEN
204
+ -- rows go with the FK cascade; nothing to rebuild
205
+ RETURN NULL;
206
+ END IF;
207
+ IF TG_OP = 'INSERT' OR OLD.all_units IS DISTINCT FROM NEW.all_units OR OLD.active IS DISTINCT FROM NEW.active THEN
208
+ PERFORM app.refresh_user_unit_access(NEW.tenant_id, NEW.user_id);
209
+ END IF;
210
+ RETURN NULL;
211
+ END $$;
212
+ DROP TRIGGER IF EXISTS memberships_fanout ON app.memberships;
213
+ CREATE TRIGGER memberships_fanout
214
+ AFTER INSERT OR UPDATE OR DELETE ON app.memberships
215
+ FOR EACH ROW EXECUTE FUNCTION app.trg_memberships_fanout();
216
+
217
+ -- F3 + F4 — units. On insert of an active unit: every all_units member gets it,
218
+ -- and every user with explicit access to an ancestor gets it via subtree — the
219
+ -- classic forgotten case (franchisor opens a branch). On a parent move or an
220
+ -- active flip: rebuild every user whose scope can depend on the tree.
221
+ CREATE OR REPLACE FUNCTION app.trg_units_fanout()
222
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
223
+ DECLARE r record;
224
+ BEGIN
225
+ IF TG_OP = 'INSERT' AND NOT NEW.active THEN
226
+ RETURN NULL;
227
+ END IF;
228
+ IF TG_OP = 'UPDATE'
229
+ AND OLD.parent_unit_id IS NOT DISTINCT FROM NEW.parent_unit_id
230
+ AND OLD.active IS NOT DISTINCT FROM NEW.active THEN
231
+ RETURN NULL; -- a rename does not change scope
232
+ END IF;
233
+ -- Affected: all_units members + anyone holding explicit unit access in the
234
+ -- tenant (their subtree may have gained/lost this unit). Members with no
235
+ -- unit access at all cannot be affected.
236
+ FOR r IN
237
+ SELECT DISTINCT m.user_id
238
+ FROM app.memberships m
239
+ WHERE m.tenant_id = NEW.tenant_id
240
+ AND (m.all_units OR EXISTS (SELECT 1 FROM app.membership_units mu WHERE mu.tenant_id = m.tenant_id AND mu.user_id = m.user_id))
241
+ LOOP
242
+ PERFORM app.refresh_user_unit_access(NEW.tenant_id, r.user_id);
243
+ END LOOP;
244
+ RETURN NULL;
245
+ END $$;
246
+ DROP TRIGGER IF EXISTS units_fanout ON app.units;
247
+ CREATE TRIGGER units_fanout
248
+ AFTER INSERT OR UPDATE ON app.units
249
+ FOR EACH ROW EXECUTE FUNCTION app.trg_units_fanout();
250
+
251
+ -- Bring every tenant's share table in line with this migration (a no-op on a
252
+ -- fresh database; on a converted one it is the first reconciliation).
253
+ SELECT app.recompute_all_user_unit_access('migration');