@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,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;
@@ -0,0 +1,132 @@
1
+ -- ============================================================================
2
+ -- 060_tenancy_seam.sql — the tenancy seam moves onto the authz core (#134).
3
+ --
4
+ -- Three functions the whole fleet's RLS resolves through are re-authored to read
5
+ -- app.memberships instead of the legacy public.tenant_members:
6
+ --
7
+ -- public.user_tenant_ids() the seam of ~260 policies (028): same signature,
8
+ -- same claim semantics, new source table.
9
+ -- public.is_tenant_admin(uuid) the legacy "owner|admin" bypass. It is now a
10
+ -- compat shim over permissions: TRUE only when the
11
+ -- caller's active membership holds `tenant.manage`.
12
+ -- No role literal survives in its body. Deprecated:
13
+ -- policies of 001/003/021 still call it; new code
14
+ -- uses app.has_permission(...).
15
+ -- public.shares_tenant_with(uuid) (023) profiles visibility, same rule on app.*.
16
+ --
17
+ -- public.tenants gains `role_template` (which vertical template seeded its system
18
+ -- roles — needed to re-seed a tenant when a template grows keys) and loses the
19
+ -- open `tenants_insert` policy: a tenant is created by public.create_tenant (051),
20
+ -- never by a bare INSERT.
21
+ --
22
+ -- Idempotent and replay-safe: CREATE OR REPLACE keeps the functions' oids,
23
+ -- ownership and grants (every existing policy keeps compiling); the policy is
24
+ -- DROP IF EXISTS + CREATE. On a force replay 001/002/006/028 re-create the old
25
+ -- bodies first and this file re-authors them again — the chain converges.
26
+ -- ============================================================================
27
+
28
+ ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS role_template text;
29
+ -- The synthetic seed records its template in settings; real tenants created by
30
+ -- create_tenant record it in the column. Backfill the one from the other.
31
+ UPDATE public.tenants
32
+ SET role_template = settings ->> 'template'
33
+ WHERE role_template IS NULL
34
+ AND settings ->> 'template' IS NOT NULL
35
+ AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = public.tenants.settings ->> 'template');
36
+
37
+ -- ── public.user_tenant_ids() ────────────────────────────────────────────
38
+ -- Structure copied from 028 (read its header for the reasoning): a top-level
39
+ -- `tenant_id` claim NARROWS and never widens; a signed-in session is intersected
40
+ -- with real (active) membership; a session-less signed token is scoped by the
41
+ -- claim alone; a present-but-unparseable claim resolves to nothing; no claim ⇒
42
+ -- every active membership. Only the source table changes.
43
+ CREATE OR REPLACE FUNCTION public.user_tenant_ids()
44
+ RETURNS SETOF uuid
45
+ LANGUAGE sql STABLE SECURITY DEFINER
46
+ SET search_path = ''
47
+ AS $$
48
+ WITH claim AS (
49
+ SELECT coalesce(jsonb_exists(auth.jwt(), 'tenant_id'), false) AS present,
50
+ auth.jwt() ->> 'tenant_id' AS raw
51
+ ),
52
+ scoped AS (
53
+ SELECT raw::uuid AS tenant_id
54
+ FROM claim
55
+ WHERE raw ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
56
+ )
57
+ SELECT m.tenant_id
58
+ FROM app.memberships m
59
+ WHERE m.user_id = auth.uid()
60
+ AND m.active
61
+ AND NOT (SELECT present FROM claim)
62
+ UNION
63
+ SELECT s.tenant_id
64
+ FROM scoped s
65
+ WHERE auth.uid() IS NULL
66
+ OR EXISTS (
67
+ SELECT 1 FROM app.memberships m
68
+ WHERE m.user_id = auth.uid() AND m.tenant_id = s.tenant_id AND m.active
69
+ );
70
+ $$;
71
+ -- Same grant set as 028 (PUBLIC keeps its default EXECUTE on purpose: RLS
72
+ -- expressions run as the querying role and anon storefront reads must resolve
73
+ -- to zero rows, not to "permission denied for function").
74
+ GRANT EXECUTE ON FUNCTION public.user_tenant_ids() TO authenticated, service_role;
75
+ DO $$
76
+ BEGIN
77
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_connector') THEN
78
+ EXECUTE 'GRANT EXECUTE ON FUNCTION public.user_tenant_ids() TO fayz_connector';
79
+ END IF;
80
+ END $$;
81
+
82
+ -- ── public.is_tenant_admin(uuid) — compat shim over permissions ─────────
83
+ -- No role literal: the answer is "does the caller's active membership in that
84
+ -- tenant hold tenant.manage" (the base role; unit overrides do not apply to a
85
+ -- tenant-level question). Callers pass the tenant, so it is used verbatim
86
+ -- rather than app.current_tenant_id().
87
+ CREATE OR REPLACE FUNCTION public.is_tenant_admin(p_tenant_id uuid)
88
+ RETURNS boolean
89
+ LANGUAGE sql STABLE SECURITY DEFINER
90
+ SET search_path = ''
91
+ AS $$
92
+ SELECT EXISTS (
93
+ SELECT 1
94
+ FROM app.memberships m
95
+ JOIN app.role_permissions rp
96
+ ON rp.tenant_id = m.tenant_id AND rp.role_id = m.role_id
97
+ WHERE m.tenant_id = p_tenant_id
98
+ AND m.user_id = auth.uid()
99
+ AND m.active
100
+ AND rp.permission = 'tenant.manage'
101
+ );
102
+ $$;
103
+ COMMENT ON FUNCTION public.is_tenant_admin(uuid) IS
104
+ 'DEPRECATED (#134): compat shim kept for the policies of 001/003/021. TRUE iff the caller''s active membership holds tenant.manage. New code uses app.has_permission(...). Removal: Phase 1, once no policy references it.';
105
+ REVOKE ALL ON FUNCTION public.is_tenant_admin(uuid) FROM public, anon;
106
+ GRANT EXECUTE ON FUNCTION public.is_tenant_admin(uuid) TO authenticated, service_role;
107
+
108
+ -- ── public.shares_tenant_with(uuid) ─────────────────────────────────────
109
+ CREATE OR REPLACE FUNCTION public.shares_tenant_with(p_user_id uuid)
110
+ RETURNS boolean
111
+ LANGUAGE sql STABLE SECURITY DEFINER
112
+ SET search_path = ''
113
+ AS $$
114
+ SELECT EXISTS (
115
+ SELECT 1
116
+ FROM app.memberships mine
117
+ JOIN app.memberships theirs ON theirs.tenant_id = mine.tenant_id
118
+ WHERE mine.user_id = auth.uid() AND mine.active
119
+ AND theirs.user_id = p_user_id AND theirs.active
120
+ );
121
+ $$;
122
+ REVOKE ALL ON FUNCTION public.shares_tenant_with(uuid) FROM public, anon;
123
+ GRANT EXECUTE ON FUNCTION public.shares_tenant_with(uuid) TO authenticated, service_role;
124
+
125
+ -- ── public.tenants policies ─────────────────────────────────────────────
126
+ -- Read: through the seam (no detour via the legacy table). Insert: none — a
127
+ -- bare INSERT would create a tenant with no owner membership; create_tenant
128
+ -- (051) is the only path. Update keeps is_tenant_admin (now permission-based).
129
+ DROP POLICY IF EXISTS "tenants_select" ON public.tenants;
130
+ CREATE POLICY "tenants_select" ON public.tenants FOR SELECT TO authenticated
131
+ USING (id IN (SELECT public.user_tenant_ids()));
132
+ DROP POLICY IF EXISTS "tenants_insert" ON public.tenants;