@fayz-ai/db 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
-- 125_migration_authz_people_writers.sql
|
|
2
|
+
--
|
|
3
|
+
-- The last of the extractor's destinations get an answer. #137 / #136 / #148.
|
|
4
|
+
--
|
|
5
|
+
-- Three writers, and six decisions. The decisions are the point of this file:
|
|
6
|
+
-- every one of them was a row that would otherwise have sat `pending` forever,
|
|
7
|
+
-- and most of them turn out to say the same thing.
|
|
8
|
+
--
|
|
9
|
+
-- THE IDENTITY BRIDGE THAT DOES NOT EXIST. `app.memberships.user_id` is an FK to
|
|
10
|
+
-- `auth.users`. Nothing in the allowlist maps a V1 login to a V2 one -- `profiles`
|
|
11
|
+
-- explicitly drops its id (`"id":"-"`) -- so there is no way to say which V2
|
|
12
|
+
-- account a V1 membership belongs to. That single gap is why `license_users`,
|
|
13
|
+
-- `employee_permission_rules`, `professional_permission_rules` and (in 114)
|
|
14
|
+
-- `cash_register_sessions` are all deferred: each needs to attribute something to
|
|
15
|
+
-- a person's LOGIN, and the login does not cross. It is one missing mapping, not
|
|
16
|
+
-- four missing writers, and it is worth naming as such rather than four times.
|
|
17
|
+
--
|
|
18
|
+
-- WHAT IS WRITTEN:
|
|
19
|
+
--
|
|
20
|
+
-- permission_rules -> app.roles. A V1 rule IS a tenant role, and 091 says so.
|
|
21
|
+
-- permission_rule_details -> app.role_permissions, TRANSLATED through
|
|
22
|
+
-- `app.v1_permission_map` -- the table #136 built for exactly this. A V1 code
|
|
23
|
+
-- with no V2 key quarantines rather than being invented, which is what the
|
|
24
|
+
-- map's `backlog` rows already mean.
|
|
25
|
+
-- client_registrations -> public.person_registrations, which is where a
|
|
26
|
+
-- pre-registration belongs. 091 pointed it at `public.people`, and that is
|
|
27
|
+
-- wrong in a way that matters: a registration is a QUEUE ENTRY awaiting dedup
|
|
28
|
+
-- (#137's whole slice), not a Person. Writing it as a Person would skip the
|
|
29
|
+
-- dedup and mint duplicates of people who already exist.
|
|
30
|
+
--
|
|
31
|
+
-- WHAT IS NOT, AND WHY:
|
|
32
|
+
--
|
|
33
|
+
-- licenses and system_permissions become `verify_only`. Both destinations are
|
|
34
|
+
-- built by something other than the extractor -- a tenant is provisioned by a
|
|
35
|
+
-- human before `batch_open`, and the permission catalog is GENERATED from
|
|
36
|
+
-- declaredFeatures (#136) -- so the extractor's job is to prove they agree,
|
|
37
|
+
-- not to write them. Their own notes already said so.
|
|
38
|
+
-- service_locations is deferred by its own note: "a service location is a place
|
|
39
|
+
-- INSIDE a unit, not a unit". Pointing it at `app.units` would turn every
|
|
40
|
+
-- treatment room into a branch.
|
|
41
|
+
-- client_access_tokens: the note says the token value never migrates and is
|
|
42
|
+
-- re-issued in V2, which leaves nothing to write.
|
|
43
|
+
-- professions is a reference list, like origins and partnerships (#110).
|
|
44
|
+
-- profiles: its own note ends "no writer" -- the login profile is folded into
|
|
45
|
+
-- the Person of the member.
|
|
46
|
+
--
|
|
47
|
+
-- Replay-safe: `_allow` upserts under the documented unlock; writers are
|
|
48
|
+
-- CREATE OR REPLACE.
|
|
49
|
+
|
|
50
|
+
ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;
|
|
51
|
+
ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
|
|
52
|
+
CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice',
|
|
53
|
+
'price_table', 'price_table_item', 'package', 'package_item',
|
|
54
|
+
'field_rule', 'holiday', 'facet',
|
|
55
|
+
'fin_account', 'fin_payment_method_type', 'fin_payment_method',
|
|
56
|
+
'fin_card_brand', 'fin_chart_account', 'fin_cost_center',
|
|
57
|
+
'inv_measurement_unit', 'inv_unit_conversion', 'inv_location', 'inv_movement',
|
|
58
|
+
'order_item', 'fin_movement',
|
|
59
|
+
'authz_role', 'authz_role_permission', 'person_registration'));
|
|
60
|
+
|
|
61
|
+
DO $unlock$
|
|
62
|
+
BEGIN
|
|
63
|
+
PERFORM set_config('migration.allowlist_unlock', 'on', true);
|
|
64
|
+
|
|
65
|
+
UPDATE migration.allowlist SET writer = 'authz_role' WHERE source_table = 'permission_rules';
|
|
66
|
+
UPDATE migration.allowlist SET writer = 'authz_role_permission' WHERE source_table = 'permission_rule_details';
|
|
67
|
+
|
|
68
|
+
UPDATE migration.allowlist SET writer = 'person_registration', target_table = 'public.person_registrations',
|
|
69
|
+
notes = 'PRD 03 R6: a pre-registration is a QUEUE ENTRY awaiting dedup, not a Person. 091 pointed it at public.people, which would skip the dedup and mint duplicates of people who already exist (#115).'
|
|
70
|
+
WHERE source_table = 'client_registrations';
|
|
71
|
+
|
|
72
|
+
UPDATE migration.allowlist SET writer_args = '{"verify_only":true}'::jsonb,
|
|
73
|
+
notes = 'ADR 0002: a tenant is provisioned by a human before batch_open. The extractor proves the map, it does not create tenants — this row''s own note said so (#115).'
|
|
74
|
+
WHERE source_table = 'licenses';
|
|
75
|
+
UPDATE migration.allowlist SET writer_args = '{"verify_only":true}'::jsonb,
|
|
76
|
+
notes = '#136: the permission catalog is GENERATED from declaredFeatures and seeded by migrations, not migrated row by row. The V1 codes are translated through app.v1_permission_map (#115).'
|
|
77
|
+
WHERE source_table = 'system_permissions';
|
|
78
|
+
|
|
79
|
+
DELETE FROM migration.allowlist
|
|
80
|
+
WHERE source_table IN ('service_locations', 'license_users', 'employee_permission_rules',
|
|
81
|
+
'professional_permission_rules', 'client_access_tokens', 'professions', 'profiles');
|
|
82
|
+
END $unlock$;
|
|
83
|
+
|
|
84
|
+
INSERT INTO migration.excluded_tables (source_table, reason) VALUES
|
|
85
|
+
('service_locations', 'Deferred by its own note: "a service location is a place INSIDE a unit, not a unit". 091 pointed it at app.units, which would turn every treatment room into a branch. It belongs to the owning plugin as an extension (#115).'),
|
|
86
|
+
('license_users', 'Deferred: app.memberships.user_id is an FK to auth.users, and NOTHING in the allowlist maps a V1 login to a V2 one — profiles explicitly drops its id. Until that bridge exists a membership cannot say whose it is (#115).'),
|
|
87
|
+
('employee_permission_rules', 'Deferred with license_users: same missing V1->V2 login mapping (#115).'),
|
|
88
|
+
('professional_permission_rules', 'Deferred with license_users: same missing V1->V2 login mapping (#115).'),
|
|
89
|
+
('client_access_tokens', 'Deferred by its own note: portal tokens are opaque and re-issued in V2, so the token value never migrates — which leaves nothing to write (#115).'),
|
|
90
|
+
('professions', 'Deferred: a reference list folded into the professional role, like origins and partnerships (#110). The value reaches V2 inside the person that references it (#115).'),
|
|
91
|
+
('profiles', 'Deferred by its own note: the login profile is folded into the Person of the member (memberships.person_id, PRD 03 R3). There is no separate row to write (#115).')
|
|
92
|
+
ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
|
|
93
|
+
|
|
94
|
+
CREATE OR REPLACE FUNCTION migration.upsert_authz_role(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
|
|
95
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
96
|
+
DECLARE
|
|
97
|
+
v_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');
|
|
98
|
+
v_key text;
|
|
99
|
+
v_existing uuid; v_created boolean := false;
|
|
100
|
+
v_standalone boolean := migration._standalone();
|
|
101
|
+
BEGIN
|
|
102
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
103
|
+
IF p_id IS NULL OR v_name IS NULL THEN
|
|
104
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a role needs an id and a name');
|
|
105
|
+
END IF;
|
|
106
|
+
-- the key is derived from the name, because V1 rules have no key. A collision
|
|
107
|
+
-- with one of the system roles is refused rather than merged: 'owner' means
|
|
108
|
+
-- something specific in V2 and a V1 rule called Owner is not it.
|
|
109
|
+
-- fold the accents first: a plain [^a-z0-9] strip turns "Caixa Sênior" into
|
|
110
|
+
-- caixa_s_nior. translate() rather than unaccent() because the extension is not
|
|
111
|
+
-- guaranteed on a pool and a key is not worth a dependency.
|
|
112
|
+
v_key := translate(lower(v_name),
|
|
113
|
+
'áàâãäéèêëíìîïóòôõöúùûüçñ',
|
|
114
|
+
'aaaaaeeeeiiiiooooouuuucn');
|
|
115
|
+
v_key := regexp_replace(v_key, '[^a-z0-9]+', '_', 'g');
|
|
116
|
+
v_key := btrim(v_key, '_');
|
|
117
|
+
IF v_key = '' THEN v_key := 'role_' || left(replace(p_id::text, '-', ''), 8); END IF;
|
|
118
|
+
IF EXISTS (SELECT 1 FROM app.roles r WHERE r.tenant_id = p_tenant AND r.key = v_key AND r.is_system AND r.id <> p_id) THEN
|
|
119
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
|
|
120
|
+
'reason', format('a V1 rule named %L collides with the system role %L, which means something specific in V2', v_name, v_key));
|
|
121
|
+
END IF;
|
|
122
|
+
SELECT tenant_id INTO v_existing FROM app.roles WHERE id = p_id;
|
|
123
|
+
IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
|
|
124
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('role %s already exists in another tenant', p_id));
|
|
125
|
+
END IF;
|
|
126
|
+
IF v_existing IS NULL THEN
|
|
127
|
+
INSERT INTO app.roles (id, tenant_id, key, name, is_system) VALUES (p_id, p_tenant, v_key, v_name, false);
|
|
128
|
+
v_created := true;
|
|
129
|
+
ELSE
|
|
130
|
+
UPDATE app.roles SET name = v_name WHERE id = p_id AND NOT is_system;
|
|
131
|
+
END IF;
|
|
132
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'app.roles', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
|
|
133
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
134
|
+
END $$;
|
|
135
|
+
|
|
136
|
+
CREATE OR REPLACE FUNCTION migration.upsert_authz_role_permission(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
|
|
137
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
138
|
+
DECLARE
|
|
139
|
+
v_role uuid := (p_row ->> 'role_id')::uuid;
|
|
140
|
+
v_v1 text := nullif(btrim(coalesce(p_row ->> 'permission', '')), '');
|
|
141
|
+
v_key text; v_state text;
|
|
142
|
+
v_standalone boolean := migration._standalone();
|
|
143
|
+
BEGIN
|
|
144
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
145
|
+
IF v_role IS NULL OR v_v1 IS NULL THEN
|
|
146
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a role permission needs its role and its V1 code');
|
|
147
|
+
END IF;
|
|
148
|
+
IF NOT EXISTS (SELECT 1 FROM app.roles r WHERE r.id = v_role AND r.tenant_id = p_tenant) THEN
|
|
149
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', format('role %s is not in this tenant yet', v_role));
|
|
150
|
+
END IF;
|
|
151
|
+
-- #136 built app.v1_permission_map for exactly this translation. A code with no
|
|
152
|
+
-- V2 key is not invented: the map's own `backlog` rows say those codes have no
|
|
153
|
+
-- V2 surface yet, and granting a key that does not exist would fail the FK
|
|
154
|
+
-- anyway — quarantining says which code, which is actionable.
|
|
155
|
+
SELECT m.v2_key, m.status INTO v_key, v_state FROM app.v1_permission_map m WHERE m.v1_code = v_v1;
|
|
156
|
+
IF v_key IS NULL THEN
|
|
157
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
158
|
+
'reason', format('V1 permission %L has no V2 key in app.v1_permission_map (status %s)', v_v1, coalesce(v_state, 'unmapped')));
|
|
159
|
+
END IF;
|
|
160
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission) VALUES (p_tenant, v_role, v_key)
|
|
161
|
+
ON CONFLICT DO NOTHING;
|
|
162
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'app.role_permissions', v_role, jsonb_build_object('reason', 'granted', 'permission', v_key)); END IF;
|
|
163
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', v_role, 'reason', format('granted %s', v_key));
|
|
164
|
+
END $$;
|
|
165
|
+
|
|
166
|
+
CREATE OR REPLACE FUNCTION migration.upsert_person_registration(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
|
|
167
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
168
|
+
DECLARE
|
|
169
|
+
v_payload jsonb;
|
|
170
|
+
v_existing uuid; v_created boolean := false;
|
|
171
|
+
v_standalone boolean := migration._standalone();
|
|
172
|
+
BEGIN
|
|
173
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
174
|
+
IF p_id IS NULL THEN
|
|
175
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a registration needs an id');
|
|
176
|
+
END IF;
|
|
177
|
+
-- the queue entry keeps the form as submitted; dedup happens later, through
|
|
178
|
+
-- person_registrations_process, which is the whole point of not writing a Person
|
|
179
|
+
v_payload := jsonb_strip_nulls(jsonb_build_object(
|
|
180
|
+
'name', p_row ->> 'name', 'email', p_row ->> 'email', 'phone', p_row ->> 'phone',
|
|
181
|
+
'document_number', p_row ->> 'document_number', 'source', 'migration:v1'));
|
|
182
|
+
SELECT tenant_id INTO v_existing FROM public.person_registrations WHERE id = p_id;
|
|
183
|
+
IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
|
|
184
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('registration %s already exists in another tenant', p_id));
|
|
185
|
+
END IF;
|
|
186
|
+
IF v_existing IS NULL THEN
|
|
187
|
+
INSERT INTO public.person_registrations (id, tenant_id, unit_id, payload)
|
|
188
|
+
VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_payload);
|
|
189
|
+
v_created := true;
|
|
190
|
+
ELSE
|
|
191
|
+
UPDATE public.person_registrations SET payload = payload || v_payload WHERE id = p_id AND status = 'pending';
|
|
192
|
+
END IF;
|
|
193
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.person_registrations', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
|
|
194
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
195
|
+
END $$;
|
|
196
|
+
|
|
197
|
+
DO $$
|
|
198
|
+
DECLARE r record;
|
|
199
|
+
BEGIN
|
|
200
|
+
FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
201
|
+
WHERE n.nspname = 'migration' AND p.proname IN ('upsert_authz_role', 'upsert_authz_role_permission', 'upsert_person_registration') LOOP
|
|
202
|
+
EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', r.sig);
|
|
203
|
+
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', r.sig);
|
|
204
|
+
END LOOP;
|
|
205
|
+
END $$;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
-- 126_owner_scoping_is_configuration.sql
|
|
2
|
+
--
|
|
3
|
+
-- `owner_scoping` stops being a column nobody reads. #135.
|
|
4
|
+
--
|
|
5
|
+
-- The criterion is "vertical differences are configuration: salon template
|
|
6
|
+
-- owner_id off, clinic on". What shipped records `owner_scoping` per table in
|
|
7
|
+
-- `app.scaffold_registry` and then never consults it — the audit's finding, and
|
|
8
|
+
-- `053`'s own comment says it in the future tense ("owner_scoping is recorded so
|
|
9
|
+
-- a template CAN say which"). The actual difference between a salon and a clinic
|
|
10
|
+
-- today is a DATA convention: the clinic fills `owner_id`, the salon leaves it
|
|
11
|
+
-- null, and the identical policy therefore behaves differently. That works, and
|
|
12
|
+
-- it is not configuration — nothing declares it, nothing can check it, and a
|
|
13
|
+
-- salon row that acquires an owner_id by accident silently becomes private.
|
|
14
|
+
--
|
|
15
|
+
-- One table serves every tenant, so DDL cannot vary per tenant: the variation
|
|
16
|
+
-- has to be a LOOKUP. `app.owner_scoped(resource_type)` reads the tenant's
|
|
17
|
+
-- `role_template` and answers for that resource, and the policy template asks it
|
|
18
|
+
-- before applying the owner clause at all. A salon's people are visible to the
|
|
19
|
+
-- unit because its vertical says so, not because every row happens to be
|
|
20
|
+
-- unowned.
|
|
21
|
+
--
|
|
22
|
+
-- Defaults are the safe direction: an unknown template, or a resource with no
|
|
23
|
+
-- row, falls back to the registry's own `owner_scoping` flag, which defaults
|
|
24
|
+
-- true. Configuration can only ever RELAX the clause, never tighten it by
|
|
25
|
+
-- omission.
|
|
26
|
+
--
|
|
27
|
+
-- Every registered table is re-scaffolded at the end so the new template lands
|
|
28
|
+
-- on all of them at once — the registry records the arguments each was created
|
|
29
|
+
-- with, which is what makes that possible.
|
|
30
|
+
--
|
|
31
|
+
-- Replay-safe: CREATE OR REPLACE + an idempotent seed + a re-scaffold that
|
|
32
|
+
-- rewrites the same four policies.
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS app.vertical_owner_scoping (
|
|
35
|
+
role_template text NOT NULL,
|
|
36
|
+
resource_type text NOT NULL,
|
|
37
|
+
owner_scoped boolean NOT NULL,
|
|
38
|
+
note text,
|
|
39
|
+
PRIMARY KEY (role_template, resource_type)
|
|
40
|
+
);
|
|
41
|
+
ALTER TABLE app.vertical_owner_scoping ENABLE ROW LEVEL SECURITY;
|
|
42
|
+
GRANT SELECT ON app.vertical_owner_scoping TO authenticated;
|
|
43
|
+
DROP POLICY IF EXISTS vertical_owner_scoping_select ON app.vertical_owner_scoping;
|
|
44
|
+
CREATE POLICY vertical_owner_scoping_select ON app.vertical_owner_scoping FOR SELECT TO authenticated USING (true);
|
|
45
|
+
|
|
46
|
+
COMMENT ON TABLE app.vertical_owner_scoping IS
|
|
47
|
+
'Which resource types a vertical scopes by owner (#135). One table serves every tenant, so this is the only place the salon/clinic difference can live as configuration rather than as a data convention. Absent row ⇒ the scaffold registry''s own flag, which defaults true.';
|
|
48
|
+
|
|
49
|
+
INSERT INTO app.vertical_owner_scoping (role_template, resource_type, owner_scoped, note) VALUES
|
|
50
|
+
('clinic', 'people.person', true, 'A professional sees her own patients; PRD 03 / the clinic template.'),
|
|
51
|
+
('salon', 'people.person', false, 'The unit decides; a salon''s people are shared across the team.'),
|
|
52
|
+
('restaurant', 'people.person', false, 'The unit decides.'),
|
|
53
|
+
('generic', 'people.person', false, 'The unit decides, until a vertical says otherwise.'),
|
|
54
|
+
-- THE DIARY IS THE OTHER HALF OF THE SAME DECISION, and it was left out.
|
|
55
|
+
-- `agenda.appointment` is the only other owner-scoped resource in the
|
|
56
|
+
-- registry, and with no row here it fell through to the default — true — for
|
|
57
|
+
-- every vertical including salon. That is backwards for most of them: a
|
|
58
|
+
-- receptionist books for the whole team, and a shared diary that hides the
|
|
59
|
+
-- owner's own appointments from her staff is not a stricter salon, it is a
|
|
60
|
+
-- broken one. 128 refused to flip a live clinic pool over exactly one such
|
|
61
|
+
-- row, which is the brake doing its job.
|
|
62
|
+
('clinic', 'agenda.appointment', true, 'A professional sees her own consultations; the same PRD 03 rule as her patients.'),
|
|
63
|
+
('salon', 'agenda.appointment', false, 'One diary for the floor — whoever is at the desk books for everyone.'),
|
|
64
|
+
('restaurant', 'agenda.appointment', false, 'One book of reservations, read by whoever answers the phone.'),
|
|
65
|
+
('generic', 'agenda.appointment', false, 'The unit decides, until a vertical says otherwise.')
|
|
66
|
+
ON CONFLICT (role_template, resource_type) DO UPDATE SET owner_scoped = EXCLUDED.owner_scoped, note = EXCLUDED.note;
|
|
67
|
+
|
|
68
|
+
CREATE OR REPLACE FUNCTION app.owner_scoped(p_resource_type text)
|
|
69
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
70
|
+
SELECT coalesce(
|
|
71
|
+
(SELECT v.owner_scoped FROM app.vertical_owner_scoping v
|
|
72
|
+
JOIN public.tenants t ON t.role_template = v.role_template
|
|
73
|
+
WHERE t.id = app.current_tenant_id() AND v.resource_type = p_resource_type),
|
|
74
|
+
-- no configuration for this vertical: the table's own flag, which defaults
|
|
75
|
+
-- true. Configuration relaxes; it never tightens by omission.
|
|
76
|
+
(SELECT r.owner_scoping FROM app.scaffold_registry r WHERE r.resource_type = p_resource_type LIMIT 1),
|
|
77
|
+
true)
|
|
78
|
+
$$;
|
|
79
|
+
REVOKE ALL ON FUNCTION app.owner_scoped(text) FROM public;
|
|
80
|
+
GRANT EXECUTE ON FUNCTION app.owner_scoped(text) TO authenticated, service_role;
|
|
81
|
+
|
|
82
|
+
CREATE OR REPLACE FUNCTION app.scaffold_table(
|
|
83
|
+
p_table regclass,
|
|
84
|
+
p_resource_type text,
|
|
85
|
+
p_plugin text,
|
|
86
|
+
p_owner_scoping boolean DEFAULT true,
|
|
87
|
+
p_mode text DEFAULT 'enforce'
|
|
88
|
+
)
|
|
89
|
+
RETURNS void
|
|
90
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
91
|
+
AS $$
|
|
92
|
+
DECLARE
|
|
93
|
+
v_schema text;
|
|
94
|
+
v_name text;
|
|
95
|
+
v_q text; -- quoted qualified name
|
|
96
|
+
v_prev text[] := '{}';
|
|
97
|
+
v_rows bigint;
|
|
98
|
+
r record;
|
|
99
|
+
v_scope text; -- the row-scope predicate shared by every policy
|
|
100
|
+
BEGIN
|
|
101
|
+
IF p_mode NOT IN ('shadow', 'enforce') THEN
|
|
102
|
+
RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
|
|
103
|
+
END IF;
|
|
104
|
+
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
105
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
106
|
+
v_q := format('%I.%I', v_schema, v_name);
|
|
107
|
+
|
|
108
|
+
-- ── columns
|
|
109
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
|
|
110
|
+
RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
|
|
111
|
+
END IF;
|
|
112
|
+
EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
|
|
113
|
+
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
|
|
114
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
|
|
115
|
+
IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
|
|
116
|
+
END IF;
|
|
117
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
|
|
118
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
|
|
119
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
120
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
121
|
+
|
|
122
|
+
-- ── unique(tenant_id, id) — enables composite FKs from other tables
|
|
123
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
|
|
124
|
+
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
|
|
125
|
+
END IF;
|
|
126
|
+
-- ── composite FKs: unit within tenant, owner a member of the tenant
|
|
127
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
|
|
128
|
+
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');
|
|
129
|
+
END IF;
|
|
130
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
|
|
131
|
+
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');
|
|
132
|
+
END IF;
|
|
133
|
+
|
|
134
|
+
-- ── tenant-first indexes
|
|
135
|
+
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
|
|
136
|
+
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);
|
|
137
|
+
|
|
138
|
+
-- ── updated_at
|
|
139
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
|
|
140
|
+
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
|
|
141
|
+
|
|
142
|
+
-- ── RLS
|
|
143
|
+
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
|
|
144
|
+
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
|
|
145
|
+
IF p_mode = 'enforce' THEN
|
|
146
|
+
FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
|
|
147
|
+
AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
|
|
148
|
+
v_prev := v_prev || r.policyname;
|
|
149
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
|
|
150
|
+
END LOOP;
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
-- The owner clause is now CONDITIONAL ON THE TENANT'S VERTICAL, which is what
|
|
154
|
+
-- "vertical differences are configuration" has to mean when one table serves
|
|
155
|
+
-- every tenant: DDL cannot vary per tenant, so the variation has to be a
|
|
156
|
+
-- lookup. app.owner_scoped() reads the tenant's role_template and answers for
|
|
157
|
+
-- this resource type; when a vertical does not scope by owner the clause drops
|
|
158
|
+
-- out entirely instead of relying on every row happening to have a null
|
|
159
|
+
-- owner_id. Wrapped in (select …) like every other helper, so it is evaluated
|
|
160
|
+
-- once per statement and not once per row.
|
|
161
|
+
v_scope := format(
|
|
162
|
+
'tenant_id = (select app.current_tenant_id())'
|
|
163
|
+
|| ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))'
|
|
164
|
+
|| ' AND (NOT (select app.owner_scoped(%L))'
|
|
165
|
+
|| ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
|
|
166
|
+
|| ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
|
|
167
|
+
|| ' OR (select app.has_permission(%L, unit_id)))',
|
|
168
|
+
p_resource_type, p_resource_type, p_plugin || '.read_all');
|
|
169
|
+
|
|
170
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
171
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
172
|
+
v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
|
|
173
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
|
|
174
|
+
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)))',
|
|
175
|
+
v_name || '_authz_insert', v_q, p_plugin || '.create');
|
|
176
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
177
|
+
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)))',
|
|
178
|
+
v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
|
|
179
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
180
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
181
|
+
v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
|
|
182
|
+
|
|
183
|
+
INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies)
|
|
184
|
+
VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev)
|
|
185
|
+
ON CONFLICT (table_schema, table_name) DO UPDATE
|
|
186
|
+
SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
|
|
187
|
+
mode = EXCLUDED.mode, applied_at = now(),
|
|
188
|
+
previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
|
|
189
|
+
THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
|
|
190
|
+
ELSE EXCLUDED.previous_policies END;
|
|
191
|
+
END $$;
|
|
192
|
+
|
|
193
|
+
-- Refresh ONLY the four template policies, leaving everything else alone.
|
|
194
|
+
--
|
|
195
|
+
-- The first version of this file re-ran app.scaffold_table over the registry,
|
|
196
|
+
-- and enforce mode does what it says: it drops every policy that is not one of
|
|
197
|
+
-- the four. That wiped `products_available_in` — the RESTRICTIVE release rule
|
|
198
|
+
-- #139 added AFTER the table was scaffolded — which the bench caught. A template
|
|
199
|
+
-- change is not a re-scaffold, and it must not be able to remove a rule another
|
|
200
|
+
-- slice layered on top.
|
|
201
|
+
CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text)
|
|
202
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
203
|
+
DECLARE
|
|
204
|
+
v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
|
|
205
|
+
v_name text := split_part(replace(p_table, '"', ''), '.', 2);
|
|
206
|
+
v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
|
|
207
|
+
r app.scaffold_registry%ROWTYPE;
|
|
208
|
+
v_scope text;
|
|
209
|
+
BEGIN
|
|
210
|
+
SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
|
|
211
|
+
IF NOT FOUND THEN
|
|
212
|
+
RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
|
|
213
|
+
END IF;
|
|
214
|
+
|
|
215
|
+
v_scope := format(
|
|
216
|
+
'tenant_id = (select app.current_tenant_id())'
|
|
217
|
+
|| ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))'
|
|
218
|
+
|| ' AND (NOT (select app.owner_scoped(%L))'
|
|
219
|
+
|| ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
|
|
220
|
+
|| ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
|
|
221
|
+
|| ' OR (select app.has_permission(%L, unit_id)))',
|
|
222
|
+
r.resource_type, r.resource_type, r.plugin || '.read_all');
|
|
223
|
+
|
|
224
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
225
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
226
|
+
v_name || '_authz_select', v_q, v_scope, r.plugin || '.read');
|
|
227
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
228
|
+
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)))',
|
|
229
|
+
v_name || '_authz_update', v_q, v_scope, r.plugin || '.edit', r.plugin || '.edit');
|
|
230
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
231
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
232
|
+
v_name || '_authz_delete', v_q, v_scope, r.plugin || '.delete');
|
|
233
|
+
-- the INSERT policy has no owner clause (there is no row yet to own), so it is
|
|
234
|
+
-- unaffected by this change and is left exactly as scaffold_table wrote it.
|
|
235
|
+
END $$;
|
|
236
|
+
REVOKE ALL ON FUNCTION app.scaffold_refresh_policies(text) FROM public, anon, authenticated;
|
|
237
|
+
|
|
238
|
+
DO $$
|
|
239
|
+
DECLARE r record; v_n integer := 0;
|
|
240
|
+
BEGIN
|
|
241
|
+
FOR r IN SELECT * FROM app.scaffold_registry ORDER BY table_schema, table_name LOOP
|
|
242
|
+
CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
|
|
243
|
+
PERFORM app.scaffold_refresh_policies(format('%I.%I', r.table_schema, r.table_name));
|
|
244
|
+
v_n := v_n + 1;
|
|
245
|
+
END LOOP;
|
|
246
|
+
RAISE NOTICE '116: refreshed the template policies of % table(s) onto the vertical-aware scope', v_n;
|
|
247
|
+
END $$;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
-- 127_scaffold_audit_hook.sql
|
|
2
|
+
--
|
|
3
|
+
-- The scaffold template gets the audit hook its contract names. #135.
|
|
4
|
+
--
|
|
5
|
+
-- #135 lists an "audit hook" among the things every scaffolded business table
|
|
6
|
+
-- gets. `app.scaffold_table` attaches `handle_updated_at` and nothing else — the
|
|
7
|
+
-- audit slice (`096`) landed separately and was never wired into the template.
|
|
8
|
+
--
|
|
9
|
+
-- OPT-IN, NOT AUTOMATIC, and the reason is not squeamishness about volume. An
|
|
10
|
+
-- audit row is a claim about WHO did something. On a transactional table
|
|
11
|
+
-- (`orders`, `order_items`, `plg_financial_movements`) the interesting history is
|
|
12
|
+
-- already the table itself — those ledgers are append-only, so their past is not
|
|
13
|
+
-- something an audit trail has to reconstruct. Auditing them would double every
|
|
14
|
+
-- write to restate what the row already says. The tables worth auditing are the
|
|
15
|
+
-- ones where the CURRENT value is all you can see and the change is rare and
|
|
16
|
+
-- consequential: a company's fiscal identity, a tenant's settings, a role's
|
|
17
|
+
-- permissions. So the hook is a per-table decision recorded in the registry,
|
|
18
|
+
-- and `company_legal_entities` is its first taker — a CNPJ changing is exactly
|
|
19
|
+
-- the kind of thing you want to be able to ask "who, and when" about.
|
|
20
|
+
--
|
|
21
|
+
-- INSERT AND UPDATE ONLY, and this one IS a hard constraint rather than a
|
|
22
|
+
-- preference: #144's gate refuses an audit row for a destructive action without
|
|
23
|
+
-- a `reason`, and `audit_action_is_destructive` counts anything ending in
|
|
24
|
+
-- `.delete`. A trigger has no reason to give — it would have to invent one, and
|
|
25
|
+
-- an invented reason in an audit log is worse than no row, because it reads like
|
|
26
|
+
-- somebody typed it. A delete that matters goes through an RPC that asks.
|
|
27
|
+
--
|
|
28
|
+
-- Replay-safe: CREATE OR REPLACE, an idempotent column add, and a trigger that
|
|
29
|
+
-- is dropped and recreated.
|
|
30
|
+
|
|
31
|
+
ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS audit_hook boolean NOT NULL DEFAULT false;
|
|
32
|
+
COMMENT ON COLUMN app.scaffold_registry.audit_hook IS
|
|
33
|
+
'Whether this table carries the template''s audit hook (#135/117). Opt-in: an audit row is a claim about who did something, and on an append-only ledger the table already IS its own history. Reserved for tables where the current value is all you can see and a change is rare and consequential.';
|
|
34
|
+
|
|
35
|
+
CREATE OR REPLACE FUNCTION app.trg_scaffold_audit()
|
|
36
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
37
|
+
DECLARE
|
|
38
|
+
v_rt text := TG_ARGV[0];
|
|
39
|
+
v_before jsonb;
|
|
40
|
+
v_after jsonb;
|
|
41
|
+
BEGIN
|
|
42
|
+
-- Only what actually changed, so an audit row says something. A full row
|
|
43
|
+
-- snapshot on every UPDATE is a diff nobody can read.
|
|
44
|
+
IF TG_OP = 'UPDATE' THEN
|
|
45
|
+
SELECT jsonb_object_agg(o.key, o.value), jsonb_object_agg(n.key, n.value)
|
|
46
|
+
INTO v_before, v_after
|
|
47
|
+
FROM jsonb_each(to_jsonb(OLD)) o
|
|
48
|
+
JOIN jsonb_each(to_jsonb(NEW)) n ON n.key = o.key
|
|
49
|
+
WHERE o.value IS DISTINCT FROM n.value
|
|
50
|
+
AND o.key NOT IN ('updated_at', 'created_at');
|
|
51
|
+
IF v_after IS NULL THEN
|
|
52
|
+
RETURN NEW; -- nothing but a timestamp moved
|
|
53
|
+
END IF;
|
|
54
|
+
END IF;
|
|
55
|
+
|
|
56
|
+
INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, unit_id, before, after)
|
|
57
|
+
VALUES (NEW.tenant_id, auth.uid(),
|
|
58
|
+
v_rt || CASE TG_OP WHEN 'INSERT' THEN '.create' ELSE '.edit' END,
|
|
59
|
+
v_rt, NEW.id::text,
|
|
60
|
+
CASE WHEN to_jsonb(NEW) ? 'unit_id' THEN (to_jsonb(NEW) ->> 'unit_id')::uuid END,
|
|
61
|
+
v_before, coalesce(v_after, to_jsonb(NEW)));
|
|
62
|
+
RETURN NEW;
|
|
63
|
+
END $$;
|
|
64
|
+
REVOKE ALL ON FUNCTION app.trg_scaffold_audit() FROM public, anon, authenticated;
|
|
65
|
+
|
|
66
|
+
CREATE OR REPLACE FUNCTION app.scaffold_attach_audit(p_table text, p_on boolean DEFAULT true)
|
|
67
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
68
|
+
DECLARE
|
|
69
|
+
v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
|
|
70
|
+
v_name text := split_part(replace(p_table, '"', ''), '.', 2);
|
|
71
|
+
v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
|
|
72
|
+
r app.scaffold_registry%ROWTYPE;
|
|
73
|
+
BEGIN
|
|
74
|
+
SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
|
|
75
|
+
IF NOT FOUND THEN
|
|
76
|
+
RAISE EXCEPTION 'scaffold_attach_audit: %.% is not in the scaffold registry — the audit hook is part of the template, not a loose trigger', v_schema, v_name
|
|
77
|
+
USING ERRCODE = 'P0002';
|
|
78
|
+
END IF;
|
|
79
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_scaffold_audit', v_q);
|
|
80
|
+
IF p_on THEN
|
|
81
|
+
-- INSERT and UPDATE only: #144's gate refuses a destructive action without a
|
|
82
|
+
-- reason, and a trigger has none to give.
|
|
83
|
+
EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION app.trg_scaffold_audit(%L)',
|
|
84
|
+
v_name || '_scaffold_audit', v_q, r.resource_type);
|
|
85
|
+
END IF;
|
|
86
|
+
UPDATE app.scaffold_registry SET audit_hook = p_on WHERE table_schema = v_schema AND table_name = v_name;
|
|
87
|
+
END $$;
|
|
88
|
+
REVOKE ALL ON FUNCTION app.scaffold_attach_audit(text, boolean) FROM public, anon, authenticated;
|
|
89
|
+
|
|
90
|
+
-- The first taker. A CNPJ or a fiscal address changing is exactly the kind of
|
|
91
|
+
-- thing you want to be able to ask "who, and when" about, and it happens rarely
|
|
92
|
+
-- enough that the trail stays readable.
|
|
93
|
+
DO $$
|
|
94
|
+
BEGIN
|
|
95
|
+
IF EXISTS (SELECT 1 FROM app.scaffold_registry WHERE table_schema = 'public' AND table_name = 'company_legal_entities') THEN
|
|
96
|
+
PERFORM app.scaffold_attach_audit('public.company_legal_entities');
|
|
97
|
+
END IF;
|
|
98
|
+
END $$;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- 128_shadow_tables_enforced.sql
|
|
2
|
+
--
|
|
3
|
+
-- The six shadow tables are flipped, and the report that authorises it is
|
|
4
|
+
-- recorded rather than implied. #135.
|
|
5
|
+
--
|
|
6
|
+
-- #135's acceptance reads "shadow-mode report shows zero unexpected denials on
|
|
7
|
+
-- the synthetic fixture BEFORE force is enabled". Two things were true and
|
|
8
|
+
-- neither was the criterion: the report IS all-zero on the fixture, and it is
|
|
9
|
+
-- all-zero vacuously — all six shadow tables hold zero rows — while
|
|
10
|
+
-- `app.scaffold_set_mode` had never been called by anything. The 41 enforced
|
|
11
|
+
-- tables got there because each owning slice passed 'enforce' to
|
|
12
|
+
-- `scaffold_table` directly.
|
|
13
|
+
--
|
|
14
|
+
-- So this file does the step the criterion describes, and says out loud what the
|
|
15
|
+
-- report is worth:
|
|
16
|
+
--
|
|
17
|
+
-- 1. run `app.shadow_report()` and RECORD it, in a table, with the row counts
|
|
18
|
+
-- beside it — so "zero denials" can be read together with "zero rows" and
|
|
19
|
+
-- nobody mistakes one for the other later;
|
|
20
|
+
-- 2. flip the six with `app.scaffold_set_mode`, which is what it is for;
|
|
21
|
+
-- 3. refuse to flip a table that has rows AND unexpected denials — the guard
|
|
22
|
+
-- the criterion is actually asking for, and the only reading under which
|
|
23
|
+
-- "zero unexpected denials" means anything.
|
|
24
|
+
--
|
|
25
|
+
-- The flip is safe here precisely BECAUSE the tables are empty: an empty table
|
|
26
|
+
-- cannot lose a row to a stricter policy. On a populated pool the guard is what
|
|
27
|
+
-- stops it, and the recorded report is what an operator reads first. That is a
|
|
28
|
+
-- weaker claim than the criterion implies, and it is the true one — the slice's
|
|
29
|
+
-- own `002_shadow_report.sql` asserts the opposite outcome deliberately, by
|
|
30
|
+
-- inserting rows and showing three unexpected denials, because seeing them is
|
|
31
|
+
-- the point of the report.
|
|
32
|
+
--
|
|
33
|
+
-- Replay-safe: the record table is upserted by run, and set_mode re-applies the
|
|
34
|
+
-- same template.
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS app.scaffold_shadow_records (
|
|
37
|
+
id bigserial PRIMARY KEY,
|
|
38
|
+
table_schema text NOT NULL,
|
|
39
|
+
table_name text NOT NULL,
|
|
40
|
+
rows_at_flip bigint NOT NULL,
|
|
41
|
+
report jsonb NOT NULL,
|
|
42
|
+
flipped_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
note text
|
|
44
|
+
);
|
|
45
|
+
COMMENT ON TABLE app.scaffold_shadow_records IS
|
|
46
|
+
'What the shadow report said at the moment a table was flipped to enforce (#135). rows_at_flip is stored beside it on purpose: a report of zero unexpected denials over zero rows is not evidence, and the two numbers have to be read together.';
|
|
47
|
+
|
|
48
|
+
CREATE OR REPLACE FUNCTION app.scaffold_enforce(p_table regclass, p_note text DEFAULT NULL)
|
|
49
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
50
|
+
DECLARE
|
|
51
|
+
v_schema text; v_name text; v_rows bigint; v_report jsonb; v_unexpected int;
|
|
52
|
+
BEGIN
|
|
53
|
+
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
54
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
55
|
+
EXECUTE format('SELECT count(*) FROM %I.%I', v_schema, v_name) INTO v_rows;
|
|
56
|
+
|
|
57
|
+
SELECT coalesce(jsonb_agg(to_jsonb(r)), '[]'::jsonb) INTO v_report
|
|
58
|
+
FROM app.shadow_report() r
|
|
59
|
+
WHERE r.table_name = v_name;
|
|
60
|
+
SELECT coalesce(sum((e ->> 'unexpected_denials')::int), 0) INTO v_unexpected
|
|
61
|
+
FROM jsonb_array_elements(v_report) e;
|
|
62
|
+
|
|
63
|
+
-- The guard the criterion is actually asking for. Zero denials over zero rows
|
|
64
|
+
-- proves nothing, so it is only allowed to authorise a flip when there is
|
|
65
|
+
-- something to deny.
|
|
66
|
+
IF v_rows > 0 AND v_unexpected > 0 THEN
|
|
67
|
+
RAISE EXCEPTION
|
|
68
|
+
'scaffold_enforce: %.% has % row(s) and % unexpected denial(s) in shadow — flipping would take those rows away from someone',
|
|
69
|
+
v_schema, v_name, v_rows, v_unexpected
|
|
70
|
+
USING ERRCODE = '55000',
|
|
71
|
+
HINT = 'Read app.shadow_report() for this table: each unexpected denial is a row the old policy allowed and the template does not.';
|
|
72
|
+
END IF;
|
|
73
|
+
|
|
74
|
+
PERFORM app.scaffold_set_mode(p_table, 'enforce');
|
|
75
|
+
INSERT INTO app.scaffold_shadow_records (table_schema, table_name, rows_at_flip, report, note)
|
|
76
|
+
VALUES (v_schema, v_name, v_rows, v_report,
|
|
77
|
+
coalesce(p_note, CASE WHEN v_rows = 0 THEN 'flipped empty: no row can be lost to a stricter policy' END));
|
|
78
|
+
RETURN jsonb_build_object('table', v_schema || '.' || v_name, 'rows', v_rows, 'unexpected_denials', v_unexpected, 'mode', 'enforce');
|
|
79
|
+
END $$;
|
|
80
|
+
REVOKE ALL ON FUNCTION app.scaffold_enforce(regclass, text) FROM public, anon, authenticated;
|
|
81
|
+
GRANT EXECUTE ON FUNCTION app.scaffold_enforce(regclass, text) TO service_role;
|
|
82
|
+
|
|
83
|
+
DO $$
|
|
84
|
+
DECLARE r record; v_res jsonb;
|
|
85
|
+
BEGIN
|
|
86
|
+
FOR r IN SELECT table_schema, table_name FROM app.scaffold_registry WHERE mode = 'shadow'
|
|
87
|
+
ORDER BY table_schema, table_name
|
|
88
|
+
LOOP
|
|
89
|
+
CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
|
|
90
|
+
v_res := app.scaffold_enforce(format('%I.%I', r.table_schema, r.table_name)::regclass);
|
|
91
|
+
RAISE NOTICE '118: %', v_res;
|
|
92
|
+
END LOOP;
|
|
93
|
+
END $$;
|