@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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,126 @@
|
|
|
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;
|
|
@@ -0,0 +1,471 @@
|
|
|
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 $$;
|