@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,65 @@
|
|
|
1
|
+
-- 114_migrations_ledger_immutable.sql
|
|
2
|
+
--
|
|
3
|
+
-- The migration ledger stops being append-only by politeness. Fix for #145.
|
|
4
|
+
--
|
|
5
|
+
-- `public._migrations` is what `fayz db apply` reads to know what has run and to
|
|
6
|
+
-- detect checksum drift. It had RLS enabled and **zero policies**, which sounds
|
|
7
|
+
-- locked — but `service_role` holds BYPASSRLS, so as far as the API was concerned
|
|
8
|
+
-- the ledger was an ordinary writable table: `UPDATE 1`, `DELETE 1`, both
|
|
9
|
+
-- succeeded. A client could quietly tell the applier that a migration it never
|
|
10
|
+
-- ran has already run, or that one it did run has not.
|
|
11
|
+
--
|
|
12
|
+
-- WHAT "APPEND-ONLY" ACTUALLY MEANS HERE, because the criterion overstates it and
|
|
13
|
+
-- the chain itself proves the overstatement: `107_core_table_prefix.sql:359`
|
|
14
|
+
-- deliberately DELETEs a ledger row so the next apply re-runs a file that had no
|
|
15
|
+
-- fix-forward form. Forgetting a row is a legitimate migration act. A rule that
|
|
16
|
+
-- forbade it would be a rule the chain breaks on line one.
|
|
17
|
+
--
|
|
18
|
+
-- So the enforceable property is not "nothing is ever deleted". It is:
|
|
19
|
+
--
|
|
20
|
+
-- 1. no API role touches the ledger at all — not anon, not authenticated, and
|
|
21
|
+
-- not service_role, which is the one that could;
|
|
22
|
+
-- 2. a row's IDENTITY never changes. `checksum`, `applied_at`, `applied_by` and
|
|
23
|
+
-- `plugin_version` move when a file is legitimately re-applied after an
|
|
24
|
+
-- edit — that is how drift detection works — but the (plugin_id, file_name)
|
|
25
|
+
-- a row stands for is fixed for its life. Rewriting that is not a
|
|
26
|
+
-- correction, it is a forgery: it makes one file's history describe another.
|
|
27
|
+
--
|
|
28
|
+
-- The applier connects through the Supabase Management API, which runs as
|
|
29
|
+
-- `postgres`, so it is unaffected. This is a boundary between the applier and the
|
|
30
|
+
-- API, not a freeze.
|
|
31
|
+
--
|
|
32
|
+
-- Replay-safe: CREATE OR REPLACE + DROP/CREATE TRIGGER, no data touched.
|
|
33
|
+
|
|
34
|
+
CREATE OR REPLACE FUNCTION app.migrations_ledger_guard()
|
|
35
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
36
|
+
BEGIN
|
|
37
|
+
-- 1. the ledger belongs to the applier, not to the API surface
|
|
38
|
+
IF current_user IN ('anon', 'authenticated', 'service_role') THEN
|
|
39
|
+
RAISE EXCEPTION
|
|
40
|
+
'public._migrations is the migration applier''s ledger — % may not write it', current_user
|
|
41
|
+
USING ERRCODE = '42501',
|
|
42
|
+
HINT = 'Apply migrations with `fayz db apply`, which owns this table and detects checksum drift.';
|
|
43
|
+
END IF;
|
|
44
|
+
|
|
45
|
+
-- 2. a row's identity is fixed for its life; its provenance is not
|
|
46
|
+
IF TG_OP = 'UPDATE' AND (NEW.id <> OLD.id
|
|
47
|
+
OR NEW.plugin_id <> OLD.plugin_id
|
|
48
|
+
OR NEW.file_name <> OLD.file_name) THEN
|
|
49
|
+
RAISE EXCEPTION
|
|
50
|
+
'public._migrations: the (plugin_id, file_name) of ledger row % cannot change — that would make one file''s history describe another', OLD.id
|
|
51
|
+
USING ERRCODE = '42501',
|
|
52
|
+
HINT = 'Re-applying an edited file updates its checksum; it does not move the row to a different file.';
|
|
53
|
+
END IF;
|
|
54
|
+
|
|
55
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
56
|
+
END $$;
|
|
57
|
+
REVOKE ALL ON FUNCTION app.migrations_ledger_guard() FROM public, anon, authenticated;
|
|
58
|
+
|
|
59
|
+
DROP TRIGGER IF EXISTS migrations_ledger_guard ON public._migrations;
|
|
60
|
+
CREATE TRIGGER migrations_ledger_guard
|
|
61
|
+
BEFORE INSERT OR UPDATE OR DELETE ON public._migrations
|
|
62
|
+
FOR EACH ROW EXECUTE FUNCTION app.migrations_ledger_guard();
|
|
63
|
+
|
|
64
|
+
COMMENT ON TABLE public._migrations IS
|
|
65
|
+
'The migration applier''s ledger (#145). Client-immutable: no API role may write it (104). A row''s (plugin_id, file_name) is fixed for its life; checksum/applied_at/applied_by move when a file is legitimately re-applied after an edit, which is how drift detection works. A migration may forget a row (097 does, deliberately) — that is an applier act, not a client one.';
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
-- 115_registration_honours_field_rules.sql
|
|
2
|
+
--
|
|
3
|
+
-- The validation API gets its first caller. Fix for #144.
|
|
4
|
+
--
|
|
5
|
+
-- #144 shipped FieldRules with three states per tenant, per unit and per context,
|
|
6
|
+
-- a resolver, and `field_rules_check`, which raises in pt-BR with a HINT. All of
|
|
7
|
+
-- it works. Nothing called it. `095_config_field_rules.sql:16` even names the
|
|
8
|
+
-- caller it expected -- "the check-in RPC calls field_rules_check('check_in',
|
|
9
|
+
-- 'person', ...)" -- and that RPC does not exist; plugin-agenda ships fifteen
|
|
10
|
+
-- migrations and no check-in.
|
|
11
|
+
--
|
|
12
|
+
-- So the acceptance sentence ("a required FieldRule blocks the RPC in pt-BR") had
|
|
13
|
+
-- no RPC to block, and a mechanism nobody invokes is indistinguishable from one
|
|
14
|
+
-- that does not work.
|
|
15
|
+
--
|
|
16
|
+
-- `person_registrations_process` is the natural host and was the clearest
|
|
17
|
+
-- illustration of the gap: it read `payload ->> 'name'` and, when that was empty,
|
|
18
|
+
-- wrote the Person anyway under the name 'Sem nome'. A tenant that had declared
|
|
19
|
+
-- name REQUIRED for pre-registration got a row called "Sem nome".
|
|
20
|
+
--
|
|
21
|
+
-- The check goes in before the INSERT, on the `pre_registration` context, scoped
|
|
22
|
+
-- to the registration's own unit so a branch's stricter rule applies. A tenant
|
|
23
|
+
-- with no rule for that context is unaffected -- the check returns {} and the
|
|
24
|
+
-- call is a no-op -- so this changes behaviour only where somebody asked for it.
|
|
25
|
+
--
|
|
26
|
+
-- The other half of #144's acceptance (a hidden field never reaching the payload)
|
|
27
|
+
-- is a read-side concern and is not addressed here; it stays on the issue.
|
|
28
|
+
--
|
|
29
|
+
-- Replay-safe: CREATE OR REPLACE of one function, no DDL, no data touched.
|
|
30
|
+
|
|
31
|
+
CREATE OR REPLACE FUNCTION public.person_registrations_process(p_id uuid, p_action text DEFAULT 'process', p_person_id uuid DEFAULT NULL)
|
|
32
|
+
RETURNS jsonb
|
|
33
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
34
|
+
AS $$
|
|
35
|
+
DECLARE
|
|
36
|
+
v_tenant uuid := app.current_tenant_id();
|
|
37
|
+
v_actor uuid := auth.uid();
|
|
38
|
+
r public.person_registrations%ROWTYPE;
|
|
39
|
+
v_email text;
|
|
40
|
+
v_phone text;
|
|
41
|
+
v_by_doc uuid[];
|
|
42
|
+
v_by_sig uuid[];
|
|
43
|
+
v_person uuid;
|
|
44
|
+
v_q uuid;
|
|
45
|
+
v_status text;
|
|
46
|
+
BEGIN
|
|
47
|
+
IF v_tenant IS NULL THEN RAISE EXCEPTION 'no active tenant for this session' USING ERRCODE = '42501'; END IF;
|
|
48
|
+
IF p_action NOT IN ('process', 'create', 'merge', 'reject') THEN
|
|
49
|
+
RAISE EXCEPTION 'person_registrations_process: unknown action %', p_action USING ERRCODE = '22023';
|
|
50
|
+
END IF;
|
|
51
|
+
SELECT * INTO r FROM public.person_registrations WHERE tenant_id = v_tenant AND id = p_id FOR UPDATE;
|
|
52
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'person_registrations_process: registration not found in this tenant' USING ERRCODE = 'P0002'; END IF;
|
|
53
|
+
PERFORM public.people_assert_scope(CASE WHEN p_action = 'merge' THEN 'people.manage' ELSE 'people.create' END, r.unit_id);
|
|
54
|
+
|
|
55
|
+
IF p_action = 'process' AND r.status <> 'pending' THEN
|
|
56
|
+
RAISE EXCEPTION 'person_registrations_process: only a pending registration can be processed (status %)', r.status USING ERRCODE = 'P0001';
|
|
57
|
+
END IF;
|
|
58
|
+
IF p_action IN ('create', 'merge') AND r.status <> 'quarantined' THEN
|
|
59
|
+
RAISE EXCEPTION 'person_registrations_process: % resolves a quarantined registration (status %)', p_action, r.status USING ERRCODE = 'P0001';
|
|
60
|
+
END IF;
|
|
61
|
+
IF p_action = 'reject' AND r.status NOT IN ('pending', 'quarantined') THEN
|
|
62
|
+
RAISE EXCEPTION 'person_registrations_process: registration already %', r.status USING ERRCODE = 'P0001';
|
|
63
|
+
END IF;
|
|
64
|
+
|
|
65
|
+
IF p_action = 'reject' THEN
|
|
66
|
+
UPDATE public.person_registrations SET status = 'rejected', processed_at = now(), processed_by = v_actor WHERE id = r.id;
|
|
67
|
+
RETURN jsonb_build_object('registration_id', r.id, 'status', 'rejected', 'person_id', NULL);
|
|
68
|
+
END IF;
|
|
69
|
+
|
|
70
|
+
IF p_action = 'merge' THEN
|
|
71
|
+
IF p_person_id IS NULL THEN RAISE EXCEPTION 'person_registrations_process: merge needs p_person_id' USING ERRCODE = '22023'; END IF;
|
|
72
|
+
SELECT p.id INTO v_person FROM public.people p WHERE p.tenant_id = v_tenant AND p.id = p_person_id AND p.merged_into_id IS NULL;
|
|
73
|
+
IF v_person IS NULL THEN RAISE EXCEPTION 'person_registrations_process: person not found in this tenant' USING ERRCODE = 'P0002'; END IF;
|
|
74
|
+
v_status := 'merged';
|
|
75
|
+
ELSIF p_action = 'create' THEN
|
|
76
|
+
v_person := NULL;
|
|
77
|
+
v_status := 'created';
|
|
78
|
+
ELSE
|
|
79
|
+
-- 'process': the dedup rules
|
|
80
|
+
v_email := nullif(lower(btrim(r.payload ->> 'email')), '');
|
|
81
|
+
v_phone := nullif(regexp_replace(coalesce(r.payload ->> 'phone', ''), '\D', '', 'g'), '');
|
|
82
|
+
IF r.document_number IS NOT NULL THEN
|
|
83
|
+
SELECT coalesce(array_agg(p.id ORDER BY p.created_at), '{}') INTO v_by_doc
|
|
84
|
+
FROM public.people p
|
|
85
|
+
WHERE p.tenant_id = v_tenant AND p.merged_into_id IS NULL AND p.document_number = r.document_number;
|
|
86
|
+
ELSE
|
|
87
|
+
v_by_doc := '{}';
|
|
88
|
+
END IF;
|
|
89
|
+
IF cardinality(v_by_doc) = 1 THEN
|
|
90
|
+
v_person := v_by_doc[1];
|
|
91
|
+
v_status := 'merged';
|
|
92
|
+
ELSIF cardinality(v_by_doc) > 1 THEN
|
|
93
|
+
v_status := 'quarantined';
|
|
94
|
+
v_by_sig := v_by_doc;
|
|
95
|
+
ELSE
|
|
96
|
+
-- no document match: a same e-mail / same phone person is a possible
|
|
97
|
+
-- duplicate the CPF cannot confirm — a human decides
|
|
98
|
+
SELECT coalesce(array_agg(p.id ORDER BY p.created_at), '{}') INTO v_by_sig
|
|
99
|
+
FROM public.people p
|
|
100
|
+
WHERE p.tenant_id = v_tenant AND p.merged_into_id IS NULL
|
|
101
|
+
AND ((v_email IS NOT NULL AND lower(p.email) = v_email)
|
|
102
|
+
OR (v_phone IS NOT NULL AND regexp_replace(coalesce(p.phone, ''), '\D', '', 'g') = v_phone));
|
|
103
|
+
IF cardinality(v_by_sig) > 0 THEN
|
|
104
|
+
v_status := 'quarantined';
|
|
105
|
+
ELSE
|
|
106
|
+
v_status := 'created';
|
|
107
|
+
END IF;
|
|
108
|
+
END IF;
|
|
109
|
+
|
|
110
|
+
IF v_status = 'quarantined' THEN
|
|
111
|
+
INSERT INTO public.person_identity_quarantine (tenant_id, document_number, person_ids, reason, context)
|
|
112
|
+
VALUES (v_tenant, r.document_number, v_by_sig,
|
|
113
|
+
CASE WHEN cardinality(v_by_doc) > 1 THEN 'registration: more than one person carries this document'
|
|
114
|
+
ELSE 'registration: e-mail/phone match without a document match' END,
|
|
115
|
+
jsonb_build_object('source', 'person_registrations_process', 'registration_id', r.id))
|
|
116
|
+
ON CONFLICT (tenant_id, document_number) WHERE resolved_at IS NULL AND document_number IS NOT NULL DO UPDATE
|
|
117
|
+
SET person_ids = EXCLUDED.person_ids, context = public.person_identity_quarantine.context || EXCLUDED.context
|
|
118
|
+
RETURNING id INTO v_q;
|
|
119
|
+
UPDATE public.person_registrations SET status = 'quarantined', quarantine_id = v_q, processed_at = now(), processed_by = v_actor WHERE id = r.id;
|
|
120
|
+
RETURN jsonb_build_object('registration_id', r.id, 'status', 'quarantined', 'person_id', NULL, 'quarantine_id', v_q, 'candidates', to_jsonb(v_by_sig));
|
|
121
|
+
END IF;
|
|
122
|
+
END IF;
|
|
123
|
+
|
|
124
|
+
IF v_status = 'created' THEN
|
|
125
|
+
-- The tenant's FieldRules for this context decide whether this payload may
|
|
126
|
+
-- become a Person at all. Until now field_rules_check had ZERO callers: the
|
|
127
|
+
-- rules resolved, raised in pt-BR, and nothing ever asked them — while this
|
|
128
|
+
-- very statement defaulted a missing name to 'Sem nome' and wrote the row
|
|
129
|
+
-- (#144). A tenant with no pre_registration rule is unaffected: the check
|
|
130
|
+
-- returns {} and this is a no-op.
|
|
131
|
+
PERFORM public.field_rules_check('pre_registration', 'person', r.payload, r.unit_id);
|
|
132
|
+
|
|
133
|
+
-- kind = 'customer' goes through the compat trigger: the customer role is
|
|
134
|
+
-- created as primary. Vertical fields stay in metadata.registration.
|
|
135
|
+
INSERT INTO public.people (tenant_id, unit_id, kind, name, email, phone, document_number, date_of_birth,
|
|
136
|
+
address, city, state, country, postal_code, notes, metadata, created_by)
|
|
137
|
+
VALUES (v_tenant, r.unit_id, 'customer',
|
|
138
|
+
coalesce(nullif(btrim(r.payload ->> 'name'), ''), 'Sem nome'),
|
|
139
|
+
nullif(btrim(r.payload ->> 'email'), ''), nullif(btrim(r.payload ->> 'phone'), ''), r.document_number,
|
|
140
|
+
CASE WHEN (r.payload ->> 'date_of_birth') ~ '^\d{4}-\d{2}-\d{2}$' THEN (r.payload ->> 'date_of_birth')::date END,
|
|
141
|
+
nullif(r.payload ->> 'address', ''), nullif(r.payload ->> 'city', ''), nullif(r.payload ->> 'state', ''),
|
|
142
|
+
coalesce(nullif(r.payload ->> 'country', ''), 'BR'), nullif(r.payload ->> 'postal_code', ''), nullif(r.payload ->> 'notes', ''),
|
|
143
|
+
jsonb_build_object('registration', jsonb_build_object('id', r.id, 'unit_id', r.unit_id, 'payload', r.payload)),
|
|
144
|
+
v_actor)
|
|
145
|
+
RETURNING id INTO v_person;
|
|
146
|
+
ELSE
|
|
147
|
+
-- merged: the person exists; she is (also) a customer now. Nothing else is
|
|
148
|
+
-- overwritten — the registration keeps the payload for a human to compare.
|
|
149
|
+
IF NOT EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = v_person AND x.role = 'customer'
|
|
150
|
+
AND public.person_role_active(x.valid_from, x.valid_to)) THEN
|
|
151
|
+
INSERT INTO public.person_roles (tenant_id, person_id, role, unit_id, is_primary, metadata)
|
|
152
|
+
VALUES (v_tenant, v_person, 'customer', r.unit_id,
|
|
153
|
+
NOT EXISTS (SELECT 1 FROM public.person_roles x WHERE x.tenant_id = v_tenant AND x.person_id = v_person AND x.is_primary),
|
|
154
|
+
jsonb_build_object('source', 'person_registrations_process', 'registration_id', r.id))
|
|
155
|
+
ON CONFLICT DO NOTHING;
|
|
156
|
+
UPDATE public.person_roles x SET valid_to = NULL, valid_from = least(x.valid_from, current_date)
|
|
157
|
+
WHERE x.tenant_id = v_tenant AND x.person_id = v_person AND x.role = 'customer' AND NOT public.person_role_active(x.valid_from, x.valid_to)
|
|
158
|
+
AND NOT EXISTS (SELECT 1 FROM public.person_roles y WHERE y.tenant_id = v_tenant AND y.person_id = v_person AND y.role = 'customer' AND public.person_role_active(y.valid_from, y.valid_to));
|
|
159
|
+
END IF;
|
|
160
|
+
END IF;
|
|
161
|
+
|
|
162
|
+
UPDATE public.person_registrations
|
|
163
|
+
SET status = v_status, matched_person_id = v_person, processed_at = now(), processed_by = v_actor
|
|
164
|
+
WHERE id = r.id;
|
|
165
|
+
IF r.quarantine_id IS NOT NULL THEN
|
|
166
|
+
UPDATE public.person_identity_quarantine SET resolved_at = now(), resolved_by = v_actor, resolution = v_status || ':' || v_person::text
|
|
167
|
+
WHERE id = r.quarantine_id AND resolved_at IS NULL;
|
|
168
|
+
END IF;
|
|
169
|
+
INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
170
|
+
VALUES (v_tenant, v_actor, 'people.registration.' || v_status, 'people', v_person::text, jsonb_build_object('registration_id', r.id, 'action', p_action));
|
|
171
|
+
RETURN jsonb_build_object('registration_id', r.id, 'status', v_status, 'person_id', v_person);
|
|
172
|
+
END $$;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- 116_field_rules_check_needs_a_tenant.sql
|
|
2
|
+
--
|
|
3
|
+
-- A validator that cannot see the rules must not approve. Fix for #147.
|
|
4
|
+
--
|
|
5
|
+
-- `field_rules_check` resolves the tenant from `app.current_tenant_id()`, which
|
|
6
|
+
-- reads the JWT. With no claim there is no tenant, so `field_rules_validate`
|
|
7
|
+
-- finds no rules, so nothing is ever "missing", so the function returns the
|
|
8
|
+
-- payload untouched -- a clean pass on a payload containing none of the required
|
|
9
|
+
-- fields.
|
|
10
|
+
--
|
|
11
|
+
-- That is not hypothetical. `11-verification-suite.md` told the QA Agent to run
|
|
12
|
+
-- its invariants as `service_role`, and US-PLT-ADM-07 claims this function raises
|
|
13
|
+
-- 23514 for a missing required field. Run exactly as documented it returned `{}`,
|
|
14
|
+
-- so **the agent got a silent pass on the very violation it was checking for**.
|
|
15
|
+
--
|
|
16
|
+
-- The doc is corrected in the same commit to say "as the persona". This is the
|
|
17
|
+
-- half that does not depend on anyone reading it: with no tenant, the function
|
|
18
|
+
-- refuses instead of approving. Failing closed is the only safe direction for a
|
|
19
|
+
-- validator -- a false "valid" is worse than an error, because it is invisible.
|
|
20
|
+
--
|
|
21
|
+
-- ONE TRAP WORTH RECORDING, because it cost a bench run and will catch the next
|
|
22
|
+
-- person. 097 renamed the eight core tables AND rewrote every function body that
|
|
23
|
+
-- referenced them, with pg_get_functiondef + regexp_replace. So the live body of
|
|
24
|
+
-- this function says `entity_fields`, while 085 -- the file it is defined in --
|
|
25
|
+
-- still says `plg_entity_fields`, because applied files are never edited. Lifting
|
|
26
|
+
-- a function from its original file after 097 therefore RESURRECTS the old table
|
|
27
|
+
-- name and the function breaks at its first call:
|
|
28
|
+
--
|
|
29
|
+
-- ERROR: relation "public.plg_entity_fields" does not exist
|
|
30
|
+
--
|
|
31
|
+
-- The reference below is the post-097 name on purpose. scripts/check-post-097-names.mjs
|
|
32
|
+
-- now fails CI on any migration after 097 that mentions one of the eight old names.
|
|
33
|
+
--
|
|
34
|
+
-- Replay-safe: CREATE OR REPLACE of one function, no DDL, no data touched.
|
|
35
|
+
|
|
36
|
+
CREATE OR REPLACE FUNCTION public.field_rules_check(p_context text, p_reference text, p_payload jsonb, p_unit uuid DEFAULT NULL)
|
|
37
|
+
RETURNS jsonb
|
|
38
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
39
|
+
AS $$
|
|
40
|
+
DECLARE
|
|
41
|
+
v_res jsonb := public.field_rules_validate(p_context, p_reference, p_payload, p_unit);
|
|
42
|
+
v_tenant uuid := app.current_tenant_id();
|
|
43
|
+
v_labels text;
|
|
44
|
+
BEGIN
|
|
45
|
+
-- Without a tenant there are no rules, so nothing is ever "missing" and this
|
|
46
|
+
-- returns the payload untouched — a caller checking for a required field gets
|
|
47
|
+
-- a clean pass on a payload that has none of them. That is what happened to the
|
|
48
|
+
-- QA Agent hooks (#147): the doc told them to run as `service_role`, which
|
|
49
|
+
-- carries no JWT, so ADM-07 silently returned {} where it claimed to raise.
|
|
50
|
+
-- A validator that cannot see the rules must say so, not approve.
|
|
51
|
+
IF v_tenant IS NULL THEN
|
|
52
|
+
RAISE EXCEPTION 'field_rules_check: no active tenant — the rules cannot be resolved, so nothing can be validated'
|
|
53
|
+
USING ERRCODE = '42501',
|
|
54
|
+
HINT = 'Call this as the persona whose form is being validated. service_role carries no tenant claim.';
|
|
55
|
+
END IF;
|
|
56
|
+
IF (v_res ->> 'ok')::boolean THEN
|
|
57
|
+
RETURN v_res -> 'payload';
|
|
58
|
+
END IF;
|
|
59
|
+
SELECT string_agg(coalesce(
|
|
60
|
+
(SELECT f.label FROM public.entity_fields f
|
|
61
|
+
WHERE f.reference = p_reference AND f.key = k.key
|
|
62
|
+
AND (f.tenant_id IS NULL OR f.tenant_id = v_tenant)
|
|
63
|
+
AND f.archived_at IS NULL AND nullif(btrim(f.label), '') IS NOT NULL
|
|
64
|
+
ORDER BY f.tenant_id NULLS LAST LIMIT 1),
|
|
65
|
+
k.key), ', ' ORDER BY k.ord)
|
|
66
|
+
INTO v_labels
|
|
67
|
+
FROM jsonb_array_elements_text(v_res -> 'missing') WITH ORDINALITY AS k(key, ord);
|
|
68
|
+
RAISE EXCEPTION 'Campo obrigatório: %', v_labels
|
|
69
|
+
USING ERRCODE = '23514',
|
|
70
|
+
DETAIL = format('field_rules_check: required fields missing for %s/%s: %s', p_context, p_reference, (v_res -> 'missing')::text),
|
|
71
|
+
HINT = 'Preencha os campos obrigatórios antes de continuar.';
|
|
72
|
+
END $$;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
-- 117_authz_reconciliation_schedule.sql
|
|
2
|
+
--
|
|
3
|
+
-- The reconciliation alert stops being a view nobody queries. Fix for #133.
|
|
4
|
+
--
|
|
5
|
+
-- #133's criterion is "`recompute_user_unit_access` + diff-log; runs after authz
|
|
6
|
+
-- migrations and on demand; **alert when diff ≠ 0 outside migrations**". What
|
|
7
|
+
-- shipped for the last clause was `public.v_authz_reconciliation_alerts` — a
|
|
8
|
+
-- correct view over the diff log, and nothing that ever reads it. Nothing was
|
|
9
|
+
-- scheduled, no channel existed, and `source = 'scheduled'` appeared nowhere in
|
|
10
|
+
-- the repo, so on a built database the log held only rows the migrations and the
|
|
11
|
+
-- tests wrote. An alert nobody runs is a query, and the difference matters most
|
|
12
|
+
-- in the case the alert exists for: `user_unit_access` is a PRECOMPUTED
|
|
13
|
+
-- projection, so a drift there is not a slow page — it is a person seeing units
|
|
14
|
+
-- they should not, or losing units they should have, silently, until someone
|
|
15
|
+
-- happens to look.
|
|
16
|
+
--
|
|
17
|
+
-- Three parts, and the third is the one that makes it an alert:
|
|
18
|
+
--
|
|
19
|
+
-- 1. `app.authz_reconcile_all()` — recompute every tenant with source
|
|
20
|
+
-- 'scheduled', returning what it changed. It fixes as it goes: the
|
|
21
|
+
-- projection is derived, so the recompute IS the repair, and leaving a
|
|
22
|
+
-- known-wrong access row in place to preserve evidence would be choosing
|
|
23
|
+
-- forensics over the person who cannot see her own unit. The diff log keeps
|
|
24
|
+
-- the evidence.
|
|
25
|
+
-- 2. a pg_cron job at 03:41, following the idiom of 033 (guarded on pg_cron
|
|
26
|
+
-- being present, idempotent by job name, off-peak and off-the-hour so it
|
|
27
|
+
-- does not stampede with every other platform's 03:00).
|
|
28
|
+
-- 3. `app.authz_alert_reconciliation()` — RAISES WARNING per drifting tenant,
|
|
29
|
+
-- so a non-zero diff reaches the Postgres log and anything tailing it,
|
|
30
|
+
-- instead of sitting in a table. This is the honest ceiling for what a
|
|
31
|
+
-- migration can wire on its own: routing to Slack or a pager needs an
|
|
32
|
+
-- endpoint the platform owns, and inventing one here would be a channel
|
|
33
|
+
-- nobody is on.
|
|
34
|
+
--
|
|
35
|
+
-- Replay-safe: CREATE OR REPLACE, cron.schedule upserts by job name.
|
|
36
|
+
|
|
37
|
+
CREATE OR REPLACE FUNCTION app.authz_reconcile_all()
|
|
38
|
+
RETURNS TABLE (tenant_id uuid, added integer, removed integer)
|
|
39
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
40
|
+
DECLARE r record; v record;
|
|
41
|
+
BEGIN
|
|
42
|
+
FOR r IN SELECT t.id FROM public.tenants t ORDER BY t.id LOOP
|
|
43
|
+
SELECT * INTO v FROM app.recompute_user_unit_access(r.id, 'scheduled');
|
|
44
|
+
IF v.added <> 0 OR v.removed <> 0 THEN
|
|
45
|
+
tenant_id := r.id; added := v.added; removed := v.removed;
|
|
46
|
+
RETURN NEXT;
|
|
47
|
+
END IF;
|
|
48
|
+
END LOOP;
|
|
49
|
+
END $$;
|
|
50
|
+
REVOKE ALL ON FUNCTION app.authz_reconcile_all() FROM public, anon, authenticated;
|
|
51
|
+
|
|
52
|
+
-- The alert itself. A WARNING is what a migration can reach on its own; the
|
|
53
|
+
-- platform decides where the log goes.
|
|
54
|
+
CREATE OR REPLACE FUNCTION app.authz_alert_reconciliation()
|
|
55
|
+
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
56
|
+
DECLARE r record; v_n integer := 0;
|
|
57
|
+
BEGIN
|
|
58
|
+
FOR r IN SELECT * FROM app.authz_reconcile_all() LOOP
|
|
59
|
+
v_n := v_n + 1;
|
|
60
|
+
RAISE WARNING 'authz drift: tenant % had user_unit_access repaired (+% / -%). A precomputed access row was wrong until now — see public.v_authz_reconciliation_alerts',
|
|
61
|
+
r.tenant_id, r.added, r.removed;
|
|
62
|
+
END LOOP;
|
|
63
|
+
IF v_n = 0 THEN
|
|
64
|
+
RAISE NOTICE 'authz reconciliation: no drift';
|
|
65
|
+
END IF;
|
|
66
|
+
RETURN v_n;
|
|
67
|
+
END $$;
|
|
68
|
+
REVOKE ALL ON FUNCTION app.authz_alert_reconciliation() FROM public, anon, authenticated;
|
|
69
|
+
|
|
70
|
+
DO $do$
|
|
71
|
+
BEGIN
|
|
72
|
+
IF to_regprocedure('cron.schedule(text, text, text)') IS NULL THEN
|
|
73
|
+
RAISE WARNING 'pg_cron is not available: authz reconciliation is NOT scheduled on this pool — a drift in user_unit_access would go unnoticed';
|
|
74
|
+
RETURN;
|
|
75
|
+
END IF;
|
|
76
|
+
-- 03:41: off-peak, and off the hour so it does not land with every other
|
|
77
|
+
-- platform's 03:00 job. Daily, because the fan-out triggers keep the projection
|
|
78
|
+
-- right in the same transaction — this is the net that catches what a trigger
|
|
79
|
+
-- could not see (a restore, a manual fix, a bug), not the primary mechanism.
|
|
80
|
+
PERFORM cron.schedule('fayz_authz_reconcile', '41 3 * * *',
|
|
81
|
+
$cmd$SELECT app.authz_alert_reconciliation()$cmd$);
|
|
82
|
+
END $do$;
|