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