@fayz-ai/db 0.13.0 → 0.14.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 (187) hide show
  1. package/canonical-schema.json +106 -23
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +1 -1
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -46
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -90
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
  85. package/migrations/050_authz_core.sql +0 -230
  86. package/migrations/051_authz_helpers.sql +0 -330
  87. package/migrations/052_authz_fanout.sql +0 -253
  88. package/migrations/053_authz_catalog_templates.sql +0 -402
  89. package/migrations/060_tenancy_seam.sql +0 -132
  90. package/migrations/061_create_tenant.sql +0 -125
  91. package/migrations/062_invitations.sql +0 -329
  92. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  93. package/migrations/064_agent_guard_authz.sql +0 -118
  94. package/migrations/064b_supersede_unit_scope.sql +0 -205
  95. package/migrations/065_scaffold_sweep.sql +0 -139
  96. package/migrations/066_shadow_report.sql +0 -122
  97. package/migrations/070_permission_catalog.sql +0 -205
  98. package/migrations/071_v1_permission_map.sql +0 -266
  99. package/migrations/072_role_rpcs.sql +0 -126
  100. package/migrations/073_legacy_rbac_retire.sql +0 -471
  101. package/migrations/075_company_legal_entities.sql +0 -169
  102. package/migrations/076_tenant_settings.sql +0 -251
  103. package/migrations/080_people_roles.sql +0 -269
  104. package/migrations/081_people_identity.sql +0 -271
  105. package/migrations/082_people_membership_link.sql +0 -142
  106. package/migrations/083_people_data_migration.sql +0 -75
  107. package/migrations/084_people_registrations_tokens.sql +0 -282
  108. package/migrations/085_catalog_products.sql +0 -333
  109. package/migrations/086_catalog_pricing.sql +0 -194
  110. package/migrations/087_catalog_packages.sql +0 -134
  111. package/migrations/090_orders_venda.sql +0 -166
  112. package/migrations/091_orders_scaffold.sql +0 -92
  113. package/migrations/095_config_field_rules.sql +0 -261
  114. package/migrations/096_audit_append_only.sql +0 -256
  115. package/migrations/097_sequences_authorized.sql +0 -111
  116. package/migrations/098_holidays.sql +0 -80
  117. package/migrations/099_access_grants.sql +0 -293
  118. package/migrations/100_migration_schema.sql +0 -217
  119. package/migrations/101_migration_allowlist.sql +0 -202
  120. package/migrations/102_migration_rpcs.sql +0 -933
  121. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  122. package/migrations/105_policies_to_authenticated.sql +0 -71
  123. package/migrations/106_function_search_path.sql +0 -55
  124. package/migrations/107_core_table_prefix.sql +0 -361
  125. package/migrations/108_authz_write_guards.sql +0 -149
  126. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  127. package/migrations/110_people_kind_vocabulary.sql +0 -120
  128. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  129. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  130. package/migrations/112_money_precision_standard.sql +0 -132
  131. package/migrations/113_addresses_units_companies.sql +0 -114
  132. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  133. package/migrations/115_registration_honours_field_rules.sql +0 -172
  134. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  135. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  136. package/migrations/118_migration_catalog_writers.sql +0 -450
  137. package/migrations/119_migration_config_writers.sql +0 -193
  138. package/migrations/120_migration_facet_writer.sql +0 -139
  139. package/migrations/121_migration_financial_writers.sql +0 -294
  140. package/migrations/122_migration_inventory_writers.sql +0 -245
  141. package/migrations/123_migration_order_item_writer.sql +0 -144
  142. package/migrations/124_migration_ledger_writers.sql +0 -170
  143. package/migrations/125_migration_authz_people_writers.sql +0 -205
  144. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  145. package/migrations/127_scaffold_audit_hook.sql +0 -98
  146. package/migrations/128_shadow_tables_enforced.sql +0 -93
  147. package/migrations/129_order_kind_terminal_states.sql +0 -57
  148. package/migrations/130_every_login_is_a_person.sql +0 -196
  149. package/migrations/131_migration_invoice_split.sql +0 -127
  150. package/migrations/132_audit_prune_scheduled.sql +0 -28
  151. package/migrations/133_unit_is_a_session_property.sql +0 -155
  152. package/migrations/134_field_rules_on_read.sql +0 -341
  153. package/migrations/135_distribution_polarity.sql +0 -158
  154. package/migrations/136_unit_id_means_locality.sql +0 -358
  155. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  156. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  157. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  158. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  159. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  160. package/migrations/142_a_person_has_addresses.sql +0 -212
  161. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  162. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  163. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  164. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  165. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  166. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  167. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  168. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  169. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  170. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  171. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  172. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  173. package/migrations/155_a_tenant_can_leave.sql +0 -271
  174. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  175. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  176. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  177. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  178. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  179. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  180. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  181. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  182. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  183. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  184. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  185. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  186. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  187. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -1,126 +0,0 @@
