@fayz-ai/db 0.11.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/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- 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 +6 -5
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 136_unit_id_means_locality.sql — `unit_id` stops meaning two things (#225).
|
|
3
|
+
--
|
|
4
|
+
-- The column carried both "this row belongs to a unit" (cardinality 1, a fact
|
|
5
|
+
-- about the row) and "this row is available in a unit" (cardinality N, a
|
|
6
|
+
-- relation that changes without the row changing). Two consequences, both
|
|
7
|
+
-- verifiable before this file: there were TWO ways to say "only at Centro"
|
|
8
|
+
-- (`unit_id = centro`, or tenant-wide plus one `available_in` tuple) resolved by
|
|
9
|
+
-- different code paths with nothing preventing the wrong one; and the scaffold
|
|
10
|
+
-- had never once generated `unit_id NOT NULL` — one table in the whole repo had
|
|
11
|
+
-- it, added by hand.
|
|
12
|
+
--
|
|
13
|
+
-- THREE CLASSES, NOT TWO — and the schema is what says so.
|
|
14
|
+
--
|
|
15
|
+
-- The decision as written said `scope` was `unit | tenant`. Classifying the 47
|
|
16
|
+
-- registered tables did not survive that: `holidays`, `field_rules`,
|
|
17
|
+
-- `price_tables` and `plg_inventory_product_settings` each document, in their own
|
|
18
|
+
-- COMMENT, that `unit_id NULL` means "the network's" and a value means "that
|
|
19
|
+
-- unit's own". That is not the conflation this file exists to remove — both
|
|
20
|
+
-- readings are LOCALITY, at cardinality 0-or-1. It is a third class, and
|
|
21
|
+
-- pretending otherwise would have forced a wrong constraint onto four tables
|
|
22
|
+
-- that were already right.
|
|
23
|
+
--
|
|
24
|
+
-- unit locality required → unit_id NOT NULL, and the policy drops
|
|
25
|
+
-- its `unit_id IS NULL OR` branch
|
|
26
|
+
-- unit_optional locality optional → nothing enforced, but DECLARED: the NULL
|
|
27
|
+
-- now means something a reader can check
|
|
28
|
+
-- tenant no locality at all → CHECK (unit_id IS NULL); distribution is
|
|
29
|
+
-- `available_in`, and there is one way to
|
|
30
|
+
-- say it
|
|
31
|
+
--
|
|
32
|
+
-- NOT NULL IS APPLIED ONLY WHERE THE DATA ALLOWS, AND THE REST IS NAMED.
|
|
33
|
+
-- A table classified `unit` whose rows still hold NULLs is not silently skipped
|
|
34
|
+
-- and not force-migrated: it lands in `app.scaffold_scope_pending` with the count
|
|
35
|
+
-- that has to be backfilled, and 900_verification asserts the list, so it cannot
|
|
36
|
+
-- grow quietly.
|
|
37
|
+
--
|
|
38
|
+
-- Replay-safe: additive column, idempotent seed, constraints guarded by
|
|
39
|
+
-- existence checks.
|
|
40
|
+
-- ============================================================================
|
|
41
|
+
|
|
42
|
+
-- ── the registry records the class ──────────────────────────────────────
|
|
43
|
+
ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS scope text;
|
|
44
|
+
DO $$
|
|
45
|
+
BEGIN
|
|
46
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'app.scaffold_registry'::regclass AND conname = 'scaffold_registry_scope_chk') THEN
|
|
47
|
+
ALTER TABLE app.scaffold_registry
|
|
48
|
+
ADD CONSTRAINT scaffold_registry_scope_chk CHECK (scope IS NULL OR scope IN ('unit', 'unit_optional', 'tenant'));
|
|
49
|
+
END IF;
|
|
50
|
+
END $$;
|
|
51
|
+
COMMENT ON COLUMN app.scaffold_registry.scope IS
|
|
52
|
+
'What unit_id means on this table (#225): unit = locality required (NOT NULL); unit_optional = locality optional, NULL is the network default; tenant = no locality, distribution is available_in and unit_id is CHECKed NULL.';
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS app.scaffold_scope_pending (
|
|
55
|
+
table_schema text NOT NULL,
|
|
56
|
+
table_name text NOT NULL,
|
|
57
|
+
scope text NOT NULL,
|
|
58
|
+
offending bigint NOT NULL,
|
|
59
|
+
noticed_at timestamptz NOT NULL DEFAULT now(),
|
|
60
|
+
PRIMARY KEY (table_schema, table_name)
|
|
61
|
+
);
|
|
62
|
+
COMMENT ON TABLE app.scaffold_scope_pending IS
|
|
63
|
+
'Tables classified `unit` whose rows still hold a NULL unit_id, with the count that has to be backfilled before NOT NULL can land (#225). Empty is the goal; 900_verification asserts the contents so the list cannot grow without someone noticing.';
|
|
64
|
+
|
|
65
|
+
-- ── the predicate already knows the class ──────────────────────────────
|
|
66
|
+
-- app.scaffold_scope_sql has taken a third argument since 124, defaulted and
|
|
67
|
+
-- unused until now. What changes here is who passes it.
|
|
68
|
+
|
|
69
|
+
-- 134's refresh, now passing the class through
|
|
70
|
+
CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text)
|
|
71
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
72
|
+
DECLARE
|
|
73
|
+
v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
|
|
74
|
+
v_name text := split_part(replace(p_table, '"', ''), '.', 2);
|
|
75
|
+
v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
|
|
76
|
+
r app.scaffold_registry%ROWTYPE;
|
|
77
|
+
v_scope text;
|
|
78
|
+
v_class text;
|
|
79
|
+
BEGIN
|
|
80
|
+
SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
|
|
81
|
+
IF NOT FOUND THEN
|
|
82
|
+
RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
|
|
83
|
+
END IF;
|
|
84
|
+
|
|
85
|
+
-- The `unit` rendering drops the `unit_id IS NULL OR` branch, which is only
|
|
86
|
+
-- sound when the column really is NOT NULL. Declaring a table `unit` while its
|
|
87
|
+
-- writers still produce NULLs is exactly the state this file leaves most tables
|
|
88
|
+
-- in — so the optimisation follows the CONSTRAINT, never the label. Getting
|
|
89
|
+
-- this wrong hid every unit-less financial movement and took
|
|
90
|
+
-- v_financial_customer_credit to 0.00.
|
|
91
|
+
SELECT CASE WHEN c.is_nullable = 'NO' THEN 'unit' ELSE NULL END INTO v_class
|
|
92
|
+
FROM information_schema.columns c
|
|
93
|
+
WHERE c.table_schema = v_schema AND c.table_name = v_name AND c.column_name = 'unit_id';
|
|
94
|
+
v_scope := app.scaffold_scope_sql(r.resource_type, r.plugin, v_class);
|
|
95
|
+
|
|
96
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
97
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
98
|
+
v_name || '_authz_select', v_q, v_scope, r.plugin || '.read');
|
|
99
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
100
|
+
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)))',
|
|
101
|
+
v_name || '_authz_update', v_q, v_scope, r.plugin || '.edit', r.plugin || '.edit');
|
|
102
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
103
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
104
|
+
v_name || '_authz_delete', v_q, v_scope, r.plugin || '.delete');
|
|
105
|
+
END $$;
|
|
106
|
+
REVOKE ALL ON FUNCTION app.scaffold_refresh_policies(text) FROM public, anon, authenticated;
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
-- ── scaffold_table, re-emitted so registration always declares a class ──
|
|
110
|
+
-- Lifted from 124 by transformation again; the only change is the registry
|
|
111
|
+
-- INSERT.
|
|
112
|
+
CREATE OR REPLACE FUNCTION app.scaffold_table(
|
|
113
|
+
p_table regclass,
|
|
114
|
+
p_resource_type text,
|
|
115
|
+
p_plugin text,
|
|
116
|
+
p_owner_scoping boolean DEFAULT true,
|
|
117
|
+
p_mode text DEFAULT 'enforce'
|
|
118
|
+
)
|
|
119
|
+
RETURNS void
|
|
120
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
121
|
+
AS $$
|
|
122
|
+
DECLARE
|
|
123
|
+
v_schema text;
|
|
124
|
+
v_name text;
|
|
125
|
+
v_q text; -- quoted qualified name
|
|
126
|
+
v_prev text[] := '{}';
|
|
127
|
+
v_rows bigint;
|
|
128
|
+
r record;
|
|
129
|
+
v_scope text; -- the row-scope predicate shared by every policy
|
|
130
|
+
BEGIN
|
|
131
|
+
IF p_mode NOT IN ('shadow', 'enforce') THEN
|
|
132
|
+
RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
|
|
133
|
+
END IF;
|
|
134
|
+
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
135
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
136
|
+
v_q := format('%I.%I', v_schema, v_name);
|
|
137
|
+
|
|
138
|
+
-- ── columns
|
|
139
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
|
|
140
|
+
RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
|
|
141
|
+
END IF;
|
|
142
|
+
EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
|
|
143
|
+
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
|
|
144
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
|
|
145
|
+
IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
|
|
146
|
+
END IF;
|
|
147
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
|
|
148
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
|
|
149
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
150
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
151
|
+
|
|
152
|
+
-- ── unique(tenant_id, id) — enables composite FKs from other tables
|
|
153
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
|
|
154
|
+
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
|
|
155
|
+
END IF;
|
|
156
|
+
-- ── composite FKs: unit within tenant, owner a member of the tenant
|
|
157
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
|
|
158
|
+
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');
|
|
159
|
+
END IF;
|
|
160
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
|
|
161
|
+
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');
|
|
162
|
+
END IF;
|
|
163
|
+
|
|
164
|
+
-- ── tenant-first indexes
|
|
165
|
+
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
|
|
166
|
+
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);
|
|
167
|
+
|
|
168
|
+
-- ── updated_at
|
|
169
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
|
|
170
|
+
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
|
|
171
|
+
|
|
172
|
+
-- ── RLS
|
|
173
|
+
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
|
|
174
|
+
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
|
|
175
|
+
IF p_mode = 'enforce' THEN
|
|
176
|
+
FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
|
|
177
|
+
AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
|
|
178
|
+
v_prev := v_prev || r.policyname;
|
|
179
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
|
|
180
|
+
END LOOP;
|
|
181
|
+
END IF;
|
|
182
|
+
|
|
183
|
+
v_scope := app.scaffold_scope_sql(p_resource_type, p_plugin);
|
|
184
|
+
|
|
185
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
186
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
187
|
+
v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
|
|
188
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
|
|
189
|
+
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)))',
|
|
190
|
+
v_name || '_authz_insert', v_q, p_plugin || '.create');
|
|
191
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
192
|
+
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)))',
|
|
193
|
+
v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
|
|
194
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
195
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
196
|
+
v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
|
|
197
|
+
|
|
198
|
+
-- scope defaults on REGISTRATION, so a table cannot exist in the registry
|
|
199
|
+
-- unclassified. A plugin's tables are scaffolded after the core chain has run,
|
|
200
|
+
-- so 136's DO block could never have reached them — they arrive here instead,
|
|
201
|
+
-- declared `unit_optional` until the plugin says otherwise.
|
|
202
|
+
INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, scope)
|
|
203
|
+
VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev, 'unit_optional')
|
|
204
|
+
ON CONFLICT (table_schema, table_name) DO UPDATE
|
|
205
|
+
SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
|
|
206
|
+
scope = coalesce(app.scaffold_registry.scope, EXCLUDED.scope),
|
|
207
|
+
mode = EXCLUDED.mode, applied_at = now(),
|
|
208
|
+
previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
|
|
209
|
+
THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
|
|
210
|
+
ELSE EXCLUDED.previous_policies END;
|
|
211
|
+
END $$;
|
|
212
|
+
|
|
213
|
+
-- ── declare a class, and make the schema say it ─────────────────────────
|
|
214
|
+
CREATE OR REPLACE FUNCTION app.scaffold_scope(p_table regclass, p_scope text, p_enforce boolean DEFAULT false)
|
|
215
|
+
RETURNS void
|
|
216
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
217
|
+
AS $$
|
|
218
|
+
DECLARE
|
|
219
|
+
v_schema text; v_name text; v_q text; v_bad bigint;
|
|
220
|
+
BEGIN
|
|
221
|
+
IF p_scope NOT IN ('unit', 'unit_optional', 'tenant') THEN
|
|
222
|
+
RAISE EXCEPTION 'scaffold_scope: scope must be unit | unit_optional | tenant';
|
|
223
|
+
END IF;
|
|
224
|
+
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
225
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
226
|
+
v_q := format('%I.%I', v_schema, v_name);
|
|
227
|
+
|
|
228
|
+
UPDATE app.scaffold_registry SET scope = p_scope
|
|
229
|
+
WHERE table_schema = v_schema AND table_name = v_name;
|
|
230
|
+
IF NOT FOUND THEN
|
|
231
|
+
RAISE EXCEPTION 'scaffold_scope: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
|
|
232
|
+
END IF;
|
|
233
|
+
DELETE FROM app.scaffold_scope_pending WHERE table_schema = v_schema AND table_name = v_name;
|
|
234
|
+
|
|
235
|
+
IF p_scope = 'unit' THEN
|
|
236
|
+
EXECUTE format('SELECT count(*) FROM %s WHERE unit_id IS NULL', v_q) INTO v_bad;
|
|
237
|
+
IF NOT p_enforce THEN
|
|
238
|
+
-- Declared, not enforced — and recorded, so it is a list and not a silence.
|
|
239
|
+
-- p_enforce is an explicit decision because "the table has no offending
|
|
240
|
+
-- rows" turned out to be worthless evidence: nine suites went red on the
|
|
241
|
+
-- first attempt at NOT NULL, and every one of them was an EMPTY table whose
|
|
242
|
+
-- writers do not fill unit_id. The constraint is about the writers.
|
|
243
|
+
INSERT INTO app.scaffold_scope_pending (table_schema, table_name, scope, offending)
|
|
244
|
+
VALUES (v_schema, v_name, p_scope, v_bad);
|
|
245
|
+
ELSIF v_bad > 0 THEN
|
|
246
|
+
RAISE EXCEPTION 'scaffold_scope: % was declared enforceable but holds % row(s) without a unit', v_q, v_bad;
|
|
247
|
+
ELSE
|
|
248
|
+
EXECUTE format('ALTER TABLE %s ALTER COLUMN unit_id SET NOT NULL', v_q);
|
|
249
|
+
END IF;
|
|
250
|
+
|
|
251
|
+
ELSIF p_scope = 'tenant' THEN
|
|
252
|
+
EXECUTE format('SELECT count(*) FROM %s WHERE unit_id IS NOT NULL', v_q) INTO v_bad;
|
|
253
|
+
IF v_bad = 0 THEN
|
|
254
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_id_null_chk') THEN
|
|
255
|
+
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I CHECK (unit_id IS NULL)', v_q, v_name || '_unit_id_null_chk');
|
|
256
|
+
END IF;
|
|
257
|
+
ELSE
|
|
258
|
+
INSERT INTO app.scaffold_scope_pending (table_schema, table_name, scope, offending)
|
|
259
|
+
VALUES (v_schema, v_name, p_scope, v_bad);
|
|
260
|
+
RAISE NOTICE '126: % has % row(s) with a unit_id but is classified tenant (recorded)', v_q, v_bad;
|
|
261
|
+
END IF;
|
|
262
|
+
END IF;
|
|
263
|
+
|
|
264
|
+
PERFORM app.scaffold_refresh_policies(v_q);
|
|
265
|
+
-- a masked view renders the same predicate, so it has to learn the class too
|
|
266
|
+
PERFORM app.masked_view_refresh(p_table, m.context, m.reference)
|
|
267
|
+
FROM app.masked_views m
|
|
268
|
+
WHERE m.table_schema = v_schema AND m.table_name = v_name;
|
|
269
|
+
END $$;
|
|
270
|
+
REVOKE ALL ON FUNCTION app.scaffold_scope(regclass, text, boolean) FROM public, anon, authenticated;
|
|
271
|
+
|
|
272
|
+
COMMENT ON FUNCTION app.scaffold_scope(regclass, text, boolean) IS
|
|
273
|
+
'Declare what unit_id means on a scaffolded table and make the schema enforce it (#225). Folding this into app.scaffold_table''s signature waits for #224, which changes that signature anyway to add `distributable` — one signature change instead of two.';
|
|
274
|
+
|
|
275
|
+
-- ── classify all 47 ─────────────────────────────────────────────────────
|
|
276
|
+
-- ENFORCEMENT IS EVIDENCE, NOT INFERENCE.
|
|
277
|
+
--
|
|
278
|
+
-- The first version of this block applied NOT NULL wherever no row violated it.
|
|
279
|
+
-- Nine suites went red — orders, the migration ledger's extractor writers, two
|
|
280
|
+
-- financial suites — and every one of those tables was EMPTY when the constraint
|
|
281
|
+
-- landed. An empty table proves nothing about whether its writers fill the
|
|
282
|
+
-- column; it only proves nobody has written yet. So NOT NULL is applied to the
|
|
283
|
+
-- tables whose writers are demonstrated by the suites to fill a unit, and every
|
|
284
|
+
-- other `unit` table is declared and listed instead.
|
|
285
|
+
--
|
|
286
|
+
-- The declaration is most of the value on its own: it is what makes the NULL on
|
|
287
|
+
-- `holidays` legibly different from the NULL on `orders`, and it is what a
|
|
288
|
+
-- reader (and #224) can check.
|
|
289
|
+
DO $$
|
|
290
|
+
DECLARE
|
|
291
|
+
-- writers proven to fill a unit: the inventory ledger and the access grants
|
|
292
|
+
v_unit_enforced text[] := ARRAY[
|
|
293
|
+
'plg_inventory_stock_locations','plg_inventory_stock_movements',
|
|
294
|
+
'plg_inventory_stock_positions','plg_inventory_reservations','access_grants'];
|
|
295
|
+
-- declared `unit` — this is where it happened — but the writers have not been
|
|
296
|
+
-- audited yet, so the column stays nullable and the table stays on the list
|
|
297
|
+
v_unit_declared text[] := ARRAY[
|
|
298
|
+
'appointments','appointment_items','orders','order_items','schedules',
|
|
299
|
+
'plg_financial_movements','plg_financial_invoices','plg_financial_invoice_installments',
|
|
300
|
+
'plg_financial_allocations','plg_financial_nfe','plg_financial_cash_register_sessions',
|
|
301
|
+
'plg_financial_cash_session_reviewers'];
|
|
302
|
+
-- `transactions` is deliberately in neither: it is the pre-plugin money row
|
|
303
|
+
-- (#187) and its writers never learned about units. Declaring it `unit` would
|
|
304
|
+
-- describe an intention nobody holds. unit_optional until #187 moves that money
|
|
305
|
+
-- into the ledger, which is the change that would give each row a branch
|
|
306
|
+
-- honestly.
|
|
307
|
+
-- NOTHING in core is `tenant` today, and finding that out is the useful part.
|
|
308
|
+
--
|
|
309
|
+
-- The catalogue was going to be: 075 says in prose that it is "a tenant-wide
|
|
310
|
+
-- resource: unit_id is null by default", so a CHECK looked like that sentence
|
|
311
|
+
-- made enforceable. 085's own pgTAP says otherwise — 002_release_visibility
|
|
312
|
+
-- creates "Vela B2", a product that BELONGS to a branch, and asserts it behaves
|
|
313
|
+
-- that way. The RESTRICTIVE policy's `unit_id IS NOT NULL OR available_in…`
|
|
314
|
+
-- exists for exactly that row.
|
|
315
|
+
--
|
|
316
|
+
-- So "two ways to say only at Centro" was overstated, and this file is where it
|
|
317
|
+
-- gets corrected: `unit_id` set means the branch's OWN item, which is never
|
|
318
|
+
-- shared; a grant means the NETWORK's item, released there. Same visible
|
|
319
|
+
-- effect, different things, and only the second is distribution. `tenant` stays
|
|
320
|
+
-- in the vocabulary for #224 to apply where distribution really is the only
|
|
321
|
+
-- mechanism — the class is exercised by 055/004 either way.
|
|
322
|
+
v_tenant text[] := ARRAY[]::text[];
|
|
323
|
+
t text; v_n integer := 0;
|
|
324
|
+
BEGIN
|
|
325
|
+
FOREACH t IN ARRAY v_unit_enforced LOOP
|
|
326
|
+
CONTINUE WHEN to_regclass('public.' || t) IS NULL;
|
|
327
|
+
-- The spine runs before the plugins, so on a FRESH pool a plugin's table is
|
|
328
|
+
-- simply absent here and the guard above skips it. On a pool that already
|
|
329
|
+
-- carries an older install of that plugin the table IS present while its own
|
|
330
|
+
-- scaffold file has not run yet, and scaffold_scope refuses a table it has
|
|
331
|
+
-- never registered. Skipping is right in both cases: the plugin's scaffold
|
|
332
|
+
-- classifies it when it runs, a few files later in this same chain.
|
|
333
|
+
CONTINUE WHEN NOT EXISTS (
|
|
334
|
+
SELECT 1 FROM app.scaffold_registry
|
|
335
|
+
WHERE table_schema = 'public' AND table_name = t);
|
|
336
|
+
PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit', true); v_n := v_n + 1;
|
|
337
|
+
END LOOP;
|
|
338
|
+
FOREACH t IN ARRAY v_unit_declared LOOP
|
|
339
|
+
CONTINUE WHEN to_regclass('public.' || t) IS NULL;
|
|
340
|
+
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM app.scaffold_registry
|
|
341
|
+
WHERE table_schema = 'public' AND table_name = t);
|
|
342
|
+
PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit', false); v_n := v_n + 1;
|
|
343
|
+
END LOOP;
|
|
344
|
+
FOREACH t IN ARRAY v_tenant LOOP
|
|
345
|
+
CONTINUE WHEN to_regclass('public.' || t) IS NULL;
|
|
346
|
+
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM app.scaffold_registry
|
|
347
|
+
WHERE table_schema = 'public' AND table_name = t);
|
|
348
|
+
PERFORM app.scaffold_scope(('public.' || t)::regclass, 'tenant'); v_n := v_n + 1;
|
|
349
|
+
END LOOP;
|
|
350
|
+
FOR t IN SELECT r.table_name FROM app.scaffold_registry r
|
|
351
|
+
WHERE r.scope IS NULL AND r.table_schema = 'public'
|
|
352
|
+
AND to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NOT NULL LOOP
|
|
353
|
+
-- No registry guard here: this loop reads FROM the registry.
|
|
354
|
+
PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit_optional'); v_n := v_n + 1;
|
|
355
|
+
END LOOP;
|
|
356
|
+
RAISE NOTICE '126: classified % scaffolded table(s); % awaiting a writer audit',
|
|
357
|
+
v_n, (SELECT count(*) FROM app.scaffold_scope_pending);
|
|
358
|
+
END $$;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 137_vendor_named_spine_objects.sql — the schema stops naming the vendor (#181).
|
|
3
|
+
--
|
|
4
|
+
-- `public.fayz_projects` holds THE SITE A TENANT PUBLISHES. The provider might be
|
|
5
|
+
-- Fayz, Wix, WordPress or a custom host — that is an attribute of the row, not
|
|
6
|
+
-- the identity of the table. Same for the search registry.
|
|
7
|
+
--
|
|
8
|
+
-- public.fayz_projects → public.published_sites
|
|
9
|
+
-- fayz_project_id → provider_site_id
|
|
10
|
+
-- (new) → provider text NOT NULL DEFAULT 'fayz'
|
|
11
|
+
-- public.fayz_search_sources → public.search_sources
|
|
12
|
+
-- public.fayz_global_search() → public.global_search()
|
|
13
|
+
--
|
|
14
|
+
-- WHAT DELIBERATELY KEEPS THE PREFIX, and why it is not the same smell:
|
|
15
|
+
--
|
|
16
|
+
-- fayz_norm / fayz_digits / fayz_cat — SQL helpers whose names are baked into
|
|
17
|
+
-- the `haystack_expr` STRINGS stored in the registry rows. Renaming them is a
|
|
18
|
+
-- data migration of every deployed row, for functions that are internal
|
|
19
|
+
-- plumbing rather than an entity anyone names. Not worth the blast radius.
|
|
20
|
+
-- fayz_audit_prune / fayz_authz_reconcile / fayz_sync_* — pg_cron job names.
|
|
21
|
+
-- Cron lives in a database shared with everything else on the instance; there
|
|
22
|
+
-- the prefix IS the namespace, which is the opposite of a smell.
|
|
23
|
+
-- fayz_functions_url / fayz_scheduler_secret — vault secret keys. Same
|
|
24
|
+
-- argument: a shared keyspace wants a prefix.
|
|
25
|
+
--
|
|
26
|
+
-- NO COMPAT VIEW UNDER THE OLD NAME. 097 settled this: frozen migrations re-run
|
|
27
|
+
-- `ALTER TABLE` and `CREATE POLICY` against the old name on a replay, and those
|
|
28
|
+
-- fail on a view. A guarded rename converges instead — it fires only when the old
|
|
29
|
+
-- name is there and the new one is not.
|
|
30
|
+
--
|
|
31
|
+
-- ⚠ CROSS-REPO: the platform (ymaia) writes these rows. There is no compat layer,
|
|
32
|
+
-- so ymaia's writers move to `published_sites` / `provider_site_id` in the same
|
|
33
|
+
-- deploy. That coordination is the whole reason #181 carried `decision-needed`.
|
|
34
|
+
--
|
|
35
|
+
-- Replay-safe: every step guarded on the object's current name.
|
|
36
|
+
-- ============================================================================
|
|
37
|
+
|
|
38
|
+
-- ── public.fayz_projects → public.published_sites ───────────────────────
|
|
39
|
+
DO $$
|
|
40
|
+
BEGIN
|
|
41
|
+
IF to_regclass('public.fayz_projects') IS NOT NULL AND to_regclass('public.published_sites') IS NULL THEN
|
|
42
|
+
ALTER TABLE public.fayz_projects RENAME TO published_sites;
|
|
43
|
+
|
|
44
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
45
|
+
WHERE table_schema = 'public' AND table_name = 'published_sites' AND column_name = 'fayz_project_id') THEN
|
|
46
|
+
ALTER TABLE public.published_sites RENAME COLUMN fayz_project_id TO provider_site_id;
|
|
47
|
+
END IF;
|
|
48
|
+
|
|
49
|
+
ALTER INDEX IF EXISTS public.fayz_projects_tenant_idx RENAME TO published_sites_tenant_idx;
|
|
50
|
+
ALTER INDEX IF EXISTS public.fayz_projects_one_default_per_tenant RENAME TO published_sites_one_default_per_tenant;
|
|
51
|
+
ALTER INDEX IF EXISTS public.fayz_projects_pkey RENAME TO published_sites_pkey;
|
|
52
|
+
|
|
53
|
+
DROP TRIGGER IF EXISTS fayz_projects_updated_at ON public.published_sites;
|
|
54
|
+
DROP TRIGGER IF EXISTS published_sites_updated_at ON public.published_sites;
|
|
55
|
+
CREATE TRIGGER published_sites_updated_at BEFORE UPDATE ON public.published_sites
|
|
56
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
57
|
+
|
|
58
|
+
DROP POLICY IF EXISTS "fayz_projects_member_all" ON public.published_sites;
|
|
59
|
+
DROP POLICY IF EXISTS "published_sites_member_all" ON public.published_sites;
|
|
60
|
+
CREATE POLICY "published_sites_member_all" ON public.published_sites FOR ALL TO authenticated
|
|
61
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
62
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
63
|
+
|
|
64
|
+
RAISE NOTICE '127: fayz_projects → published_sites';
|
|
65
|
+
END IF;
|
|
66
|
+
END $$;
|
|
67
|
+
|
|
68
|
+
-- the provider becomes a column, which is the point of the rename
|
|
69
|
+
ALTER TABLE public.published_sites ADD COLUMN IF NOT EXISTS provider text NOT NULL DEFAULT 'fayz';
|
|
70
|
+
|
|
71
|
+
COMMENT ON TABLE public.published_sites IS
|
|
72
|
+
'A site or app a tenant publishes. `provider` says who hosts it (fayz | wix | wordpress | custom | …) and `provider_site_id` is that provider''s own id — the vendor is an attribute here, never the table''s identity (#181).';
|
|
73
|
+
COMMENT ON COLUMN public.published_sites.provider IS
|
|
74
|
+
'Who hosts this site. Defaults to fayz because every row that existed before #181 was a fayz project.';
|
|
75
|
+
COMMENT ON COLUMN public.published_sites.provider_site_id IS
|
|
76
|
+
'The provider''s own id for this site (was fayz_project_id). Text: each provider issues its own.';
|
|
77
|
+
|
|
78
|
+
-- ── public.fayz_search_sources → public.search_sources ──────────────────
|
|
79
|
+
DO $$
|
|
80
|
+
BEGIN
|
|
81
|
+
IF to_regclass('public.fayz_search_sources') IS NOT NULL AND to_regclass('public.search_sources') IS NULL THEN
|
|
82
|
+
ALTER TABLE public.fayz_search_sources RENAME TO search_sources;
|
|
83
|
+
ALTER INDEX IF EXISTS public.fayz_search_sources_pkey RENAME TO search_sources_pkey;
|
|
84
|
+
DROP POLICY IF EXISTS "fayz_search_sources_read" ON public.search_sources;
|
|
85
|
+
DROP POLICY IF EXISTS "search_sources_read" ON public.search_sources;
|
|
86
|
+
CREATE POLICY "search_sources_read" ON public.search_sources FOR SELECT TO authenticated USING (true);
|
|
87
|
+
RAISE NOTICE '127: fayz_search_sources → search_sources';
|
|
88
|
+
END IF;
|
|
89
|
+
END $$;
|
|
90
|
+
|
|
91
|
+
COMMENT ON TABLE public.search_sources IS
|
|
92
|
+
'Registry of searchable relations for public.global_search(). Readable by any member, writable only by the migration role — the expressions are executed as SQL.';
|
|
93
|
+
|
|
94
|
+
-- ── public.fayz_global_search() → public.global_search() ────────────────
|
|
95
|
+
-- Renamed by re-emitting under the new name and dropping the old, rather than
|
|
96
|
+
-- ALTER FUNCTION … RENAME: the body reads `fayz_search_sources`, which no longer
|
|
97
|
+
-- exists, so it has to be rewritten anyway.
|
|
98
|
+
DO $$
|
|
99
|
+
DECLARE v_src text; v_args text;
|
|
100
|
+
BEGIN
|
|
101
|
+
-- Already renamed? Then leave both names alone. 141 puts a compat WRAPPER back
|
|
102
|
+
-- under `fayz_global_search`, so a re-run of this file finds that wrapper and
|
|
103
|
+
-- would try to convert it into `global_search` a second time — over a function
|
|
104
|
+
-- that exists, with a signature that no longer matches, which fails on the
|
|
105
|
+
-- parameter defaults. The rename is done; the old name answering again is the
|
|
106
|
+
-- bridge doing its job, not work left over.
|
|
107
|
+
IF to_regproc('public.global_search') IS NOT NULL THEN
|
|
108
|
+
RETURN;
|
|
109
|
+
END IF;
|
|
110
|
+
|
|
111
|
+
SELECT pg_get_functiondef(p.oid), pg_get_function_identity_arguments(p.oid)
|
|
112
|
+
INTO v_src, v_args
|
|
113
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
114
|
+
WHERE n.nspname = 'public' AND p.proname = 'fayz_global_search'
|
|
115
|
+
LIMIT 1;
|
|
116
|
+
|
|
117
|
+
IF v_src IS NOT NULL THEN
|
|
118
|
+
v_src := replace(v_src, 'public.fayz_global_search', 'public.global_search');
|
|
119
|
+
v_src := replace(v_src, 'fayz_search_sources', 'search_sources');
|
|
120
|
+
EXECUTE v_src;
|
|
121
|
+
EXECUTE format('DROP FUNCTION IF EXISTS public.fayz_global_search(%s)', v_args);
|
|
122
|
+
EXECUTE 'REVOKE ALL ON FUNCTION public.global_search(' || v_args || ') FROM public, anon';
|
|
123
|
+
EXECUTE 'GRANT EXECUTE ON FUNCTION public.global_search(' || v_args || ') TO authenticated, service_role';
|
|
124
|
+
RAISE NOTICE '127: fayz_global_search() → global_search()';
|
|
125
|
+
END IF;
|
|
126
|
+
END $$;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 138_grants_inherit_down_the_tree.sql — distribution follows the hierarchy that
|
|
3
|
+
-- access already follows (#227).
|
|
4
|
+
--
|
|
5
|
+
-- `user_unit_access` fans out by subtree: managing `f1` gives you `f1` and every
|
|
6
|
+
-- branch under it, `via = 'subtree'`. `has_grant` did not: it matched a unit
|
|
7
|
+
-- tuple only when the granted unit was LITERALLY one of your access rows. So:
|
|
8
|
+
--
|
|
9
|
+
-- grant names f1, caller manages f1 → matched
|
|
10
|
+
-- grant names b1, caller manages f1 → matched (b1 arrived by subtree)
|
|
11
|
+
-- grant names f1, caller works at b1 → DID NOT MATCH ← the bug
|
|
12
|
+
--
|
|
13
|
+
-- "Release this service to the whole f1 region" reached the regional manager and
|
|
14
|
+
-- not the people who work in its branches — the ones meant to sell it. Nobody
|
|
15
|
+
-- reads that off the schema: you grant to a region, test as someone who holds
|
|
16
|
+
-- the region, and ship.
|
|
17
|
+
--
|
|
18
|
+
-- PRECEDENCE, which is the part that needed deciding
|
|
19
|
+
--
|
|
20
|
+
-- Walking a chain means allow and deny can meet at different depths, so:
|
|
21
|
+
--
|
|
22
|
+
-- MOST SPECIFIC WINS. For each unit in scope, walk from the unit up to the
|
|
23
|
+
-- root and stop at the first level carrying any tuple. Deny beats allow AT
|
|
24
|
+
-- THAT LEVEL.
|
|
25
|
+
--
|
|
26
|
+
-- That is what makes both directions sayable: "the whole f1 region except b2" is
|
|
27
|
+
-- deny at f1 + allow at b2, and "nowhere except b1" is one allow at b1. A blanket
|
|
28
|
+
-- deny-always-wins would make the first one unsayable, and #223's rule ("deny
|
|
29
|
+
-- wins") survives intact — it was always a statement about one level.
|
|
30
|
+
--
|
|
31
|
+
-- User- and role-subject tuples sit outside the walk entirely: the subject is the
|
|
32
|
+
-- person, and a person is not in the tree.
|
|
33
|
+
--
|
|
34
|
+
-- Implementation (a) of the two the issue listed: walk at query time. The tree is
|
|
35
|
+
-- shallow — tenant → region → branch — so this is a handful of index lookups, and
|
|
36
|
+
-- it adds no second projection to keep honest. `user_unit_access` is materialised
|
|
37
|
+
-- because it is read for every row of every query; a grant chain is read once per
|
|
38
|
+
-- resource, which is a different problem.
|
|
39
|
+
--
|
|
40
|
+
-- Replay-safe: CREATE OR REPLACE only.
|
|
41
|
+
-- ============================================================================
|
|
42
|
+
|
|
43
|
+
-- ── the chain from a unit up to its root ────────────────────────────────
|
|
44
|
+
CREATE OR REPLACE FUNCTION app.unit_chain(p_unit uuid)
|
|
45
|
+
RETURNS TABLE (unit_id uuid, depth integer)
|
|
46
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
47
|
+
SET search_path = ''
|
|
48
|
+
AS $$
|
|
49
|
+
WITH RECURSIVE up AS (
|
|
50
|
+
SELECT u.id, u.parent_unit_id, 0 AS depth
|
|
51
|
+
FROM app.units u WHERE u.id = p_unit
|
|
52
|
+
UNION ALL
|
|
53
|
+
SELECT u.id, u.parent_unit_id, up.depth + 1
|
|
54
|
+
FROM app.units u JOIN up ON u.id = up.parent_unit_id
|
|
55
|
+
WHERE up.depth < 32 -- a cycle cannot happen (parent <> id, FK per tenant), but a bound is cheap
|
|
56
|
+
)
|
|
57
|
+
SELECT up.id, up.depth FROM up;
|
|
58
|
+
$$;
|
|
59
|
+
REVOKE ALL ON FUNCTION app.unit_chain(uuid) FROM public, anon;
|
|
60
|
+
GRANT EXECUTE ON FUNCTION app.unit_chain(uuid) TO authenticated, service_role;
|
|
61
|
+
|
|
62
|
+
COMMENT ON FUNCTION app.unit_chain(uuid) IS
|
|
63
|
+
'A unit and its ancestors, nearest first (#227). Distribution tuples are resolved along this chain so a release to a region reaches its branches.';
|
|
64
|
+
|
|
65
|
+
-- ── what the tuples say about one unit ──────────────────────────────────
|
|
66
|
+
-- 'allow' | 'deny' | 'none'. The first level of the chain carrying any tuple
|
|
67
|
+
-- decides, and deny beats allow at that level.
|
|
68
|
+
CREATE OR REPLACE FUNCTION app.unit_release_verdict(p_type text, p_id uuid, p_unit uuid)
|
|
69
|
+
RETURNS text
|
|
70
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
71
|
+
SET search_path = ''
|
|
72
|
+
AS $$
|
|
73
|
+
SELECT coalesce(
|
|
74
|
+
(SELECT CASE WHEN bool_or(g.relation = 'unavailable_in') THEN 'deny' ELSE 'allow' END
|
|
75
|
+
FROM app.unit_chain(p_unit) c
|
|
76
|
+
JOIN app.resource_grants g
|
|
77
|
+
ON g.tenant_id = app.current_tenant_id()
|
|
78
|
+
AND g.resource_type = p_type AND g.resource_id = p_id
|
|
79
|
+
AND g.subject_type = 'unit' AND g.subject_id = c.unit_id
|
|
80
|
+
AND g.relation IN ('available_in', 'unavailable_in')
|
|
81
|
+
GROUP BY c.depth
|
|
82
|
+
ORDER BY c.depth
|
|
83
|
+
LIMIT 1),
|
|
84
|
+
'none');
|
|
85
|
+
$$;
|
|
86
|
+
REVOKE ALL ON FUNCTION app.unit_release_verdict(text, uuid, uuid) FROM public, anon;
|
|
87
|
+
GRANT EXECUTE ON FUNCTION app.unit_release_verdict(text, uuid, uuid) TO authenticated, service_role;
|
|
88
|
+
|
|
89
|
+
COMMENT ON FUNCTION app.unit_release_verdict(text, uuid, uuid) IS
|
|
90
|
+
'allow | deny | none for one unit (#227): the nearest level of its chain that carries a distribution tuple decides, and deny beats allow at that level. Most specific wins, which is what makes "the whole region except one branch" sayable.';
|
|
91
|
+
|
|
92
|
+
-- ── the release rule, resolved along the tree ───────────────────────────
|
|
93
|
+
-- Replaces the flat version of 125. The three shapes it has to keep true:
|
|
94
|
+
-- no tuples at all → available (the default that predates #223)
|
|
95
|
+
-- only inclusions → available where one names a unit in scope
|
|
96
|
+
-- inclusions and exclusions mixed → nearest level decides, per unit in scope
|
|
97
|
+
CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
|
|
98
|
+
RETURNS boolean
|
|
99
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
100
|
+
SET search_path = ''
|
|
101
|
+
AS $$
|
|
102
|
+
WITH me AS (
|
|
103
|
+
SELECT app.current_tenant_id() AS tenant_id,
|
|
104
|
+
auth.uid() AS uid,
|
|
105
|
+
nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
|
|
106
|
+
),
|
|
107
|
+
tup AS (
|
|
108
|
+
SELECT g.relation, g.subject_type, g.subject_id
|
|
109
|
+
FROM app.resource_grants g, me
|
|
110
|
+
WHERE g.tenant_id = me.tenant_id AND g.resource_type = p_type AND g.resource_id = p_id
|
|
111
|
+
AND g.relation IN ('available_in', 'unavailable_in')
|
|
112
|
+
),
|
|
113
|
+
-- me, or a role of mine: outside the tree, so it simply decides
|
|
114
|
+
direct AS (
|
|
115
|
+
SELECT t.relation FROM tup t, me
|
|
116
|
+
WHERE (t.subject_type = 'user' AND t.subject_id = me.uid)
|
|
117
|
+
OR (t.subject_type = 'role' AND t.subject_id IN (
|
|
118
|
+
SELECT m.role_id FROM app.memberships m
|
|
119
|
+
WHERE m.tenant_id = me.tenant_id AND m.user_id = me.uid AND m.active
|
|
120
|
+
UNION
|
|
121
|
+
SELECT mu.role_override_id FROM app.membership_units mu
|
|
122
|
+
WHERE mu.tenant_id = me.tenant_id AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL))
|
|
123
|
+
),
|
|
124
|
+
mine AS (
|
|
125
|
+
SELECT a.unit_id FROM app.user_unit_access a, me
|
|
126
|
+
WHERE a.tenant_id = me.tenant_id AND a.user_id = me.uid
|
|
127
|
+
AND (me.focus IS NULL OR a.unit_id = me.focus)
|
|
128
|
+
),
|
|
129
|
+
verdicts AS (
|
|
130
|
+
SELECT app.unit_release_verdict(p_type, p_id, mine.unit_id) AS v FROM mine
|
|
131
|
+
)
|
|
132
|
+
SELECT NOT EXISTS (SELECT 1 FROM direct WHERE relation = 'unavailable_in')
|
|
133
|
+
AND (
|
|
134
|
+
EXISTS (SELECT 1 FROM direct WHERE relation = 'available_in')
|
|
135
|
+
OR EXISTS (SELECT 1 FROM verdicts WHERE v = 'allow')
|
|
136
|
+
OR (NOT EXISTS (SELECT 1 FROM tup WHERE relation = 'available_in')
|
|
137
|
+
AND (EXISTS (SELECT 1 FROM verdicts WHERE v = 'none') OR NOT EXISTS (SELECT 1 FROM mine)))
|
|
138
|
+
);
|
|
139
|
+
$$;
|
|
140
|
+
|
|
141
|
+
COMMENT ON FUNCTION app.available_in_my_units(text, uuid) IS
|
|
142
|
+
'The release rule, resolved along the unit tree (#223 + #227): no tuples ⇒ everywhere; otherwise the nearest level of each in-scope unit''s chain decides, deny beating allow at that level. A caller holding no units is not excluded by a rule about units.';
|
|
143
|
+
|
|
144
|
+
-- ── has_grant: a unit tuple reaches the branches under it ───────────────
|
|
145
|
+
-- 135's version with the ancestor walk. `shared_with` inherits for the same
|
|
146
|
+
-- reason distribution does: sharing a record with a region has to mean sharing it
|
|
147
|
+
-- with the people who work there, or the word means nothing.
|
|
148
|
+
CREATE OR REPLACE FUNCTION app.has_grant(p_type text, p_id uuid, p_relations text[])
|
|
149
|
+
RETURNS boolean
|
|
150
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
151
|
+
SET search_path = ''
|
|
152
|
+
AS $$
|
|
153
|
+
SELECT EXISTS (
|
|
154
|
+
SELECT 1
|
|
155
|
+
FROM app.resource_grants g
|
|
156
|
+
WHERE g.tenant_id = app.current_tenant_id()
|
|
157
|
+
AND g.resource_type = p_type
|
|
158
|
+
AND g.resource_id = p_id
|
|
159
|
+
AND g.relation = ANY (p_relations)
|
|
160
|
+
AND (
|
|
161
|
+
(g.subject_type = 'user' AND g.subject_id = auth.uid())
|
|
162
|
+
OR (g.subject_type = 'unit' AND EXISTS (
|
|
163
|
+
SELECT 1 FROM app.user_unit_access a
|
|
164
|
+
WHERE a.tenant_id = g.tenant_id AND a.user_id = auth.uid()
|
|
165
|
+
AND (app.current_unit_id() IS NULL OR a.unit_id = app.current_unit_id())
|
|
166
|
+
AND EXISTS (SELECT 1 FROM app.unit_chain(a.unit_id) c WHERE c.unit_id = g.subject_id)))
|
|
167
|
+
OR (g.subject_type = 'role' AND g.subject_id IN (
|
|
168
|
+
SELECT m.role_id FROM app.memberships m
|
|
169
|
+
WHERE m.tenant_id = g.tenant_id AND m.user_id = auth.uid() AND m.active
|
|
170
|
+
UNION
|
|
171
|
+
SELECT mu.role_override_id FROM app.membership_units mu
|
|
172
|
+
WHERE mu.tenant_id = g.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL))
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
$$;
|
|
176
|
+
|
|
177
|
+
COMMENT ON FUNCTION app.has_grant(text, uuid, text[]) IS
|
|
178
|
+
'resource_grants where the subject is me, a role of mine, or a unit at or above one in scope (#220/#223/#227). Inheritance runs DOWN the tree: a tuple naming a region reaches the branches under it.';
|