@fayz-ai/db 0.11.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 (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -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');
@@ -0,0 +1,402 @@
1
+ -- ============================================================================
2
+ -- 053_authz_catalog_templates.sql — the platform permission catalog, the owner
3
+ -- role that never lags the catalog, vertical role templates, and the business
4
+ -- table scaffold (§4/§7 template as one idempotent function).
5
+ --
6
+ -- A new access rule is a row, not a migration (C6): permissions are seeded by
7
+ -- app.seed_permissions(jsonb) — the platform here, each plugin from its
8
+ -- declaredFeatures (#136) — and role templates are rows in app.role_templates
9
+ -- that app.seed_role_template(tenant, template) instantiates as system roles.
10
+ -- ============================================================================
11
+
12
+ -- ── seed_permissions(jsonb) ─────────────────────────────────────────────
13
+ -- [{key, plugin, description}] upserted. Called by the platform (below) and by
14
+ -- plugin migrations. Idempotent; never removes a key (removal is a decision).
15
+ CREATE OR REPLACE FUNCTION app.seed_permissions(p_rows jsonb)
16
+ RETURNS integer
17
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
18
+ AS $$
19
+ DECLARE v_n integer;
20
+ BEGIN
21
+ WITH ins AS (
22
+ INSERT INTO app.permissions (key, plugin, description)
23
+ SELECT r->>'key', r->>'plugin', r->>'description'
24
+ FROM jsonb_array_elements(p_rows) r
25
+ ON CONFLICT (key) DO UPDATE SET plugin = EXCLUDED.plugin, description = coalesce(EXCLUDED.description, app.permissions.description)
26
+ RETURNING 1
27
+ ) SELECT count(*) INTO v_n FROM ins;
28
+ RETURN v_n;
29
+ END $$;
30
+ REVOKE ALL ON FUNCTION app.seed_permissions(jsonb) FROM public, anon, authenticated;
31
+ GRANT EXECUTE ON FUNCTION app.seed_permissions(jsonb) TO service_role;
32
+
33
+ -- ── the owner role never lags the catalog ───────────────────────────────
34
+ -- "Owner" is a role with a permission set, not a bypass. So that a plugin
35
+ -- installed tomorrow does not leave every owner without its keys, a new catalog
36
+ -- key is granted to every tenant's system `owner` role on insert.
37
+ CREATE OR REPLACE FUNCTION app.trg_permissions_grant_owner()
38
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
39
+ BEGIN
40
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
41
+ SELECT r.tenant_id, r.id, NEW.key
42
+ FROM app.roles r
43
+ WHERE r.is_system AND r.key = 'owner'
44
+ ON CONFLICT DO NOTHING;
45
+ RETURN NULL;
46
+ END $$;
47
+ DROP TRIGGER IF EXISTS permissions_grant_owner ON app.permissions;
48
+ CREATE TRIGGER permissions_grant_owner
49
+ AFTER INSERT ON app.permissions
50
+ FOR EACH ROW EXECUTE FUNCTION app.trg_permissions_grant_owner();
51
+
52
+ -- ── platform catalog (core-A keys) ──────────────────────────────────────
53
+ -- '{feature}.{action}' with actions read/create/edit/delete/manage(+read_all
54
+ -- where owner scoping exists). Plugins add theirs (#136 seeds declaredFeatures).
55
+ SELECT app.seed_permissions('[
56
+ {"key":"tenant.read","plugin":"platform","description":"See the tenant"},
57
+ {"key":"tenant.manage","plugin":"platform","description":"Change tenant settings, name, plan"},
58
+ {"key":"units.read","plugin":"platform","description":"List units"},
59
+ {"key":"units.manage","plugin":"platform","description":"Create, move, deactivate units"},
60
+ {"key":"members.read","plugin":"platform","description":"See who is a member and where"},
61
+ {"key":"members.manage","plugin":"platform","description":"Invite, change role/units, deactivate members"},
62
+ {"key":"roles.read","plugin":"platform","description":"See roles and their permissions"},
63
+ {"key":"roles.manage","plugin":"platform","description":"Create and edit tenant roles"},
64
+ {"key":"authz.grants.manage","plugin":"platform","description":"Write resource grants for any resource type"},
65
+ {"key":"settings.read","plugin":"platform","description":"Read tenant settings"},
66
+ {"key":"settings.manage","plugin":"platform","description":"Change tenant settings"},
67
+ {"key":"billing.manage","plugin":"platform","description":"Plans and payment"},
68
+ {"key":"audit.read","plugin":"platform","description":"Read the audit log"},
69
+ {"key":"reports.read","plugin":"reports","description":"Read reports"},
70
+
71
+ {"key":"people.read","plugin":"people","description":"Read people in scope"},
72
+ {"key":"people.read_all","plugin":"people","description":"Read every person of the unit regardless of owner"},
73
+ {"key":"people.create","plugin":"people"},{"key":"people.edit","plugin":"people"},{"key":"people.delete","plugin":"people"},{"key":"people.manage","plugin":"people"},
74
+
75
+ {"key":"catalog.read","plugin":"catalog"},{"key":"catalog.create","plugin":"catalog"},{"key":"catalog.edit","plugin":"catalog"},{"key":"catalog.delete","plugin":"catalog"},{"key":"catalog.manage","plugin":"catalog"},
76
+
77
+ {"key":"orders.read","plugin":"orders"},{"key":"orders.read_all","plugin":"orders"},{"key":"orders.create","plugin":"orders"},{"key":"orders.edit","plugin":"orders"},{"key":"orders.delete","plugin":"orders"},{"key":"orders.manage","plugin":"orders"},
78
+
79
+ {"key":"agenda.read","plugin":"agenda"},{"key":"agenda.read_all","plugin":"agenda"},{"key":"agenda.create","plugin":"agenda"},{"key":"agenda.edit","plugin":"agenda"},{"key":"agenda.delete","plugin":"agenda"},{"key":"agenda.manage","plugin":"agenda"},
80
+
81
+ {"key":"financial.read","plugin":"financial"},{"key":"financial.read_all","plugin":"financial"},{"key":"financial.create","plugin":"financial"},{"key":"financial.edit","plugin":"financial"},{"key":"financial.delete","plugin":"financial"},{"key":"financial.manage","plugin":"financial"},
82
+ {"key":"financial.cash_session","plugin":"financial","description":"Open/close a cash session (caixinha)"},
83
+
84
+ {"key":"inventory.read","plugin":"inventory"},{"key":"inventory.create","plugin":"inventory"},{"key":"inventory.edit","plugin":"inventory"},{"key":"inventory.delete","plugin":"inventory"},{"key":"inventory.manage","plugin":"inventory"}
85
+ ]'::jsonb);
86
+
87
+ -- ── role templates (system roles per vertical) ──────────────────────────
88
+ -- Rows, not code: (template, role_key, role_name, permission). The `owner`
89
+ -- role of every template holds the whole catalog (granted at seed time and kept
90
+ -- current by the trigger above), so it needs no rows here.
91
+ CREATE TABLE IF NOT EXISTS app.role_templates (
92
+ template text NOT NULL,
93
+ role_key text NOT NULL,
94
+ role_name text NOT NULL,
95
+ sort_order integer NOT NULL DEFAULT 0,
96
+ permission text NOT NULL REFERENCES app.permissions(key) ON DELETE CASCADE,
97
+ PRIMARY KEY (template, role_key, permission)
98
+ );
99
+ ALTER TABLE app.role_templates ENABLE ROW LEVEL SECURITY;
100
+ GRANT SELECT ON app.role_templates TO authenticated;
101
+ DROP POLICY IF EXISTS role_templates_select ON app.role_templates;
102
+ CREATE POLICY role_templates_select ON app.role_templates FOR SELECT TO authenticated USING (true);
103
+
104
+ CREATE OR REPLACE FUNCTION app.seed_role_template_rows(p_template text, p_role_key text, p_role_name text, p_sort integer, p_perms text[])
105
+ RETURNS void LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$
106
+ INSERT INTO app.role_templates (template, role_key, role_name, sort_order, permission)
107
+ SELECT p_template, p_role_key, p_role_name, p_sort, unnest(p_perms)
108
+ ON CONFLICT DO NOTHING;
109
+ $$;
110
+ REVOKE ALL ON FUNCTION app.seed_role_template_rows(text, text, text, integer, text[]) FROM public, anon, authenticated;
111
+
112
+ -- generic — the SDK's historical five (owner/admin/manager/staff/viewer), the
113
+ -- target of the tenant_members role mapping in #134.
114
+ SELECT app.seed_role_template_rows('generic', 'admin', 'Administrator', 1, ARRAY[
115
+ 'tenant.read','units.read','units.manage','members.read','members.manage','roles.read','roles.manage','settings.read','settings.manage','audit.read','reports.read',
116
+ 'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
117
+ SELECT app.seed_role_template_rows('generic', 'manager', 'Manager', 2, ARRAY[
118
+ 'tenant.read','units.read','members.read','roles.read','settings.read','reports.read',
119
+ 'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
120
+ SELECT app.seed_role_template_rows('generic', 'staff', 'Staff', 3, ARRAY[
121
+ 'tenant.read','units.read','people.read','people.create','people.edit','catalog.read','orders.read','orders.create','orders.edit','agenda.read','agenda.create','agenda.edit','inventory.read']);
122
+ SELECT app.seed_role_template_rows('generic', 'viewer', 'Viewer', 4, ARRAY[
123
+ 'tenant.read','units.read','people.read','catalog.read','orders.read','agenda.read','inventory.read','financial.read']);
124
+
125
+ -- salon (owner_id off in the vertical template)
126
+ SELECT app.seed_role_template_rows('salon', 'manager', 'Gerente', 1, ARRAY[
127
+ 'tenant.read','units.read','members.read','members.manage','roles.read','settings.read','reports.read','audit.read',
128
+ 'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
129
+ SELECT app.seed_role_template_rows('salon', 'reception', 'Recepção', 2, ARRAY[
130
+ 'tenant.read','units.read','people.read','people.create','people.edit','catalog.read','orders.read','orders.create','orders.edit','agenda.read','agenda.create','agenda.edit']);
131
+ SELECT app.seed_role_template_rows('salon', 'professional', 'Profissional', 3, ARRAY[
132
+ 'tenant.read','units.read','people.read','catalog.read','orders.read','agenda.read','agenda.edit']);
133
+ SELECT app.seed_role_template_rows('salon', 'financial', 'Financeiro', 4, ARRAY[
134
+ 'tenant.read','units.read','people.read','orders.read','reports.read','financial.manage']);
135
+
136
+ -- clinic (owner_id on: professionals see their own patients; manager read_all)
137
+ SELECT app.seed_role_template_rows('clinic', 'manager', 'Coordenação', 1, ARRAY[
138
+ 'tenant.read','units.read','members.read','members.manage','roles.read','settings.read','reports.read','audit.read',
139
+ 'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
140
+ SELECT app.seed_role_template_rows('clinic', 'reception', 'Recepção', 2, ARRAY[
141
+ 'tenant.read','units.read','people.read','people.create','people.edit','catalog.read','orders.read','orders.create','orders.edit','agenda.read','agenda.create','agenda.edit']);
142
+ SELECT app.seed_role_template_rows('clinic', 'professional', 'Profissional', 3, ARRAY[
143
+ 'tenant.read','units.read','people.read','people.edit','catalog.read','orders.read','agenda.read','agenda.edit']);
144
+ SELECT app.seed_role_template_rows('clinic', 'financial', 'Financeiro', 4, ARRAY[
145
+ 'tenant.read','units.read','people.read','orders.read','reports.read','financial.manage']);
146
+
147
+ -- restaurant
148
+ SELECT app.seed_role_template_rows('restaurant', 'manager', 'Gerente', 1, ARRAY[
149
+ 'tenant.read','units.read','members.read','members.manage','roles.read','settings.read','reports.read','audit.read',
150
+ 'people.manage','catalog.manage','orders.manage','financial.manage','inventory.manage']);
151
+ SELECT app.seed_role_template_rows('restaurant', 'cashier', 'Caixa', 2, ARRAY[
152
+ 'tenant.read','units.read','people.read','catalog.read','orders.read','orders.edit','financial.read','financial.create','financial.cash_session']);
153
+ SELECT app.seed_role_template_rows('restaurant', 'waiter', 'Garçom', 3, ARRAY[
154
+ 'tenant.read','units.read','people.read','people.create','catalog.read','orders.read','orders.create','orders.edit']);
155
+ SELECT app.seed_role_template_rows('restaurant', 'kitchen', 'Cozinha', 4, ARRAY[
156
+ 'tenant.read','units.read','catalog.read','orders.read','orders.edit','inventory.read']);
157
+
158
+ -- ── seed_role_template(tenant, template) ────────────────────────────────
159
+ -- Instantiates the template's system roles for a tenant (owner + the rows
160
+ -- above). Idempotent: existing roles are kept, missing permissions added,
161
+ -- nothing removed (a tenant may have tightened a system role on purpose).
162
+ -- Returns the tenant's owner role id.
163
+ CREATE OR REPLACE FUNCTION app.seed_role_template(p_tenant uuid, p_template text DEFAULT 'generic')
164
+ RETURNS uuid
165
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
166
+ AS $$
167
+ DECLARE v_owner uuid;
168
+ BEGIN
169
+ IF NOT EXISTS (SELECT 1 FROM app.role_templates WHERE template = p_template) THEN
170
+ RAISE EXCEPTION 'seed_role_template: unknown template %', p_template;
171
+ END IF;
172
+
173
+ INSERT INTO app.roles (tenant_id, key, name, is_system)
174
+ VALUES (p_tenant, 'owner', 'Owner', true)
175
+ ON CONFLICT (tenant_id, key) DO NOTHING;
176
+ SELECT id INTO v_owner FROM app.roles WHERE tenant_id = p_tenant AND key = 'owner';
177
+
178
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
179
+ SELECT p_tenant, v_owner, p.key FROM app.permissions p
180
+ ON CONFLICT DO NOTHING;
181
+
182
+ INSERT INTO app.roles (tenant_id, key, name, is_system)
183
+ SELECT DISTINCT ON (t.role_key) p_tenant, t.role_key, t.role_name, true
184
+ FROM app.role_templates t WHERE t.template = p_template
185
+ ORDER BY t.role_key
186
+ ON CONFLICT (tenant_id, key) DO NOTHING;
187
+
188
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
189
+ SELECT p_tenant, r.id, t.permission
190
+ FROM app.role_templates t
191
+ JOIN app.roles r ON r.tenant_id = p_tenant AND r.key = t.role_key
192
+ WHERE t.template = p_template
193
+ ON CONFLICT DO NOTHING;
194
+
195
+ RETURN v_owner;
196
+ END $$;
197
+ REVOKE ALL ON FUNCTION app.seed_role_template(uuid, text) FROM public, anon, authenticated;
198
+ GRANT EXECUTE ON FUNCTION app.seed_role_template(uuid, text) TO service_role;
199
+
200
+ -- ═════════════════════ business-table scaffold (§4 + §7) ═════════════════
201
+ -- app.scaffold_table(table, resource_type, plugin, owner_scoping, mode)
202
+ --
203
+ -- Stamps a table with the three structural columns, unique(tenant_id, id),
204
+ -- the composite FKs, tenant-first indexes, enable+force RLS, the updated_at
205
+ -- trigger and the ONE policy template every business table gets:
206
+ --
207
+ -- select tenant = current ∧ (unit null ∨ has_unit) ∧ (owner null ∨ owner = me ∨
208
+ -- shared_with grant ∨ {plugin}.read_all) ∧ {plugin}.read
209
+ -- insert tenant/unit as above ∧ {plugin}.create
210
+ -- update select-scope ∧ {plugin}.edit (using + with check)
211
+ -- delete select-scope ∧ {plugin}.delete
212
+ --
213
+ -- Vertical differences are configuration: the policy text is identical whether
214
+ -- the vertical fills owner_id (clinic) or leaves it null (salon); owner_scoping
215
+ -- is recorded so a template can say which. mode='enforce' replaces every
216
+ -- pre-existing policy on the table with the template; mode='shadow' keeps them
217
+ -- and adds the template ones (permissive policies OR together, so shadow adds no
218
+ -- restriction) — app.shadow_report (#135) then compares old vs new decisions.
219
+ CREATE TABLE IF NOT EXISTS app.scaffold_registry (
220
+ table_schema text NOT NULL,
221
+ table_name text NOT NULL,
222
+ resource_type text NOT NULL,
223
+ plugin text NOT NULL,
224
+ owner_scoping boolean NOT NULL DEFAULT true,
225
+ mode text NOT NULL CHECK (mode IN ('shadow', 'enforce')),
226
+ previous_policies text[] NOT NULL DEFAULT '{}',
227
+ applied_at timestamptz NOT NULL DEFAULT now(),
228
+ PRIMARY KEY (table_schema, table_name)
229
+ );
230
+ ALTER TABLE app.scaffold_registry ENABLE ROW LEVEL SECURITY;
231
+ GRANT SELECT ON app.scaffold_registry TO authenticated;
232
+ DROP POLICY IF EXISTS scaffold_registry_select ON app.scaffold_registry;
233
+ CREATE POLICY scaffold_registry_select ON app.scaffold_registry FOR SELECT TO authenticated USING (true);
234
+
235
+ CREATE OR REPLACE FUNCTION app.scaffold_table(
236
+ p_table regclass,
237
+ p_resource_type text,
238
+ p_plugin text,
239
+ p_owner_scoping boolean DEFAULT true,
240
+ p_mode text DEFAULT 'enforce'
241
+ )
242
+ RETURNS void
243
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
244
+ AS $$
245
+ DECLARE
246
+ v_schema text;
247
+ v_name text;
248
+ v_q text; -- quoted qualified name
249
+ v_prev text[] := '{}';
250
+ v_rows bigint;
251
+ r record;
252
+ v_scope text; -- the row-scope predicate shared by every policy
253
+ BEGIN
254
+ IF p_mode NOT IN ('shadow', 'enforce') THEN
255
+ RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
256
+ END IF;
257
+ SELECT n.nspname, c.relname INTO v_schema, v_name
258
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
259
+ v_q := format('%I.%I', v_schema, v_name);
260
+
261
+ -- ── columns
262
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
263
+ RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
264
+ END IF;
265
+ EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
266
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'tenant_id') THEN
267
+ EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
268
+ IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
269
+ END IF;
270
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
271
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
272
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
273
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
274
+
275
+ -- ── unique(tenant_id, id) — enables composite FKs from other tables
276
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
277
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
278
+ END IF;
279
+ -- ── composite FKs: unit within tenant, owner a member of the tenant
280
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
281
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL', v_q, v_name || '_unit_fk');
282
+ END IF;
283
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
284
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL', v_q, v_name || '_owner_fk');
285
+ END IF;
286
+
287
+ -- ── tenant-first indexes
288
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
289
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, owner_id) WHERE owner_id IS NOT NULL', v_name || '_tenant_owner_idx', v_q);
290
+
291
+ -- ── updated_at
292
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
293
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
294
+
295
+ -- ── RLS
296
+ EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
297
+ EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
298
+ IF p_mode = 'enforce' THEN
299
+ FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
300
+ AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
301
+ v_prev := v_prev || r.policyname;
302
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
303
+ END LOOP;
304
+ END IF;
305
+
306
+ v_scope := format(
307
+ 'tenant_id = (select app.current_tenant_id())'
308
+ || ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))'
309
+ || ' AND (owner_id IS NULL OR owner_id = (select auth.uid())'
310
+ || ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
311
+ || ' OR (select app.has_permission(%L, unit_id)))',
312
+ p_resource_type, p_plugin || '.read_all');
313
+
314
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
315
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
316
+ v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
317
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
318
+ EXECUTE format('CREATE POLICY %I ON %s FOR INSERT TO authenticated WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
319
+ v_name || '_authz_insert', v_q, p_plugin || '.create');
320
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
321
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
322
+ v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
323
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
324
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
325
+ v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
326
+
327
+ INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies)
328
+ VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev)
329
+ ON CONFLICT (table_schema, table_name) DO UPDATE
330
+ SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
331
+ mode = EXCLUDED.mode, applied_at = now(),
332
+ previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
333
+ THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
334
+ ELSE EXCLUDED.previous_policies END;
335
+ END $$;
336
+ REVOKE ALL ON FUNCTION app.scaffold_table(regclass, text, text, boolean, text) FROM public, anon, authenticated;
337
+
338
+ -- ═════════════════════ read views for the UI (public.v_authz_*) ═══════════
339
+ -- security_invoker: the caller's RLS on the app tables decides what she sees.
340
+ CREATE OR REPLACE VIEW public.v_authz_units WITH (security_invoker = true) AS
341
+ SELECT tenant_id, id, kind, parent_unit_id, name, slug, active, company_legal_entity_id, metadata, created_at, updated_at FROM app.units;
342
+ CREATE OR REPLACE VIEW public.v_authz_roles WITH (security_invoker = true) AS
343
+ SELECT tenant_id, id, key, name, description, is_system, created_at, updated_at FROM app.roles;
344
+ CREATE OR REPLACE VIEW public.v_authz_permissions WITH (security_invoker = true) AS
345
+ SELECT key, plugin, description FROM app.permissions;
346
+ CREATE OR REPLACE VIEW public.v_authz_role_permissions WITH (security_invoker = true) AS
347
+ SELECT tenant_id, role_id, permission FROM app.role_permissions;
348
+ CREATE OR REPLACE VIEW public.v_authz_memberships WITH (security_invoker = true) AS
349
+ SELECT tenant_id, user_id, id, role_id, all_units, active, person_id, invited_by, created_at, updated_at FROM app.memberships;
350
+ CREATE OR REPLACE VIEW public.v_authz_membership_units WITH (security_invoker = true) AS
351
+ SELECT tenant_id, user_id, unit_id, role_override_id, created_at FROM app.membership_units;
352
+ CREATE OR REPLACE VIEW public.v_authz_user_unit_access WITH (security_invoker = true) AS
353
+ SELECT tenant_id, user_id, unit_id, via FROM app.user_unit_access;
354
+ CREATE OR REPLACE VIEW public.v_authz_resource_grants WITH (security_invoker = true) AS
355
+ SELECT tenant_id, resource_type, resource_id, relation, subject_type, subject_id, created_by, created_at FROM app.resource_grants;
356
+ CREATE OR REPLACE VIEW public.v_authz_role_templates WITH (security_invoker = true) AS
357
+ SELECT template, role_key, role_name, sort_order, permission FROM app.role_templates;
358
+ -- Reconciliation runs whose diff was not zero outside a migration: the alert.
359
+ CREATE OR REPLACE VIEW public.v_authz_reconciliation_alerts WITH (security_invoker = true) AS
360
+ SELECT id, tenant_id, run_at, source, added, removed, details
361
+ FROM app.authz_reconciliation_log
362
+ WHERE source <> 'migration' AND (added <> 0 OR removed <> 0);
363
+ GRANT SELECT ON public.v_authz_units, public.v_authz_roles, public.v_authz_permissions, public.v_authz_role_permissions,
364
+ public.v_authz_memberships, public.v_authz_membership_units, public.v_authz_user_unit_access,
365
+ public.v_authz_resource_grants, public.v_authz_role_templates, public.v_authz_reconciliation_alerts
366
+ TO authenticated, service_role;
367
+ -- The simple views over one table are auto-updatable: writes through them hit
368
+ -- the write policies of 041 (units/roles/role_permissions/memberships/units/grants).
369
+ GRANT INSERT, UPDATE, DELETE ON public.v_authz_units, public.v_authz_roles, public.v_authz_role_permissions,
370
+ public.v_authz_memberships, public.v_authz_membership_units, public.v_authz_resource_grants
371
+ TO authenticated, service_role;
372
+
373
+ -- ── the session's authz context, once per session per unit (§8) ─────────
374
+ CREATE OR REPLACE FUNCTION public.authz_my_permissions(p_unit uuid DEFAULT NULL)
375
+ RETURNS SETOF text
376
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
377
+ AS $$
378
+ SELECT rp.permission
379
+ FROM app.role_permissions rp
380
+ WHERE rp.tenant_id = app.current_tenant_id()
381
+ AND rp.role_id = app.effective_role_id(app.current_tenant_id(), auth.uid(), p_unit)
382
+ ORDER BY 1;
383
+ $$;
384
+ CREATE OR REPLACE FUNCTION public.authz_context()
385
+ RETURNS jsonb
386
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
387
+ AS $$
388
+ SELECT jsonb_build_object(
389
+ 'tenant_id', app.current_tenant_id(),
390
+ 'user_id', auth.uid(),
391
+ 'role_key', (SELECT r.key FROM app.memberships m JOIN app.roles r ON r.tenant_id = m.tenant_id AND r.id = m.role_id
392
+ WHERE m.tenant_id = app.current_tenant_id() AND m.user_id = auth.uid() AND m.active),
393
+ 'all_units', (SELECT m.all_units FROM app.memberships m WHERE m.tenant_id = app.current_tenant_id() AND m.user_id = auth.uid() AND m.active),
394
+ 'units', coalesce((SELECT jsonb_agg(jsonb_build_object('unit_id', a.unit_id, 'via', a.via) ORDER BY a.unit_id)
395
+ FROM app.user_unit_access a WHERE a.tenant_id = app.current_tenant_id() AND a.user_id = auth.uid()), '[]'::jsonb),
396
+ 'permissions', coalesce((SELECT jsonb_agg(p ORDER BY p) FROM public.authz_my_permissions(NULL) p), '[]'::jsonb)
397
+ );
398
+ $$;
399
+ REVOKE ALL ON FUNCTION public.authz_my_permissions(uuid) FROM public, anon;
400
+ REVOKE ALL ON FUNCTION public.authz_context() FROM public, anon;
401
+ GRANT EXECUTE ON FUNCTION public.authz_my_permissions(uuid) TO authenticated, service_role;
402
+ GRANT EXECUTE ON FUNCTION public.authz_context() TO authenticated, service_role;