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