1
- -- ============================================================================
2
- -- 072_role_rpcs.sql — tenant roles as an operation, not a table write (#136).
3
- --
4
- -- The vertical templates (043) instantiate the SYSTEM roles of a tenant. A
5
- -- network's "kit por cargo" (V1: permission_rules of the franchisor) and a
6
- -- salon's "duplicate the financial role" are TENANT roles: app.roles rows with
7
- -- is_system = false, created from an existing role's permission set. Two RPCs
8
- -- make that a single, audited call under the platform key roles.manage:
9
- --
10
- -- public.role_create_from_template(role_key, name, template_role[, template[, description]])
11
- -- clone the permissions of a role of MY tenant (system or custom) — or,
12
- -- with p_template, of a vertical template row (app.role_templates) —
13
- -- into a new tenant role. Returns the new role id.
14
- -- public.role_set_permissions(role_id, keys[])
15
- -- replace a role's permission set. The system owner role is immutable
16
- -- (it always holds the whole catalog); unknown keys are rejected as a
17
- -- whole, nothing partial.
18
- --
19
- -- Actor = auth.uid(); tenant = app.current_tenant_id() (never a parameter);
20
- -- denial is ERRCODE 42501. SECURITY DEFINER with search_path = '' so the RLS on
21
- -- app.* does not double-gate the write once the permission is checked here.
22
- -- ============================================================================
23
-
24
- CREATE OR REPLACE FUNCTION public.role_create_from_template(
25
- p_role_key text,
26
- p_name text,
27
- p_template_role text,
28
- p_template text DEFAULT NULL,
29
- p_description text DEFAULT NULL
30
- ) RETURNS uuid
31
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
32
- AS $$
33
- DECLARE
34
- v_tenant uuid := app.current_tenant_id();
35
- v_source uuid;
36
- v_id uuid;
37
- v_n integer;
38
- BEGIN
39
- IF auth.uid() IS NULL OR v_tenant IS NULL THEN
40
- RAISE EXCEPTION 'role_create_from_template: no active tenant for this session' USING ERRCODE = '42501';
41
- END IF;
42
- IF NOT app.has_permission('roles.manage') THEN
43
- RAISE EXCEPTION 'role_create_from_template: roles.manage is required' USING ERRCODE = '42501';
44
- END IF;
45
- IF p_role_key IS NULL OR p_role_key !~ '^[a-z][a-z0-9_-]*$' THEN
46
- RAISE EXCEPTION 'role_create_from_template: role key must match ^[a-z][a-z0-9_-]*$ (got %)', coalesce(p_role_key, '<null>') USING ERRCODE = '22023';
47
- END IF;
48
- IF p_name IS NULL OR btrim(p_name) = '' THEN
49
- RAISE EXCEPTION 'role_create_from_template: a name is required' USING ERRCODE = '22023';
50
- END IF;
51
- IF EXISTS (SELECT 1 FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_role_key) THEN
52
- RAISE EXCEPTION 'role_create_from_template: role % already exists in this tenant', p_role_key USING ERRCODE = '23505';
53
- END IF;
54
-
55
- IF p_template IS NULL THEN
56
- SELECT r.id INTO v_source FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_template_role;
57
- IF v_source IS NULL THEN
58
- RAISE EXCEPTION 'role_create_from_template: no role % in this tenant to clone from', p_template_role USING ERRCODE = 'P0002';
59
- END IF;
60
- ELSIF NOT EXISTS (SELECT 1 FROM app.role_templates t WHERE t.template = p_template AND t.role_key = p_template_role) THEN
61
- RAISE EXCEPTION 'role_create_from_template: template %/% does not exist', p_template, p_template_role USING ERRCODE = 'P0002';
62
- END IF;
63
-
64
- INSERT INTO app.roles (tenant_id, key, name, description, is_system)
65
- VALUES (v_tenant, p_role_key, btrim(p_name), p_description, false)
66
- RETURNING id INTO v_id;
67
-
68
- IF p_template IS NULL THEN
69
- INSERT INTO app.role_permissions (tenant_id, role_id, permission)
70
- SELECT v_tenant, v_id, rp.permission FROM app.role_permissions rp
71
- WHERE rp.tenant_id = v_tenant AND rp.role_id = v_source
72
- ON CONFLICT DO NOTHING;
73
- ELSE
74
- INSERT INTO app.role_permissions (tenant_id, role_id, permission)
75
- SELECT v_tenant, v_id, t.permission FROM app.role_templates t
76
- WHERE t.template = p_template AND t.role_key = p_template_role
77
- ON CONFLICT DO NOTHING;
78
- END IF;
79
- GET DIAGNOSTICS v_n = ROW_COUNT;
80
-
81
- RETURN v_id;
82
- END $$;
83
- REVOKE ALL ON FUNCTION public.role_create_from_template(text, text, text, text, text) FROM public, anon;
84
- GRANT EXECUTE ON FUNCTION public.role_create_from_template(text, text, text, text, text) TO authenticated, service_role;
85
-
86
- CREATE OR REPLACE FUNCTION public.role_set_permissions(p_role_id uuid, p_keys text[])
87
- RETURNS integer
88
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
89
- AS $$
90
- DECLARE
91
- v_tenant uuid := app.current_tenant_id();
92
- v_role app.roles%ROWTYPE;
93
- v_unknown text;
94
- v_keys text[] := coalesce(p_keys, '{}');
95
- v_n integer;
96
- BEGIN
97
- IF auth.uid() IS NULL OR v_tenant IS NULL THEN
98
- RAISE EXCEPTION 'role_set_permissions: no active tenant for this session' USING ERRCODE = '42501';
99
- END IF;
100
- IF NOT app.has_permission('roles.manage') THEN
101
- RAISE EXCEPTION 'role_set_permissions: roles.manage is required' USING ERRCODE = '42501';
102
- END IF;
103
- SELECT * INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.id = p_role_id;
104
- IF NOT FOUND THEN
105
- RAISE EXCEPTION 'role_set_permissions: role not found in the active tenant' USING ERRCODE = 'P0002';
106
- END IF;
107
- IF v_role.is_system AND v_role.key = 'owner' THEN
108
- RAISE EXCEPTION 'role_set_permissions: the owner role is immutable (it always holds the whole catalog)' USING ERRCODE = '42501';
109
- END IF;
110
- SELECT string_agg(k, ', ' ORDER BY k) INTO v_unknown
111
- FROM unnest(v_keys) k WHERE NOT EXISTS (SELECT 1 FROM app.permissions p WHERE p.key = k);
112
- IF v_unknown IS NOT NULL THEN
113
- RAISE EXCEPTION 'role_set_permissions: unknown permission key(s): %', v_unknown USING ERRCODE = '23503';
114
- END IF;
115
-
116
- DELETE FROM app.role_permissions rp
117
- WHERE rp.tenant_id = v_tenant AND rp.role_id = p_role_id AND rp.permission <> ALL (v_keys);
118
- INSERT INTO app.role_permissions (tenant_id, role_id, permission)
119
- SELECT v_tenant, p_role_id, k FROM unnest(v_keys) k
120
- ON CONFLICT DO NOTHING;
121
-
122
- SELECT count(*) INTO v_n FROM app.role_permissions rp WHERE rp.tenant_id = v_tenant AND rp.role_id = p_role_id;
123
- RETURN v_n;
124
- END $$;
125
- REVOKE ALL ON FUNCTION public.role_set_permissions(uuid, text[]) FROM public, anon;
126
- GRANT EXECUTE ON FUNCTION public.role_set_permissions(uuid, text[]) TO authenticated, service_role;
@@ -1,471 +0,0 @@
1
- -- ============================================================================
2
- -- 073_legacy_rbac_retire.sql — retire the legacy RBAC tables of the SDK
3
- -- (public.permissions / role_permissions / tenant_role_overrides / tenant_roles)
4
- -- in favour of app.permissions / app.roles / app.role_permissions (#136).
5
- --
6
- -- What happens here, in order:
7
- -- 1. DATA MIGRATION. Every tenant_roles row becomes an app.roles row
8
- -- (is_system = false, same id). For every (tenant, role key) that exists in
9
- -- app.roles, the legacy effective set — role_permissions[key] ∪ granted
10
- -- overrides − revoked overrides — becomes app.role_permissions rows, keys
11
- -- translated to '{feature}.{action}' (legacy 'update' → 'edit', 'write' →
12
- -- 'create'). Anything that cannot land (unknown key, role key without an
13
- -- app.roles row) is written to app.legacy_permission_quarantine — nothing
14
- -- is dropped silently.
15
- -- 2. COMPAT FACADE. The four tables stay PHYSICALLY (001/021 replay
16
- -- `CREATE TABLE IF NOT EXISTS` + `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
17
- -- + `CREATE POLICY` on those names — none of which works on a view, so a
18
- -- DROP + VIEW would break the force replay of the chain; verified) but
19
- -- become READ-ONLY MIRRORS of app.*, refilled here and kept current by
20
- -- triggers on app.permissions / app.roles / app.role_permissions:
21
- -- permissions(id = key, category = plugin)
22
- -- role_permissions — empty (V2 has no global per-key defaults)
23
- -- tenant_roles — app.roles WHERE NOT is_system (custom roles)
24
- -- tenant_role_overrides — app.role_permissions ⋈ app.roles as
25
- -- (role = key, permission_id, granted = true),
26
- -- owner excluded (the admin grants it everything)
27
- -- This keeps @fayz-ai/admin's permission matrix (listProfiles) reading the
28
- -- truth with the shape it expects. Its WRITES (createProfile/updateProfile/
29
- -- deleteProfile on tenant_roles + tenant_role_overrides) are redirected by
30
- -- BEFORE ROW triggers into app.roles / app.role_permissions — as the
31
- -- caller, so app.* RLS (roles.manage) gates them — and the row itself is
32
- -- never written (RETURN NULL); the mirror re-projects it. Direct writes to
33
- -- permissions / role_permissions are revoked from the API roles.
34
- -- 3. POLICIES of the four tables re-authored on app.* (members read their
35
- -- tenant's rows; roles.manage to write) under the SAME names 001/021 use,
36
- -- so a replay converges on these; the generic tenant_isolation_* that
37
- -- 002's replay adds to tenant_roles is dropped again here.
38
- -- 4. Deprecation: COMMENT ON TABLE. Phase 1 drops the four tables and the
39
- -- mirror/redirect triggers once @fayz-ai/admin reads public.v_authz_* —
40
- -- the mirror functions already no-op when a table is gone.
41
- --
42
- -- Idempotent + replay-safe: the migration reads whatever is in the legacy
43
- -- tables (on replay: the mirror, which is already in app.*), every insert is
44
- -- ON CONFLICT DO NOTHING, and the mirror is rebuilt from app.* at the end.
45
- -- ============================================================================
46
-
47
- -- ── 0. quarantine ─────────────────────────────────────────────────────────
48
- CREATE TABLE IF NOT EXISTS app.legacy_permission_quarantine (
49
- id bigserial PRIMARY KEY,
50
- source_table text NOT NULL,
51
- tenant_id uuid,
52
- role_key text,
53
- permission_id text,
54
- granted boolean,
55
- reason text NOT NULL CHECK (reason IN ('role-missing', 'permission-unknown', 'tenant-missing')),
56
- payload jsonb NOT NULL DEFAULT '{}'::jsonb,
57
- quarantined_at timestamptz NOT NULL DEFAULT now()
58
- );
59
- CREATE UNIQUE INDEX IF NOT EXISTS legacy_permission_quarantine_uniq
60
- ON app.legacy_permission_quarantine (source_table, coalesce(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), coalesce(role_key, ''), coalesce(permission_id, ''), reason);
61
- ALTER TABLE app.legacy_permission_quarantine ENABLE ROW LEVEL SECURITY;
62
- GRANT SELECT ON app.legacy_permission_quarantine TO authenticated;
63
- GRANT ALL ON app.legacy_permission_quarantine TO service_role;
64
- GRANT USAGE, SELECT ON SEQUENCE app.legacy_permission_quarantine_id_seq TO service_role;
65
- DROP POLICY IF EXISTS legacy_permission_quarantine_select ON app.legacy_permission_quarantine;
66
- CREATE POLICY legacy_permission_quarantine_select ON app.legacy_permission_quarantine FOR SELECT TO authenticated
67
- USING (tenant_id IS NOT NULL AND tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')));
68
- COMMENT ON TABLE app.legacy_permission_quarantine IS
69
- 'Legacy RBAC rows (SDK role_permissions/tenant_role_overrides) that could not be migrated into app.role_permissions (#136): unknown key or role key without an app.roles row. Review, then grant through role_set_permissions.';
70
-
71
- -- ── 1. key translation ────────────────────────────────────────────────────
72
- -- Legacy shapes: 'people.read' (feature.action) or ('people', actions[]) rows.
73
- -- Actions: legacy 'update' is V2 'edit', 'write' is 'create'. Returns NULL when
74
- -- no catalog key matches (the caller quarantines or raises).
75
- CREATE OR REPLACE FUNCTION app.legacy_permission_key(p_permission_id text, p_action text DEFAULT NULL)
76
- RETURNS text
77
- LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
78
- AS $$
79
- WITH cand AS (
80
- SELECT k, prio FROM (
81
- SELECT CASE
82
- WHEN p_action IS NOT NULL THEN p_permission_id || '.' || CASE p_action WHEN 'update' THEN 'edit' WHEN 'write' THEN 'create' ELSE p_action END
83
- ELSE regexp_replace(regexp_replace(p_permission_id, '\.update$', '.edit'), '\.write$', '.create')
84
- END AS k, 1 AS prio
85
- UNION ALL
86
- SELECT CASE WHEN p_action IS NOT NULL THEN p_permission_id || '.' || p_action ELSE p_permission_id END, 2
87
- ) c
88
- )
89
- SELECT k FROM cand WHERE EXISTS (SELECT 1 FROM app.permissions p WHERE p.key = cand.k) ORDER BY prio LIMIT 1;
90
- $$;
91
- REVOKE ALL ON FUNCTION app.legacy_permission_key(text, text) FROM public, anon;
92
- GRANT EXECUTE ON FUNCTION app.legacy_permission_key(text, text) TO authenticated, service_role;
93
-
94
- -- ── 2. the data migration (callable; runs once below, no-ops on replay) ───
95
- CREATE OR REPLACE FUNCTION app.migrate_legacy_rbac()
96
- RETURNS jsonb
97
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
98
- AS $$
99
- DECLARE
100
- v_roles integer := 0;
101
- v_grants integer := 0;
102
- v_revoked integer := 0;
103
- v_quar integer := 0;
104
- r record;
105
- v_key text;
106
- v_role_id uuid;
107
- v_prev text := coalesce(current_setting('app.legacy_mirror_sync', true), 'off');
108
- BEGIN
109
- -- Reads of the legacy tables below must not be redirected: this function
110
- -- writes app.* directly, so the mirror re-projects at the end (resync).
111
- PERFORM set_config('app.legacy_mirror_sync', 'on', true);
112
-
113
- -- 2a. custom roles → app.roles (same id, is_system false)
114
- IF to_regclass('public.tenant_roles') IS NOT NULL THEN
115
- WITH ins AS (
116
- INSERT INTO app.roles (tenant_id, id, key, name, description, is_system, created_at)
117
- SELECT tr.tenant_id, tr.id, tr.key, tr.name, tr.description, false, coalesce(tr.created_at, now())
118
- FROM public.tenant_roles tr
119
- WHERE EXISTS (SELECT 1 FROM public.tenants t WHERE t.id = tr.tenant_id)
120
- AND tr.key ~ '^[a-z][a-z0-9_-]*$'
121
- AND NOT EXISTS (SELECT 1 FROM app.roles ar WHERE ar.id = tr.id AND ar.tenant_id <> tr.tenant_id)
122
- ON CONFLICT (tenant_id, key) DO NOTHING
123
- RETURNING 1
124
- ) SELECT count(*) INTO v_roles FROM ins;
125
- -- keys the CHECK of app.roles refuses go to the quarantine
126
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, reason, payload)
127
- SELECT 'tenant_roles', tr.tenant_id, tr.key, 'role-missing', jsonb_build_object('name', tr.name, 'why', 'key does not match ^[a-z][a-z0-9_-]*$')
128
- FROM public.tenant_roles tr WHERE tr.key !~ '^[a-z][a-z0-9_-]*$'
129
- ON CONFLICT DO NOTHING;
130
- END IF;
131
-
132
- -- 2b. global defaults (role_permissions) fanned into every tenant that has the role key
133
- IF to_regclass('public.role_permissions') IS NOT NULL THEN
134
- FOR r IN
135
- SELECT rp.role, rp.permission_id, rp.actions FROM public.role_permissions rp
136
- LOOP
137
- IF position('.' IN r.permission_id) > 0 THEN
138
- v_key := app.legacy_permission_key(r.permission_id);
139
- IF v_key IS NULL THEN
140
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, permission_id, granted, reason)
141
- VALUES ('role_permissions', NULL, r.role, r.permission_id, true, 'permission-unknown') ON CONFLICT DO NOTHING;
142
- CONTINUE;
143
- END IF;
144
- WITH ins AS (
145
- INSERT INTO app.role_permissions (tenant_id, role_id, permission)
146
- SELECT ro.tenant_id, ro.id, v_key FROM app.roles ro WHERE ro.key = r.role
147
- ON CONFLICT DO NOTHING RETURNING 1
148
- ) SELECT v_grants + count(*) INTO v_grants FROM ins;
149
- ELSE
150
- -- legacy (feature, actions[]) shape
151
- FOR v_key IN SELECT app.legacy_permission_key(r.permission_id, a) FROM unnest(coalesce(r.actions, '{}')) a LOOP
152
- IF v_key IS NULL THEN
153
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, permission_id, granted, reason, payload)
154
- VALUES ('role_permissions', NULL, r.role, r.permission_id, true, 'permission-unknown', jsonb_build_object('actions', to_jsonb(r.actions))) ON CONFLICT DO NOTHING;
155
- CONTINUE;
156
- END IF;
157
- WITH ins AS (
158
- INSERT INTO app.role_permissions (tenant_id, role_id, permission)
159
- SELECT ro.tenant_id, ro.id, v_key FROM app.roles ro WHERE ro.key = r.role
160
- ON CONFLICT DO NOTHING RETURNING 1
161
- ) SELECT v_grants + count(*) INTO v_grants FROM ins;
162
- END LOOP;
163
- END IF;
164
- IF NOT EXISTS (SELECT 1 FROM app.roles ro WHERE ro.key = r.role) THEN
165
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, permission_id, granted, reason)
166
- VALUES ('role_permissions', NULL, r.role, r.permission_id, true, 'role-missing') ON CONFLICT DO NOTHING;
167
- END IF;
168
- END LOOP;
169
- END IF;
170
-
171
- -- 2c. per-tenant overrides: granted → insert, revoked → delete
172
- IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
173
- FOR r IN
174
- SELECT o.tenant_id, o.role, o.permission_id, o.granted FROM public.tenant_role_overrides o
175
- ORDER BY o.granted DESC -- grants first, then revocations win (V1 semantics: overrides beat defaults)
176
- LOOP
177
- IF NOT EXISTS (SELECT 1 FROM public.tenants t WHERE t.id = r.tenant_id) THEN
178
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, permission_id, granted, reason)
179
- VALUES ('tenant_role_overrides', r.tenant_id, r.role, r.permission_id, r.granted, 'tenant-missing') ON CONFLICT DO NOTHING;
180
- CONTINUE;
181
- END IF;
182
- SELECT ro.id INTO v_role_id FROM app.roles ro WHERE ro.tenant_id = r.tenant_id AND ro.key = r.role;
183
- IF v_role_id IS NULL THEN
184
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, permission_id, granted, reason)
185
- VALUES ('tenant_role_overrides', r.tenant_id, r.role, r.permission_id, r.granted, 'role-missing') ON CONFLICT DO NOTHING;
186
- CONTINUE;
187
- END IF;
188
- v_key := app.legacy_permission_key(r.permission_id);
189
- IF v_key IS NULL THEN
190
- INSERT INTO app.legacy_permission_quarantine (source_table, tenant_id, role_key, permission_id, granted, reason)
191
- VALUES ('tenant_role_overrides', r.tenant_id, r.role, r.permission_id, r.granted, 'permission-unknown') ON CONFLICT DO NOTHING;
192
- CONTINUE;
193
- END IF;
194
- IF r.granted THEN
195
- INSERT INTO app.role_permissions (tenant_id, role_id, permission) VALUES (r.tenant_id, v_role_id, v_key)
196
- ON CONFLICT DO NOTHING;
197
- IF FOUND THEN v_grants := v_grants + 1; END IF;
198
- ELSE
199
- DELETE FROM app.role_permissions rp WHERE rp.tenant_id = r.tenant_id AND rp.role_id = v_role_id AND rp.permission = v_key
200
- AND NOT EXISTS (SELECT 1 FROM app.roles ro WHERE ro.id = v_role_id AND ro.is_system AND ro.key = 'owner');
201
- IF FOUND THEN v_revoked := v_revoked + 1; END IF;
202
- END IF;
203
- END LOOP;
204
- END IF;
205
-
206
- SELECT count(*) INTO v_quar FROM app.legacy_permission_quarantine;
207
- PERFORM set_config('app.legacy_mirror_sync', v_prev, true);
208
- RETURN jsonb_build_object('roles_created', v_roles, 'grants_inserted', v_grants, 'grants_revoked', v_revoked, 'quarantined_total', v_quar);
209
- END $$;
210
- REVOKE ALL ON FUNCTION app.migrate_legacy_rbac() FROM public, anon, authenticated;
211
- GRANT EXECUTE ON FUNCTION app.migrate_legacy_rbac() TO service_role;
212
-
213
- -- ── 3. mirror maintenance (app.* → legacy tables), SECURITY DEFINER ───────
214
- -- Every mirror write runs with app.legacy_mirror_sync = on so the redirect
215
- -- triggers of §4 pass it through instead of bouncing it back into app.*.
216
- CREATE OR REPLACE FUNCTION app.legacy_rbac_resync()
217
- RETURNS jsonb
218
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
219
- AS $$
220
- DECLARE
221
- v_prev text := coalesce(current_setting('app.legacy_mirror_sync', true), 'off');
222
- v_perms integer := 0; v_roles integer := 0; v_over integer := 0;
223
- BEGIN
224
- PERFORM set_config('app.legacy_mirror_sync', 'on', true);
225
- IF to_regclass('public.permissions') IS NOT NULL THEN
226
- DELETE FROM public.permissions p WHERE NOT EXISTS (SELECT 1 FROM app.permissions a WHERE a.key = p.id);
227
- INSERT INTO public.permissions (id, category, description)
228
- SELECT a.key, a.plugin, a.description FROM app.permissions a
229
- ON CONFLICT (id) DO UPDATE SET category = EXCLUDED.category, description = EXCLUDED.description;
230
- GET DIAGNOSTICS v_perms = ROW_COUNT;
231
- END IF;
232
- IF to_regclass('public.role_permissions') IS NOT NULL THEN
233
- DELETE FROM public.role_permissions;
234
- END IF;
235
- IF to_regclass('public.tenant_roles') IS NOT NULL THEN
236
- DELETE FROM public.tenant_roles tr WHERE NOT EXISTS (SELECT 1 FROM app.roles r WHERE r.id = tr.id AND r.tenant_id = tr.tenant_id AND NOT r.is_system);
237
- -- a same-key row with another id would collide: drop it first, the upsert re-creates it with the app.roles id
238
- DELETE FROM public.tenant_roles tr WHERE EXISTS (SELECT 1 FROM app.roles r WHERE r.tenant_id = tr.tenant_id AND r.key = tr.key AND r.id <> tr.id);
239
- INSERT INTO public.tenant_roles (id, tenant_id, key, name, description, created_at)
240
- SELECT r.id, r.tenant_id, r.key, r.name, r.description, r.created_at FROM app.roles r WHERE NOT r.is_system
241
- ON CONFLICT (id) DO UPDATE SET key = EXCLUDED.key, name = EXCLUDED.name, description = EXCLUDED.description;
242
- GET DIAGNOSTICS v_roles = ROW_COUNT;
243
- END IF;
244
- IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
245
- DELETE FROM public.tenant_role_overrides o
246
- WHERE NOT o.granted
247
- OR NOT EXISTS (SELECT 1 FROM app.role_permissions rp JOIN app.roles r ON r.tenant_id = rp.tenant_id AND r.id = rp.role_id
248
- WHERE rp.tenant_id = o.tenant_id AND r.key = o.role AND rp.permission = o.permission_id AND NOT (r.is_system AND r.key = 'owner'));
249
- INSERT INTO public.tenant_role_overrides (tenant_id, role, permission_id, granted)
250
- SELECT rp.tenant_id, r.key, rp.permission, true
251
- FROM app.role_permissions rp JOIN app.roles r ON r.tenant_id = rp.tenant_id AND r.id = rp.role_id
252
- WHERE NOT (r.is_system AND r.key = 'owner')
253
- ON CONFLICT (tenant_id, role, permission_id) DO UPDATE SET granted = true;
254
- GET DIAGNOSTICS v_over = ROW_COUNT;
255
- END IF;
256
- PERFORM set_config('app.legacy_mirror_sync', v_prev, true);
257
- RETURN jsonb_build_object('permissions', v_perms, 'tenant_roles', v_roles, 'tenant_role_overrides', v_over);
258
- END $$;
259
- REVOKE ALL ON FUNCTION app.legacy_rbac_resync() FROM public, anon, authenticated;
260
- GRANT EXECUTE ON FUNCTION app.legacy_rbac_resync() TO service_role;
261
-
262
- CREATE OR REPLACE FUNCTION app.trg_mirror_permissions()
263
- RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
264
- DECLARE v_prev text := coalesce(current_setting('app.legacy_mirror_sync', true), 'off');
265
- BEGIN
266
- IF to_regclass('public.permissions') IS NULL THEN RETURN NULL; END IF;
267
- PERFORM set_config('app.legacy_mirror_sync', 'on', true);
268
- IF TG_OP = 'DELETE' THEN
269
- DELETE FROM public.permissions WHERE id = OLD.key;
270
- ELSE
271
- IF TG_OP = 'UPDATE' AND OLD.key <> NEW.key THEN DELETE FROM public.permissions WHERE id = OLD.key; END IF;
272
- INSERT INTO public.permissions (id, category, description) VALUES (NEW.key, NEW.plugin, NEW.description)
273
- ON CONFLICT (id) DO UPDATE SET category = EXCLUDED.category, description = EXCLUDED.description;
274
- END IF;
275
- PERFORM set_config('app.legacy_mirror_sync', v_prev, true);
276
- RETURN NULL;
277
- END $$;
278
- DROP TRIGGER IF EXISTS permissions_mirror_legacy ON app.permissions;
279
- CREATE TRIGGER permissions_mirror_legacy AFTER INSERT OR UPDATE OR DELETE ON app.permissions
280
- FOR EACH ROW EXECUTE FUNCTION app.trg_mirror_permissions();
281
-
282
- CREATE OR REPLACE FUNCTION app.trg_mirror_roles()
283
- RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
284
- DECLARE v_prev text := coalesce(current_setting('app.legacy_mirror_sync', true), 'off');
285
- BEGIN
286
- IF to_regclass('public.tenant_roles') IS NULL THEN RETURN NULL; END IF;
287
- PERFORM set_config('app.legacy_mirror_sync', 'on', true);
288
- IF TG_OP = 'DELETE' THEN
289
- DELETE FROM public.tenant_roles WHERE id = OLD.id;
290
- IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
291
- DELETE FROM public.tenant_role_overrides WHERE tenant_id = OLD.tenant_id AND role = OLD.key;
292
- END IF;
293
- ELSE
294
- IF TG_OP = 'UPDATE' AND OLD.key <> NEW.key AND to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
295
- UPDATE public.tenant_role_overrides SET role = NEW.key WHERE tenant_id = NEW.tenant_id AND role = OLD.key;
296
- END IF;
297
- IF NEW.is_system THEN
298
- DELETE FROM public.tenant_roles WHERE id = NEW.id;
299
- ELSE
300
- DELETE FROM public.tenant_roles WHERE tenant_id = NEW.tenant_id AND key = NEW.key AND id <> NEW.id;
301
- INSERT INTO public.tenant_roles (id, tenant_id, key, name, description, created_at)
302
- VALUES (NEW.id, NEW.tenant_id, NEW.key, NEW.name, NEW.description, NEW.created_at)
303
- ON CONFLICT (id) DO UPDATE SET key = EXCLUDED.key, name = EXCLUDED.name, description = EXCLUDED.description;
304
- END IF;
305
- END IF;
306
- PERFORM set_config('app.legacy_mirror_sync', v_prev, true);
307
- RETURN NULL;
308
- END $$;
309
- DROP TRIGGER IF EXISTS roles_mirror_legacy ON app.roles;
310
- CREATE TRIGGER roles_mirror_legacy AFTER INSERT OR UPDATE OR DELETE ON app.roles
311
- FOR EACH ROW EXECUTE FUNCTION app.trg_mirror_roles();
312
-
313
- CREATE OR REPLACE FUNCTION app.trg_mirror_role_permissions()
314
- RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
315
- DECLARE
316
- v_prev text := coalesce(current_setting('app.legacy_mirror_sync', true), 'off');
317
- v_role app.roles%ROWTYPE;
318
- BEGIN
319
- IF to_regclass('public.tenant_role_overrides') IS NULL THEN RETURN NULL; END IF;
320
- PERFORM set_config('app.legacy_mirror_sync', 'on', true);
321
- IF TG_OP IN ('DELETE', 'UPDATE') THEN
322
- SELECT * INTO v_role FROM app.roles WHERE tenant_id = OLD.tenant_id AND id = OLD.role_id;
323
- IF FOUND THEN
324
- DELETE FROM public.tenant_role_overrides WHERE tenant_id = OLD.tenant_id AND role = v_role.key AND permission_id = OLD.permission;
325
- END IF;
326
- END IF;
327
- IF TG_OP IN ('INSERT', 'UPDATE') THEN
328
- SELECT * INTO v_role FROM app.roles WHERE tenant_id = NEW.tenant_id AND id = NEW.role_id;
329
- IF FOUND AND NOT (v_role.is_system AND v_role.key = 'owner') THEN
330
- INSERT INTO public.tenant_role_overrides (tenant_id, role, permission_id, granted)
331
- VALUES (NEW.tenant_id, v_role.key, NEW.permission, true)
332
- ON CONFLICT (tenant_id, role, permission_id) DO UPDATE SET granted = true;
333
- END IF;
334
- END IF;
335
- PERFORM set_config('app.legacy_mirror_sync', v_prev, true);
336
- RETURN NULL;
337
- END $$;
338
- DROP TRIGGER IF EXISTS role_permissions_mirror_legacy ON app.role_permissions;
339
- CREATE TRIGGER role_permissions_mirror_legacy AFTER INSERT OR UPDATE OR DELETE ON app.role_permissions
340
- FOR EACH ROW EXECUTE FUNCTION app.trg_mirror_role_permissions();
341
-
342
- -- ── 4. write-through redirect (legacy tables → app.*), SECURITY INVOKER ───
343
- -- Fires for the admin's writes. Runs as the caller: app.* RLS (roles.manage,
344
- -- NOT is_system on delete) is the gate. When the mirror itself is writing
345
- -- (flag on) the row passes through untouched.
346
- CREATE OR REPLACE FUNCTION public.trg_legacy_tenant_roles_redirect()
347
- RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
348
- BEGIN
349
- IF coalesce(current_setting('app.legacy_mirror_sync', true), 'off') = 'on' THEN
350
- RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
351
- END IF;
352
- IF TG_OP = 'INSERT' THEN
353
- INSERT INTO app.roles (tenant_id, key, name, description, is_system)
354
- VALUES (NEW.tenant_id, NEW.key, NEW.name, NEW.description, false);
355
- ELSIF TG_OP = 'UPDATE' THEN
356
- UPDATE app.roles SET key = NEW.key, name = NEW.name, description = NEW.description
357
- WHERE tenant_id = OLD.tenant_id AND key = OLD.key AND NOT is_system;
358
- ELSE
359
- DELETE FROM app.roles WHERE tenant_id = OLD.tenant_id AND key = OLD.key AND NOT is_system;
360
- END IF;
361
- RETURN NULL; -- the legacy row is never written; the mirror re-projects app.roles
362
- END $$;
363
- DO $$
364
- BEGIN
365
- IF to_regclass('public.tenant_roles') IS NOT NULL THEN
366
- EXECUTE 'DROP TRIGGER IF EXISTS tenant_roles_redirect ON public.tenant_roles';
367
- EXECUTE 'CREATE TRIGGER tenant_roles_redirect BEFORE INSERT OR UPDATE OR DELETE ON public.tenant_roles FOR EACH ROW EXECUTE FUNCTION public.trg_legacy_tenant_roles_redirect()';
368
- END IF;
369
- END $$;
370
-
371
- CREATE OR REPLACE FUNCTION public.trg_legacy_overrides_redirect()
372
- RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
373
- DECLARE v_role uuid; v_key text; v_row record;
374
- BEGIN
375
- IF coalesce(current_setting('app.legacy_mirror_sync', true), 'off') = 'on' THEN
376
- RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
377
- END IF;
378
- -- the OLD half (UPDATE/DELETE): drop the grant it stood for
379
- IF TG_OP IN ('UPDATE', 'DELETE') THEN
380
- SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = OLD.tenant_id AND r.key = OLD.role;
381
- v_key := app.legacy_permission_key(OLD.permission_id);
382
- IF v_role IS NOT NULL AND v_key IS NOT NULL THEN
383
- DELETE FROM app.role_permissions rp WHERE rp.tenant_id = OLD.tenant_id AND rp.role_id = v_role AND rp.permission = v_key;
384
- END IF;
385
- END IF;
386
- -- the NEW half (INSERT/UPDATE): grant, or revoke when granted = false
387
- IF TG_OP IN ('INSERT', 'UPDATE') THEN
388
- SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = NEW.tenant_id AND r.key = NEW.role;
389
- IF v_role IS NULL THEN
390
- RAISE EXCEPTION 'tenant_role_overrides is a compat facade of app.role_permissions: role % does not exist in tenant %', NEW.role, NEW.tenant_id USING ERRCODE = '23503';
391
- END IF;
392
- v_key := app.legacy_permission_key(NEW.permission_id);
393
- IF v_key IS NULL THEN
394
- RAISE EXCEPTION 'tenant_role_overrides is a compat facade of app.role_permissions: permission % is not in the catalog (app.permissions)', NEW.permission_id USING ERRCODE = '23503';
395
- END IF;
396
- IF NEW.granted THEN
397
- INSERT INTO app.role_permissions (tenant_id, role_id, permission) VALUES (NEW.tenant_id, v_role, v_key)
398
- ON CONFLICT DO NOTHING;
399
- ELSE
400
- DELETE FROM app.role_permissions rp WHERE rp.tenant_id = NEW.tenant_id AND rp.role_id = v_role AND rp.permission = v_key;
401
- END IF;
402
- END IF;
403
- RETURN NULL;
404
- END $$;
405
- DO $$
406
- BEGIN
407
- IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
408
- EXECUTE 'DROP TRIGGER IF EXISTS tenant_role_overrides_redirect ON public.tenant_role_overrides';
409
- EXECUTE 'CREATE TRIGGER tenant_role_overrides_redirect BEFORE INSERT OR UPDATE OR DELETE ON public.tenant_role_overrides FOR EACH ROW EXECUTE FUNCTION public.trg_legacy_overrides_redirect()';
410
- END IF;
411
- END $$;
412
-
413
- -- ── 5. run the migration, then rebuild the mirror from app.* ─────────────
414
- DO $$
415
- DECLARE v jsonb;
416
- BEGIN
417
- v := app.migrate_legacy_rbac();
418
- RAISE NOTICE 'legacy rbac migrated: %', v;
419
- v := app.legacy_rbac_resync();
420
- RAISE NOTICE 'legacy rbac mirror rebuilt: %', v;
421
- END $$;
422
-
423
- -- ── 6. policies on app.* (same names as 001/021 ⇒ replay converges) ───────
424
- DO $$
425
- BEGIN
426
- IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
427
- EXECUTE 'DROP POLICY IF EXISTS "overrides_select" ON public.tenant_role_overrides';
428
- EXECUTE 'CREATE POLICY "overrides_select" ON public.tenant_role_overrides FOR SELECT TO authenticated USING ((select app.is_member(tenant_id)))';
429
- EXECUTE 'DROP POLICY IF EXISTS "overrides_manage" ON public.tenant_role_overrides';
430
- EXECUTE 'CREATE POLICY "overrides_manage" ON public.tenant_role_overrides FOR ALL TO authenticated USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission(''roles.manage''))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission(''roles.manage'')))';
431
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_select ON public.tenant_role_overrides';
432
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_insert ON public.tenant_role_overrides';
433
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_update ON public.tenant_role_overrides';
434
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_delete ON public.tenant_role_overrides';
435
- END IF;
436
- IF to_regclass('public.tenant_roles') IS NOT NULL THEN
437
- EXECUTE 'DROP POLICY IF EXISTS "tenant_roles_select" ON public.tenant_roles';
438
- EXECUTE 'CREATE POLICY "tenant_roles_select" ON public.tenant_roles FOR SELECT TO authenticated USING ((select app.is_member(tenant_id)))';
439
- EXECUTE 'DROP POLICY IF EXISTS "tenant_roles_manage" ON public.tenant_roles';
440
- EXECUTE 'CREATE POLICY "tenant_roles_manage" ON public.tenant_roles FOR ALL TO authenticated USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission(''roles.manage''))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission(''roles.manage'')))';
441
- -- 002's replay stamps the generic policies on any public table with tenant_id; not here
442
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_select ON public.tenant_roles';
443
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_insert ON public.tenant_roles';
444
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_update ON public.tenant_roles';
445
- EXECUTE 'DROP POLICY IF EXISTS tenant_isolation_delete ON public.tenant_roles';
446
- END IF;
447
- END $$;
448
-
449
- -- ── 7. grants + deprecation ───────────────────────────────────────────────
450
- -- permissions / role_permissions: read-only for the API (the mirror writes as
451
- -- definer). tenant_roles / tenant_role_overrides keep their write privileges so
452
- -- the redirect triggers can fire — no row ever lands there from a caller.
453
- DO $$
454
- BEGIN
455
- IF to_regclass('public.permissions') IS NOT NULL THEN
456
- EXECUTE 'REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.permissions FROM authenticated, anon';
457
- EXECUTE 'COMMENT ON TABLE public.permissions IS ''DEPRECATED (#136, Phase 0): read-only mirror of app.permissions (id = key, category = plugin) kept for @fayz-ai/admin''''s permission matrix. Drop in Phase 1 once the admin reads public.v_authz_permissions.''';
458
- END IF;
459
- IF to_regclass('public.role_permissions') IS NOT NULL THEN
460
- EXECUTE 'REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.role_permissions FROM authenticated, anon';
461
- EXECUTE 'COMMENT ON TABLE public.role_permissions IS ''DEPRECATED (#136, Phase 0): always empty. Per-role grants live in app.role_permissions (per tenant); the admin reads them through tenant_role_overrides. Drop in Phase 1.''';
462
- END IF;
463
- IF to_regclass('public.tenant_roles') IS NOT NULL THEN
464
- EXECUTE 'REVOKE ALL ON public.tenant_roles FROM anon';
465
- EXECUTE 'COMMENT ON TABLE public.tenant_roles IS ''DEPRECATED (#136, Phase 0): write-through mirror of app.roles WHERE NOT is_system. Writes are redirected to app.roles by trigger (roles.manage via RLS). Drop in Phase 1 once @fayz-ai/admin uses public.v_authz_roles / role_create_from_template.''';
466
- END IF;
467
- IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
468
- EXECUTE 'REVOKE ALL ON public.tenant_role_overrides FROM anon';
469
- EXECUTE 'COMMENT ON TABLE public.tenant_role_overrides IS ''DEPRECATED (#136, Phase 0): write-through mirror of app.role_permissions ⋈ app.roles (role = key, granted = true; owner excluded). Writes are redirected to app.role_permissions by trigger. Drop in Phase 1 once @fayz-ai/admin uses public.v_authz_role_permissions / role_set_permissions.''';
470
- END IF;
471
- END $$;