@fayz-ai/db 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 017_the_pin_is_checked_against_the_right_workspace.sql — a unidade escolhida
|
|
3
|
+
-- sobrevive ao F5.
|
|
4
|
+
--
|
|
5
|
+
-- O SINTOMA: "troco de unidade, recarrego, e volta pra 'Todas as unidades'".
|
|
6
|
+
--
|
|
7
|
+
-- O CAMINHO: `setActive` grava o pino chamando `public.set_entry_unit`, que
|
|
8
|
+
-- valida antes de gravar:
|
|
9
|
+
--
|
|
10
|
+
-- IF p_unit_id NOT IN (SELECT public.user_unit_ids())
|
|
11
|
+
-- THEN RAISE EXCEPTION 'unidade fora do alcance';
|
|
12
|
+
--
|
|
13
|
+
-- A gravação é silenciosa de propósito — falhar em guardar uma preferência não
|
|
14
|
+
-- é assunto de quem só queria trocar de loja — então a exceção não aparece em
|
|
15
|
+
-- lugar nenhum. `public.user_unit_prefs` fica VAZIA. Na entrada seguinte não há
|
|
16
|
+
-- pino, `resolveEntry` cai no último degrau e responde "todas". A escolha nunca
|
|
17
|
+
-- chegou a existir; só parecia ter chegado, porque a tela trocou na hora.
|
|
18
|
+
--
|
|
19
|
+
-- ── Por que a validação recusava uma unidade que o seletor tinha acabado de
|
|
20
|
+
-- oferecer ─────────────────────────────────────────────────────────────────
|
|
21
|
+
--
|
|
22
|
+
-- `public.user_unit_ids()` errava DUAS vezes, e as duas na mesma direção.
|
|
23
|
+
--
|
|
24
|
+
-- ① NÃO FILTRAVA PELA CONTA. Lia `app.membership_units` só por `user_id`, e os
|
|
25
|
+
-- vínculos de uma pessoa atravessam todas as contas dela. Standing em Espaço
|
|
26
|
+
-- Facial, a função respondia com uma unidade do Espaco Renova Rio e uma da
|
|
27
|
+
-- Clínica Sol. É a MESMA família de bug que a 015 consertou em
|
|
28
|
+
-- `unit_bootstrap`: invisível em conta única, porque lá toda unidade sua é
|
|
29
|
+
-- da única conta que você tem.
|
|
30
|
+
--
|
|
31
|
+
-- ② LIA A TABELA ERRADA. `app.membership_units` guarda só o vínculo EXPLÍCITO,
|
|
32
|
+
-- unidade por unidade. Quem alcança a conta inteira entra por
|
|
33
|
+
-- `app.user_unit_access` com `via = 'all_units'` — que é a tabela que a
|
|
34
|
+
-- própria RLS consulta (`app.has_unit`) e que
|
|
35
|
+
-- `app.refresh_user_unit_access` mantém. As quatro unidades do Espaço Facial
|
|
36
|
+
-- estavam lá, todas com `via = 'all_units'`, e nenhuma em
|
|
37
|
+
-- `membership_units`.
|
|
38
|
+
--
|
|
39
|
+
-- Somados: a função devolvia exatamente as duas unidades erradas e nenhuma das
|
|
40
|
+
-- quatro certas. O seletor (que lê `unit_bootstrap`, já corrigido pela 015)
|
|
41
|
+
-- oferecia as quatro; a gravação, olhando outra tabela e outra conta, recusava
|
|
42
|
+
-- as quatro. Seletor e gravação discordavam sobre o que "minhas unidades"
|
|
43
|
+
-- significa, e quem perdia era a escolha da pessoa.
|
|
44
|
+
--
|
|
45
|
+
-- ── A correção ─────────────────────────────────────────────────────────────
|
|
46
|
+
-- Uma fonte só, a mesma que a RLS usa, recortada pela conta em que a pessoa
|
|
47
|
+
-- está — e a descida na árvore preservada, porque um vínculo numa região
|
|
48
|
+
-- sempre alcançou as filiais abaixo dela.
|
|
49
|
+
--
|
|
50
|
+
-- E `set_entry_unit` passa a validar contra a conta que ELE recebeu, em vez da
|
|
51
|
+
-- conta ambiente da sessão. As duas coincidem hoje; depender disso seria deixar
|
|
52
|
+
-- de pé, no caminho de gravação, a mesma suposição que causou este bug.
|
|
53
|
+
--
|
|
54
|
+
-- NADA AQUI CONCEDE. Isto decide qual pino pode ser GRAVADO. Quem pode ler o
|
|
55
|
+
-- quê continua sendo `app.has_unit` sobre a mesma `app.user_unit_access` — e o
|
|
56
|
+
-- efeito de errar aqui é uma preferência perdida, nunca uma linha revelada.
|
|
57
|
+
-- ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
-- As unidades que esta pessoa alcança DENTRO de uma conta nomeada.
|
|
60
|
+
--
|
|
61
|
+
-- Recebe a conta em vez de deduzi-la: quem chama no caminho de gravação já sabe
|
|
62
|
+
-- em qual está, e passar é o que impede a pergunta de ser respondida sobre
|
|
63
|
+
-- outra.
|
|
64
|
+
CREATE OR REPLACE FUNCTION app.user_unit_ids_in(p_tenant uuid)
|
|
65
|
+
RETURNS SETOF uuid
|
|
66
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
67
|
+
SET search_path TO ''
|
|
68
|
+
AS $$
|
|
69
|
+
WITH RECURSIVE seed AS (
|
|
70
|
+
-- A MESMA tabela que a RLS lê. `membership_units` só conhece o vínculo
|
|
71
|
+
-- explícito; quem alcança a conta inteira entra por aqui com
|
|
72
|
+
-- via = 'all_units' e não tem linha lá.
|
|
73
|
+
SELECT a.unit_id AS id, 0 AS depth
|
|
74
|
+
FROM app.user_unit_access a
|
|
75
|
+
WHERE a.user_id = (SELECT auth.uid())
|
|
76
|
+
AND a.tenant_id = p_tenant
|
|
77
|
+
),
|
|
78
|
+
tree AS (
|
|
79
|
+
SELECT id, depth FROM seed
|
|
80
|
+
UNION
|
|
81
|
+
-- Um vínculo numa região alcança as filiais abaixo. `user_unit_access` já
|
|
82
|
+
-- costuma chegar expandida; a descida é o que mantém a resposta certa
|
|
83
|
+
-- quando ela não chegou.
|
|
84
|
+
SELECT u.id, t.depth + 1
|
|
85
|
+
FROM app.units u
|
|
86
|
+
JOIN tree t ON u.parent_unit_id = t.id
|
|
87
|
+
WHERE u.tenant_id = p_tenant AND t.depth < 8
|
|
88
|
+
)
|
|
89
|
+
SELECT id FROM tree;
|
|
90
|
+
$$;
|
|
91
|
+
|
|
92
|
+
REVOKE ALL ON FUNCTION app.user_unit_ids_in(uuid) FROM PUBLIC;
|
|
93
|
+
GRANT ALL ON FUNCTION app.user_unit_ids_in(uuid) TO authenticated;
|
|
94
|
+
GRANT ALL ON FUNCTION app.user_unit_ids_in(uuid) TO service_role;
|
|
95
|
+
|
|
96
|
+
COMMENT ON FUNCTION app.user_unit_ids_in(uuid) IS
|
|
97
|
+
'As unidades que o chamador alcança dentro da conta informada (#017), lidas de app.user_unit_access — a mesma fonte de app.has_unit — e descidas pela árvore. Decide o que pode ser GRAVADO como pino; nunca o que pode ser lido.';
|
|
98
|
+
|
|
99
|
+
-- A versão sem argumento passa a ser um apelido da de cima sobre a conta da
|
|
100
|
+
-- sessão. Mesma assinatura, mesma resposta para quem já a chamava — só que
|
|
101
|
+
-- agora sobre a conta certa e a tabela certa.
|
|
102
|
+
CREATE OR REPLACE FUNCTION public.user_unit_ids()
|
|
103
|
+
RETURNS SETOF uuid
|
|
104
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
105
|
+
SET search_path TO ''
|
|
106
|
+
AS $$
|
|
107
|
+
SELECT app.user_unit_ids_in(app.current_tenant_id());
|
|
108
|
+
$$;
|
|
109
|
+
|
|
110
|
+
COMMENT ON FUNCTION public.user_unit_ids() IS
|
|
111
|
+
'As unidades que o chamador alcança na conta em que está (#017). Antes lia app.membership_units sem filtro de conta e devolvia unidades de OUTRO workspace — o que fazia set_entry_unit recusar a unidade que o seletor tinha acabado de oferecer, e a escolha se perder no F5.';
|
|
112
|
+
|
|
113
|
+
-- A gravação do pino valida contra a conta que recebeu.
|
|
114
|
+
CREATE OR REPLACE FUNCTION public.set_entry_unit(p_tenant_id uuid, p_unit_id uuid, p_device_id text)
|
|
115
|
+
RETURNS void
|
|
116
|
+
LANGUAGE plpgsql
|
|
117
|
+
SET search_path TO 'public', 'pg_temp'
|
|
118
|
+
AS $$
|
|
119
|
+
BEGIN
|
|
120
|
+
IF p_device_id IS NULL OR length(trim(p_device_id)) = 0 THEN
|
|
121
|
+
RAISE EXCEPTION 'device_id obrigatório';
|
|
122
|
+
END IF;
|
|
123
|
+
|
|
124
|
+
-- "Todas as unidades" (nulo) é sempre válido. Uma unidade só é aceita se a
|
|
125
|
+
-- pessoa a alcança de verdade NESTA conta — a mesma fonte que o RLS usa, e a
|
|
126
|
+
-- conta que o chamador nomeou, não a que a sessão calhar de ter.
|
|
127
|
+
IF p_unit_id IS NOT NULL
|
|
128
|
+
AND p_unit_id NOT IN (SELECT app.user_unit_ids_in(p_tenant_id)) THEN
|
|
129
|
+
RAISE EXCEPTION 'unidade fora do alcance';
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
INSERT INTO public.user_unit_prefs (user_id, tenant_id, device_id, unit_id, updated_at)
|
|
133
|
+
VALUES ((SELECT auth.uid()), p_tenant_id, p_device_id, p_unit_id, now())
|
|
134
|
+
ON CONFLICT (user_id, tenant_id, device_id)
|
|
135
|
+
DO UPDATE SET unit_id = EXCLUDED.unit_id, updated_at = now();
|
|
136
|
+
END $$;
|
|
137
|
+
|
|
138
|
+
COMMENT ON FUNCTION public.set_entry_unit(uuid, uuid, text) IS
|
|
139
|
+
'Guarda em qual unidade a pessoa está, por aparelho (#017 valida contra a conta recebida). Silenciosa para quem chama: falhar em gravar uma preferência não é assunto de quem só queria trocar de loja.';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 018_the_bootstrap_says_what_you_reach.sql — a entrada passa a saber quais
|
|
3
|
+
-- unidades a pessoa alcança, e não só quais foram amarradas na mão.
|
|
4
|
+
--
|
|
5
|
+
-- A 017 consertou a GRAVAÇÃO do pino. Ele passou a ser aceito e a chegar em
|
|
6
|
+
-- `public.user_unit_prefs`. E a unidade continuou não persistindo, porque o
|
|
7
|
+
-- problema tinha uma segunda metade, do lado da LEITURA.
|
|
8
|
+
--
|
|
9
|
+
-- ── O que a entrada faz com o pino ─────────────────────────────────────────
|
|
10
|
+
--
|
|
11
|
+
-- const reachable = branchesOnly(expandDown(units, bindings.map(…)), byId)
|
|
12
|
+
-- const entry = resolveEntry(reachable, { device, account, legacy })
|
|
13
|
+
--
|
|
14
|
+
-- e `resolveEntry` só honra um pino que a pessoa AINDA alcança:
|
|
15
|
+
--
|
|
16
|
+
-- if (canStand(pin.unitId)) return { unitId: pin.unitId, … }
|
|
17
|
+
-- …
|
|
18
|
+
-- return { unitId: undefined, reason: 'all', … } // o último degrau
|
|
19
|
+
--
|
|
20
|
+
-- A verificação é certa — um pino para uma loja à qual alguém perdeu acesso não
|
|
21
|
+
-- pode ressuscitar o acesso. O que estava errado era o `reachable` contra o qual
|
|
22
|
+
-- ela media.
|
|
23
|
+
--
|
|
24
|
+
-- ── De onde vinha o `reachable` ────────────────────────────────────────────
|
|
25
|
+
-- Do campo `bindings` deste bootstrap, que lê `public.v_unit_members` —
|
|
26
|
+
-- portanto `app.membership_units`, que guarda só o vínculo EXPLÍCITO, unidade
|
|
27
|
+
-- por unidade.
|
|
28
|
+
--
|
|
29
|
+
-- Quem alcança a conta inteira NÃO TEM LINHA LÁ. O acesso dessa pessoa mora em
|
|
30
|
+
-- `app.user_unit_access` com `via = 'all_units'` — a mesma tabela que a RLS lê,
|
|
31
|
+
-- e a mesma que a 017 acabou de adotar. É exatamente o caso do dono do Espaço
|
|
32
|
+
-- Facial: quatro unidades em `user_unit_access`, zero em `membership_units`.
|
|
33
|
+
--
|
|
34
|
+
-- Então `bindings` voltava `[]`, `reachable` era vazio, `canStand` era falso
|
|
35
|
+
-- para qualquer pino, e a entrada caía no último degrau: "todas as unidades".
|
|
36
|
+
-- Toda vez. O menu de unidades disfarçava a metade dele — ele tem um recuo
|
|
37
|
+
-- (`myUnitIds.length > 0 ? myUnitIds : todas`) que fazia o seletor mostrar as
|
|
38
|
+
-- quatro mesmo assim. `resolveEntry` não tem recuo nenhum, e é ele que decide
|
|
39
|
+
-- onde a tela abre.
|
|
40
|
+
--
|
|
41
|
+
-- ── A correção ─────────────────────────────────────────────────────────────
|
|
42
|
+
-- O bootstrap passa a dizer as duas coisas, porque são duas coisas:
|
|
43
|
+
--
|
|
44
|
+
-- • `bindings` — o que foi amarrado na mão. Continua sendo isto, sem
|
|
45
|
+
-- mudança: é o que a aba "Unidades" de uma pessoa edita.
|
|
46
|
+
-- • `reachable` — o que a pessoa ALCANÇA de verdade, de
|
|
47
|
+
-- `app.user_unit_ids_in` (#017). É contra isto que um pino
|
|
48
|
+
-- deve ser medido, porque é isto que o Postgres vai obedecer.
|
|
49
|
+
--
|
|
50
|
+
-- Confundir os dois é o bug: um deles é a intenção que alguém digitou, o outro
|
|
51
|
+
-- é o alcance que o banco reconhece, e só o segundo responde "posso ficar aqui?".
|
|
52
|
+
--
|
|
53
|
+
-- Campo NOVO e aditivo de propósito: um cliente que ainda não conhece
|
|
54
|
+
-- `reachable` continua lendo `bindings` como sempre leu.
|
|
55
|
+
--
|
|
56
|
+
-- NADA AQUI CONCEDE. `reachable` é a leitura de uma tabela que a RLS já usa
|
|
57
|
+
-- para decidir, e serve para escolher onde a tela ABRE. Errar aqui abre na
|
|
58
|
+
-- unidade errada — e as linhas que aparecerem lá continuam sendo as que o
|
|
59
|
+
-- Postgres deixar aparecer.
|
|
60
|
+
-- ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
CREATE OR REPLACE FUNCTION public.unit_bootstrap(p_tenant_id uuid, p_device_id text DEFAULT NULL::text)
|
|
63
|
+
RETURNS jsonb
|
|
64
|
+
LANGUAGE sql
|
|
65
|
+
STABLE
|
|
66
|
+
SET search_path TO 'public', 'pg_temp'
|
|
67
|
+
AS $function$
|
|
68
|
+
SELECT jsonb_build_object(
|
|
69
|
+
'mode', COALESCE((
|
|
70
|
+
SELECT t.unit_scoping_enabled FROM public.tenants t WHERE t.id = p_tenant_id
|
|
71
|
+
), false),
|
|
72
|
+
'units', COALESCE((
|
|
73
|
+
SELECT jsonb_agg(to_jsonb(l) ORDER BY l.is_headquarters DESC NULLS LAST, l.name)
|
|
74
|
+
FROM public.v_units l WHERE l.tenant_id = p_tenant_id
|
|
75
|
+
), '[]'::jsonb),
|
|
76
|
+
-- O vínculo EXPLÍCITO, como sempre. A 015 é que lhe deu o filtro de conta.
|
|
77
|
+
'bindings', COALESCE((
|
|
78
|
+
SELECT jsonb_agg(jsonb_build_object('location_id', lm.location_id, 'role', lm.role))
|
|
79
|
+
FROM public.v_unit_members lm
|
|
80
|
+
WHERE lm.user_id = (SELECT auth.uid())
|
|
81
|
+
AND lm.tenant_id = p_tenant_id
|
|
82
|
+
), '[]'::jsonb),
|
|
83
|
+
-- O ALCANCE. Inclui quem entra pela conta inteira (`via = 'all_units'`), que
|
|
84
|
+
-- é justamente quem não tem vínculo explícito nenhum.
|
|
85
|
+
'reachable', COALESCE((
|
|
86
|
+
SELECT jsonb_agg(u) FROM app.user_unit_ids_in(p_tenant_id) u
|
|
87
|
+
), '[]'::jsonb),
|
|
88
|
+
'scoped', COALESCE((
|
|
89
|
+
SELECT jsonb_object_agg(sr.resource_table, jsonb_build_object(
|
|
90
|
+
'unit_column', sr.unit_column, 'shareable', sr.shareable))
|
|
91
|
+
FROM public.scoped_resources sr
|
|
92
|
+
), '{}'::jsonb),
|
|
93
|
+
'device_pin', (
|
|
94
|
+
SELECT p.unit_id FROM public.user_unit_prefs p
|
|
95
|
+
WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id AND p.device_id = p_device_id
|
|
96
|
+
),
|
|
97
|
+
'device_pin_set', EXISTS (
|
|
98
|
+
SELECT 1 FROM public.user_unit_prefs p
|
|
99
|
+
WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id AND p.device_id = p_device_id
|
|
100
|
+
),
|
|
101
|
+
'account_pin', (
|
|
102
|
+
SELECT p.unit_id FROM public.user_unit_prefs p
|
|
103
|
+
WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
|
|
104
|
+
ORDER BY p.updated_at DESC LIMIT 1
|
|
105
|
+
),
|
|
106
|
+
'account_pin_set', EXISTS (
|
|
107
|
+
SELECT 1 FROM public.user_unit_prefs p
|
|
108
|
+
WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
$function$;
|
|
112
|
+
|
|
113
|
+
COMMENT ON FUNCTION public.unit_bootstrap(uuid, text) IS
|
|
114
|
+
'Tudo que a entrada precisa, numa viagem. `bindings` é o vínculo explícito (o que a aba Unidades edita); `reachable` (#018) é o alcance real, de app.user_unit_ids_in — inclui quem entra pela conta inteira e não tem vínculo nenhum. É contra `reachable` que um pino de entrada deve ser medido.';
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 019_distributing_one_row_stops_costing_the_table.sql — usar a distribuição
|
|
3
|
+
-- para UMA linha deixa de custar o preço dela em TODAS.
|
|
4
|
+
--
|
|
5
|
+
-- A 016 tirou `app.available_in_my_units` de dentro do laço da RLS com uma
|
|
6
|
+
-- guarda de TIPO: "este tenant tem alguma tupla de distribuição para
|
|
7
|
+
-- `people.person`?". Não depende da linha, então vira InitPlan e roda uma vez.
|
|
8
|
+
-- `/clients` foi de 10.036ms para 15ms.
|
|
9
|
+
--
|
|
10
|
+
-- A guarda de tipo tem um degrau, e o Espaço Facial chegou nele no mesmo dia.
|
|
11
|
+
--
|
|
12
|
+
-- ── O degrau ───────────────────────────────────────────────────────────────
|
|
13
|
+
-- 128 profissionais da rede atendem em MAIS DE UMA unidade. Uma coluna
|
|
14
|
+
-- `people.unit_id` guarda uma unidade, não seis — quem atende em várias se
|
|
15
|
+
-- descreve por tuplas `available_in`, que é para isso que elas existem.
|
|
16
|
+
--
|
|
17
|
+
-- Só que a guarda da 016 responde por TIPO, e cliente e profissional são o
|
|
18
|
+
-- mesmo tipo: `people.person`. Gravar os 128 tornaria
|
|
19
|
+
-- `grants_exist('people.person')` verdadeiro para o tenant inteiro, e os 3.545
|
|
20
|
+
-- CLIENTES — todos sem unidade — voltariam a pagar 2,5ms cada um. `/clients`
|
|
21
|
+
-- voltaria aos 10s por causa de um vínculo de equipe.
|
|
22
|
+
--
|
|
23
|
+
-- Uma otimização que se desliga sozinha quando o recurso que ela protege começa
|
|
24
|
+
-- a ser usado não é uma otimização, é uma armadilha com prazo.
|
|
25
|
+
--
|
|
26
|
+
-- ── Três coisas, medidas uma a uma ─────────────────────────────────────────
|
|
27
|
+
-- Depois de gravar os vínculos, `/clients` foi a 10.036ms. As três correções,
|
|
28
|
+
-- na ordem em que o planejador as entregou:
|
|
29
|
+
--
|
|
30
|
+
-- ① A PERGUNTA CERTA É SOBRE A LINHA, não sobre o tipo: "ESTA linha tem
|
|
31
|
+
-- alguma tupla?". Sondagem no prefixo (tenant_id, resource_type,
|
|
32
|
+
-- resource_id) da PK. A derivação da 016 continua valendo palavra por
|
|
33
|
+
-- palavra, só que verificada em vez de deduzida. 10.036ms → 2.110ms
|
|
34
|
+
--
|
|
35
|
+
-- ② `app.current_tenant_id()` SAI DO LAÇO. Ela é plpgsql, lê o JWT, casa um
|
|
36
|
+
-- regex e consulta `memberships` — e a guarda a chamava por linha. Com o
|
|
37
|
+
-- tenant recebido por parâmetro e `(select app.current_tenant_id())` na
|
|
38
|
+
-- POLÍTICA, ela vira InitPlan. 2.110ms → 690ms
|
|
39
|
+
--
|
|
40
|
+
-- ③ A CHAMADA EM SI é o que sobrou: 0,1ms por linha só para o Postgres
|
|
41
|
+
-- trocar o contexto de `SECURITY DEFINER`, e uma função assim nunca é
|
|
42
|
+
-- inlinada. Então a lista de linhas distribuídas é resolvida UMA vez
|
|
43
|
+
-- (`app.released_ids`, um InitPlan materializado) e o teste por linha vira
|
|
44
|
+
-- `id <> ALL (…)` — uma comparação em memória. 690ms → 19ms
|
|
45
|
+
--
|
|
46
|
+
-- (Os 641ms que o EXPLAIN ANALYZE mostrava no fim eram instrumentação: com
|
|
47
|
+
-- SubPlan por linha, medir custa mais do que executar. O tempo de parede da
|
|
48
|
+
-- consulta, cronometrado sem EXPLAIN, é 19ms.)
|
|
49
|
+
--
|
|
50
|
+
-- ── A forma final da política ──────────────────────────────────────────────
|
|
51
|
+
-- Três guardas antes da regra cara, e NENHUMA delas menciona coluna — as três
|
|
52
|
+
-- são InitPlan:
|
|
53
|
+
--
|
|
54
|
+
-- 1. o tenant distribui algo deste tipo?
|
|
55
|
+
-- 2. a linha tem unidade própria? (então é localizada, não distribuída)
|
|
56
|
+
-- 3. a linha está na lista das que têm tupla?
|
|
57
|
+
--
|
|
58
|
+
-- Só quem passa pelas três paga a resolução completa, que é o preço correto de
|
|
59
|
+
-- um recurso que realmente foi distribuído.
|
|
60
|
+
-- ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
-- ① As linhas que TÊM regra, resolvidas de uma vez. Sem id de linha no
|
|
63
|
+
-- argumento: é o que permite ao planejador materializá-la e reusá-la.
|
|
64
|
+
CREATE OR REPLACE FUNCTION app.released_ids(p_tenant uuid, p_type text)
|
|
65
|
+
RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
|
|
66
|
+
AS $fn$
|
|
67
|
+
SELECT DISTINCT g.resource_id FROM app.resource_grants g
|
|
68
|
+
WHERE g.tenant_id = p_tenant AND g.resource_type = p_type
|
|
69
|
+
AND g.relation IN ('available_in', 'unavailable_in');
|
|
70
|
+
$fn$;
|
|
71
|
+
|
|
72
|
+
REVOKE ALL ON FUNCTION app.released_ids(uuid, text) FROM PUBLIC;
|
|
73
|
+
GRANT ALL ON FUNCTION app.released_ids(uuid, text) TO authenticated;
|
|
74
|
+
GRANT ALL ON FUNCTION app.released_ids(uuid, text) TO service_role;
|
|
75
|
+
|
|
76
|
+
COMMENT ON FUNCTION app.released_ids(uuid, text) IS
|
|
77
|
+
'As linhas deste tipo que têm alguma tupla de distribuição (#019). Existe para ser InitPlan: resolvida uma vez, o teste por linha vira `id <> ALL (…)` em memória em vez de uma chamada SECURITY DEFINER, que custa 0,1ms só de trocar o contexto.';
|
|
78
|
+
|
|
79
|
+
-- ② As mesmas perguntas, recebendo o tenant em vez de deduzi-lo. A dedução é
|
|
80
|
+
-- cara e não depende da linha; quem chama de dentro de uma política já a
|
|
81
|
+
-- tem resolvida num InitPlan.
|
|
82
|
+
CREATE OR REPLACE FUNCTION app.grants_exist(p_tenant uuid, p_type text, p_relations text[])
|
|
83
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
|
|
84
|
+
AS $fn$
|
|
85
|
+
SELECT EXISTS (SELECT 1 FROM app.resource_grants g
|
|
86
|
+
WHERE g.tenant_id = p_tenant AND g.resource_type = p_type
|
|
87
|
+
AND g.relation = ANY (p_relations));
|
|
88
|
+
$fn$;
|
|
89
|
+
|
|
90
|
+
REVOKE ALL ON FUNCTION app.grants_exist(uuid, text, text[]) FROM PUBLIC;
|
|
91
|
+
GRANT ALL ON FUNCTION app.grants_exist(uuid, text, text[]) TO authenticated;
|
|
92
|
+
GRANT ALL ON FUNCTION app.grants_exist(uuid, text, text[]) TO service_role;
|
|
93
|
+
|
|
94
|
+
CREATE OR REPLACE FUNCTION app.unit_release_verdict(p_tenant uuid, p_type text, p_id uuid, p_unit uuid)
|
|
95
|
+
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
|
|
96
|
+
AS $fn$
|
|
97
|
+
SELECT coalesce(
|
|
98
|
+
(SELECT CASE WHEN bool_or(g.relation = 'unavailable_in') THEN 'deny' ELSE 'allow' END
|
|
99
|
+
FROM app.unit_chain(p_unit) c
|
|
100
|
+
JOIN app.resource_grants g
|
|
101
|
+
ON g.tenant_id = p_tenant
|
|
102
|
+
AND g.resource_type = p_type AND g.resource_id = p_id
|
|
103
|
+
AND g.subject_type = 'unit' AND g.subject_id = c.unit_id
|
|
104
|
+
AND g.relation IN ('available_in', 'unavailable_in')
|
|
105
|
+
GROUP BY c.depth ORDER BY c.depth LIMIT 1),
|
|
106
|
+
'none');
|
|
107
|
+
$fn$;
|
|
108
|
+
|
|
109
|
+
REVOKE ALL ON FUNCTION app.unit_release_verdict(uuid, text, uuid, uuid) FROM PUBLIC;
|
|
110
|
+
GRANT ALL ON FUNCTION app.unit_release_verdict(uuid, text, uuid, uuid) TO authenticated;
|
|
111
|
+
GRANT ALL ON FUNCTION app.unit_release_verdict(uuid, text, uuid, uuid) TO service_role;
|
|
112
|
+
|
|
113
|
+
-- A regra completa, com o tenant recebido e a guarda de LINHA na frente. Corpo
|
|
114
|
+
-- idêntico ao da 000 a partir do `WITH me`.
|
|
115
|
+
CREATE OR REPLACE FUNCTION app.available_in_my_units(p_tenant uuid, p_type text, p_id uuid)
|
|
116
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
|
|
117
|
+
AS $fn$
|
|
118
|
+
SELECT CASE
|
|
119
|
+
WHEN NOT EXISTS (
|
|
120
|
+
SELECT 1 FROM app.resource_grants g
|
|
121
|
+
WHERE g.tenant_id = p_tenant AND g.resource_type = p_type AND g.resource_id = p_id
|
|
122
|
+
AND g.relation IN ('available_in', 'unavailable_in')
|
|
123
|
+
) THEN true
|
|
124
|
+
ELSE (
|
|
125
|
+
WITH me AS (
|
|
126
|
+
SELECT auth.uid() AS uid,
|
|
127
|
+
nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
|
|
128
|
+
),
|
|
129
|
+
tup AS (
|
|
130
|
+
SELECT g.relation, g.subject_type, g.subject_id
|
|
131
|
+
FROM app.resource_grants g
|
|
132
|
+
WHERE g.tenant_id = p_tenant AND g.resource_type = p_type AND g.resource_id = p_id
|
|
133
|
+
AND g.relation IN ('available_in', 'unavailable_in')
|
|
134
|
+
),
|
|
135
|
+
-- me, or a role of mine: outside the tree, so it simply decides
|
|
136
|
+
direct AS (
|
|
137
|
+
SELECT t.relation FROM tup t, me
|
|
138
|
+
WHERE (t.subject_type = 'user' AND t.subject_id = me.uid)
|
|
139
|
+
OR (t.subject_type = 'role' AND t.subject_id IN (
|
|
140
|
+
SELECT m.role_id FROM app.memberships m
|
|
141
|
+
WHERE m.tenant_id = p_tenant AND m.user_id = me.uid AND m.active
|
|
142
|
+
UNION
|
|
143
|
+
SELECT mu.role_override_id FROM app.membership_units mu
|
|
144
|
+
WHERE mu.tenant_id = p_tenant AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL))
|
|
145
|
+
),
|
|
146
|
+
mine AS (
|
|
147
|
+
SELECT a.unit_id FROM app.user_unit_access a, me
|
|
148
|
+
WHERE a.tenant_id = p_tenant AND a.user_id = me.uid
|
|
149
|
+
AND (me.focus IS NULL OR a.unit_id = me.focus)
|
|
150
|
+
),
|
|
151
|
+
verdicts AS (
|
|
152
|
+
SELECT app.unit_release_verdict(p_tenant, p_type, p_id, mine.unit_id) AS v FROM mine
|
|
153
|
+
)
|
|
154
|
+
SELECT NOT EXISTS (SELECT 1 FROM direct WHERE relation = 'unavailable_in')
|
|
155
|
+
AND (
|
|
156
|
+
EXISTS (SELECT 1 FROM direct WHERE relation = 'available_in')
|
|
157
|
+
OR EXISTS (SELECT 1 FROM verdicts WHERE v = 'allow')
|
|
158
|
+
OR (NOT EXISTS (SELECT 1 FROM tup WHERE relation = 'available_in')
|
|
159
|
+
AND (EXISTS (SELECT 1 FROM verdicts WHERE v = 'none') OR NOT EXISTS (SELECT 1 FROM mine)))
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
END;
|
|
163
|
+
$fn$;
|
|
164
|
+
|
|
165
|
+
REVOKE ALL ON FUNCTION app.available_in_my_units(uuid, text, uuid) FROM PUBLIC;
|
|
166
|
+
GRANT ALL ON FUNCTION app.available_in_my_units(uuid, text, uuid) TO authenticated;
|
|
167
|
+
GRANT ALL ON FUNCTION app.available_in_my_units(uuid, text, uuid) TO service_role;
|
|
168
|
+
|
|
169
|
+
-- A forma de dois argumentos continua existindo para quem chama de fora de uma
|
|
170
|
+
-- política (RPCs, views) e não tem o tenant à mão.
|
|
171
|
+
CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
|
|
172
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
|
|
173
|
+
AS $fn$
|
|
174
|
+
SELECT app.available_in_my_units(app.current_tenant_id(), p_type, p_id);
|
|
175
|
+
$fn$;
|
|
176
|
+
|
|
177
|
+
COMMENT ON FUNCTION app.available_in_my_units(p_type text, p_id uuid) IS
|
|
178
|
+
'A regra de distribuição, resolvida ao longo da árvore de unidades (#223 + #227): sem tupla ⇒ em todo lugar; havendo, o nível mais próximo da cadeia de cada unidade no escopo decide, e negar vence permitir naquele nível. Desde #019 delega para a forma que recebe o tenant, para que a dedução dele não aconteça por linha.';
|
|
179
|
+
|
|
180
|
+
-- ③ O gerador da política de distribuição, com as três guardas na frente.
|
|
181
|
+
CREATE OR REPLACE FUNCTION app.scaffold_distributable(p_table regclass, p_resource_type text DEFAULT NULL::text, p_key_column text DEFAULT 'id'::text, p_unit_escape boolean DEFAULT true, p_enabled boolean DEFAULT true) RETURNS void
|
|
182
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
183
|
+
SET search_path TO ''
|
|
184
|
+
AS $fn$
|
|
185
|
+
DECLARE
|
|
186
|
+
v_schema text; v_name text; v_q text; v_policy text;
|
|
187
|
+
r app.scaffold_registry%ROWTYPE; v_rt text;
|
|
188
|
+
BEGIN
|
|
189
|
+
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
190
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
191
|
+
v_q := format('%I.%I', v_schema, v_name);
|
|
192
|
+
v_policy := v_name || '_available_in';
|
|
193
|
+
|
|
194
|
+
SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
|
|
195
|
+
IF NOT FOUND THEN
|
|
196
|
+
RAISE EXCEPTION 'scaffold_distributable: %.% is not scaffolded', v_schema, v_name USING ERRCODE = 'P0002';
|
|
197
|
+
END IF;
|
|
198
|
+
v_rt := coalesce(p_resource_type, r.resource_type);
|
|
199
|
+
|
|
200
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_policy, v_q);
|
|
201
|
+
|
|
202
|
+
IF p_enabled THEN
|
|
203
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
204
|
+
WHERE table_schema = v_schema AND table_name = v_name AND column_name = p_key_column) THEN
|
|
205
|
+
RAISE EXCEPTION 'scaffold_distributable: %.% has no column %', v_schema, v_name, p_key_column;
|
|
206
|
+
END IF;
|
|
207
|
+
-- RESTRICTIVE: permissive policies OR together, so a release rule that is
|
|
208
|
+
-- meant to REMOVE rows has to AND with the scaffold's select.
|
|
209
|
+
--
|
|
210
|
+
-- A ordem das guardas é o desenho (ver o cabeçalho): as três primeiras não
|
|
211
|
+
-- mencionam coluna nenhuma e por isso são InitPlan. `(select
|
|
212
|
+
-- app.current_tenant_id())` aparece três vezes e é avaliada uma.
|
|
213
|
+
EXECUTE format(
|
|
214
|
+
'CREATE POLICY %I ON %s AS RESTRICTIVE FOR SELECT TO authenticated USING ('
|
|
215
|
+
|| 'NOT (select app.grants_exist((select app.current_tenant_id()), %L, ARRAY[''available_in'', ''unavailable_in''])) '
|
|
216
|
+
|| 'OR %s%I <> ALL (select app.released_ids((select app.current_tenant_id()), %L)) '
|
|
217
|
+
|| 'OR (select app.available_in_my_units((select app.current_tenant_id()), %L, %I)))',
|
|
218
|
+
v_policy, v_q, v_rt,
|
|
219
|
+
CASE WHEN p_unit_escape THEN 'unit_id IS NOT NULL OR ' ELSE '' END,
|
|
220
|
+
p_key_column, v_rt, v_rt, p_key_column);
|
|
221
|
+
END IF;
|
|
222
|
+
|
|
223
|
+
UPDATE app.scaffold_registry
|
|
224
|
+
SET distributable = p_enabled,
|
|
225
|
+
dist_resource_type = CASE WHEN p_enabled THEN v_rt END,
|
|
226
|
+
dist_key_column = CASE WHEN p_enabled THEN p_key_column END,
|
|
227
|
+
dist_unit_escape = CASE WHEN p_enabled THEN p_unit_escape END
|
|
228
|
+
WHERE table_schema = v_schema AND table_name = v_name;
|
|
229
|
+
END $fn$;
|
|
230
|
+
|
|
231
|
+
-- Reescrever o que já está no banco: o texto das políticas existentes foi
|
|
232
|
+
-- assado quando elas foram criadas.
|
|
233
|
+
DO $do$
|
|
234
|
+
DECLARE r record; v_n int := 0;
|
|
235
|
+
BEGIN
|
|
236
|
+
FOR r IN SELECT table_schema, table_name, dist_resource_type, dist_key_column, dist_unit_escape
|
|
237
|
+
FROM app.scaffold_registry WHERE distributable ORDER BY 1, 2 LOOP
|
|
238
|
+
CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
|
|
239
|
+
PERFORM app.scaffold_distributable(
|
|
240
|
+
to_regclass(format('%I.%I', r.table_schema, r.table_name)),
|
|
241
|
+
r.dist_resource_type, coalesce(r.dist_key_column, 'id'), coalesce(r.dist_unit_escape, true), true);
|
|
242
|
+
v_n := v_n + 1;
|
|
243
|
+
END LOOP;
|
|
244
|
+
RAISE NOTICE '019: % política(s) de distribuição regerada(s)', v_n;
|
|
245
|
+
END $do$;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 020_the_agenda_asks_where_someone_works.sql — a agenda de uma unidade passa a
|
|
3
|
+
-- mostrar quem trabalha NELA.
|
|
4
|
+
--
|
|
5
|
+
-- O SINTOMA, depois de migrar as 61 filiais e carimbar a equipe: standing em
|
|
6
|
+
-- Brisamar, a agenda mostrava 404 profissionais de 739. Filtrou — e não pareceu
|
|
7
|
+
-- ter filtrado, porque 396 dos 404 são pessoas cujo `unit_id` é NULL.
|
|
8
|
+
--
|
|
9
|
+
-- ── Por que NULL passa por todo filtro ─────────────────────────────────────
|
|
10
|
+
-- `people` é `shareable` no registro (`public.scoped_resources`), e para um
|
|
11
|
+
-- recurso compartilhável `unit_id IS NULL` quer dizer "é da rede inteira". A
|
|
12
|
+
-- regra é certa e existe por um bom motivo: o Botox vendido em toda parte
|
|
13
|
+
-- continua aparecendo em Ipanema.
|
|
14
|
+
--
|
|
15
|
+
-- Só que uma PESSOA sem unidade não é "atende em toda parte". É NÃO LOTADA —
|
|
16
|
+
-- 268 delas porque o V1 nunca disse onde trabalham, e as outras 128 porque
|
|
17
|
+
-- atendem em várias e se descrevem por tuplas `available_in`, não por coluna.
|
|
18
|
+
-- Os dois casos caem no mesmo NULL, e a coluna sozinha não sabe distinguir.
|
|
19
|
+
--
|
|
20
|
+
-- Uma agenda que mostra 396 colunas de gente que pode nem pisar naquela loja
|
|
21
|
+
-- não está compartilhando ninguém; está deixando de responder a pergunta que
|
|
22
|
+
-- lhe fizeram.
|
|
23
|
+
--
|
|
24
|
+
-- ── O que esta view acrescenta ─────────────────────────────────────────────
|
|
25
|
+
-- `unit_ids`: ONDE a pessoa efetivamente atende, juntando as duas formas de
|
|
26
|
+
-- dizer isso que hoje vivem em lugares diferentes —
|
|
27
|
+
--
|
|
28
|
+
-- • a coluna `unit_id`, para quem tem uma só;
|
|
29
|
+
-- • as tuplas `available_in` de `app.resource_grants`, para quem tem várias.
|
|
30
|
+
--
|
|
31
|
+
-- E `NULL` quando não há nem uma nem outra, que agora é uma resposta DISTINTA
|
|
32
|
+
-- de "está em todas": é "ninguém disse". A agenda esconde essas de uma unidade
|
|
33
|
+
-- e as mostra em "todas as unidades", que é onde alguém pode notar a falta e
|
|
34
|
+
-- corrigi-la. Antes elas eram invisíveis por excesso.
|
|
35
|
+
--
|
|
36
|
+
-- O filtro vira `unit_ids=cs.{id}` no PostgREST — contenção de array, sem
|
|
37
|
+
-- enumerar unidade nenhuma no navegador, que é a única coisa que este desenho
|
|
38
|
+
-- se recusa a fazer.
|
|
39
|
+
--
|
|
40
|
+
-- security_invoker OBRIGATÓRIO: sem ele a view roda com os direitos do dono e
|
|
41
|
+
-- as políticas de `people` não são avaliadas — que é exatamente como views
|
|
42
|
+
-- vazaram entre tenants antes. Com ele, esta view não pode mostrar uma linha
|
|
43
|
+
-- que a tabela recusaria.
|
|
44
|
+
-- ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
DROP VIEW IF EXISTS public.v_bookable_people;
|
|
47
|
+
|
|
48
|
+
CREATE VIEW public.v_bookable_people
|
|
49
|
+
WITH (security_invoker = true)
|
|
50
|
+
AS
|
|
51
|
+
SELECT
|
|
52
|
+
p.id,
|
|
53
|
+
p.tenant_id,
|
|
54
|
+
p.kind,
|
|
55
|
+
p.name,
|
|
56
|
+
p.avatar_url,
|
|
57
|
+
p.is_active,
|
|
58
|
+
p.is_bookable,
|
|
59
|
+
p.unit_id,
|
|
60
|
+
-- Uma unidade própria já é a resposta completa; a lista de tuplas só é
|
|
61
|
+
-- consultada para quem não tem coluna. `NULL` é "ninguém disse", e é
|
|
62
|
+
-- deliberadamente diferente de um array vazio.
|
|
63
|
+
CASE
|
|
64
|
+
WHEN p.unit_id IS NOT NULL THEN ARRAY[p.unit_id]
|
|
65
|
+
ELSE (
|
|
66
|
+
SELECT array_agg(DISTINCT g.subject_id)
|
|
67
|
+
FROM app.resource_grants g
|
|
68
|
+
WHERE g.tenant_id = p.tenant_id
|
|
69
|
+
AND g.resource_type = 'people.person'
|
|
70
|
+
AND g.resource_id = p.id
|
|
71
|
+
AND g.relation = 'available_in'
|
|
72
|
+
AND g.subject_type = 'unit'
|
|
73
|
+
)
|
|
74
|
+
END AS unit_ids
|
|
75
|
+
FROM public.people p;
|
|
76
|
+
|
|
77
|
+
REVOKE ALL ON public.v_bookable_people FROM public, anon;
|
|
78
|
+
GRANT SELECT ON public.v_bookable_people TO authenticated, service_role;
|
|
79
|
+
|
|
80
|
+
COMMENT ON VIEW public.v_bookable_people IS
|
|
81
|
+
'Pessoas com `unit_ids`: onde cada uma efetivamente atende (#020), juntando a coluna `unit_id` de quem tem uma só com as tuplas `available_in` de quem tem várias. NULL = ninguém disse, que é diferente de "em todas" — a agenda de uma unidade esconde essas, e "todas as unidades" as mostra. security_invoker: as políticas de public.people continuam decidindo o que é visível.';
|