@fayz-ai/db 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -1,474 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 050_resource_grants.sql — the exception to the default, written down.
|
|
3
|
-
--
|
|
4
|
-
-- Unit scoping (039) answers "which units do I reach". It cannot answer the two
|
|
5
|
-
-- questions the product actually asks:
|
|
6
|
-
--
|
|
7
|
-
-- • "Botox is sold in 2 of our 15 units."
|
|
8
|
-
-- • "This patient record is the psychologist's, not the reception's — unless
|
|
9
|
-
-- she shares it with the other psychologists."
|
|
10
|
-
--
|
|
11
|
-
-- Both are the SAME shape: a record, a recipient, a level. That is one table,
|
|
12
|
-
-- `resource_grants`, modelled on the `__Share` rows Salesforce has used for
|
|
13
|
-
-- twenty years. The alternative — a `product_units` join here, a
|
|
14
|
-
-- `document_shares` there — is three mechanisms, three predicates and three
|
|
15
|
-
-- screens to debug.
|
|
16
|
-
--
|
|
17
|
-
-- WHAT DECIDES WHEN NO GRANT EXISTS is `resource_visibility_defaults`: per
|
|
18
|
-
-- tenant, per record type, one of
|
|
19
|
-
--
|
|
20
|
-
-- tenant everyone in the org (today's behaviour, the default)
|
|
21
|
-
-- unit only the record's unit
|
|
22
|
-
-- owner only whoever owns it (the patient-record case)
|
|
23
|
-
--
|
|
24
|
-
-- Salesforce calls this the org-wide default, and having it is the difference
|
|
25
|
-
-- between "sharing" and "a checkbox on every form".
|
|
26
|
-
--
|
|
27
|
-
-- `scoped_resources` is the registry both of them key off — one row per table
|
|
28
|
-
-- that participates, naming its unit and owner columns. It exists so that 041
|
|
29
|
-
-- (which adds the columns) and 042 (which emits the policies) and the settings
|
|
30
|
-
-- screen all read ONE list instead of three copies of an array that will drift.
|
|
31
|
-
--
|
|
32
|
-
-- WHY THESE POLICIES ARE INLINE, AND WHY THE STRICT ONES ARE RESTRICTIVE.
|
|
33
|
-
-- 002's DO block sweeps every public table carrying a `tenant_id` and gives it
|
|
34
|
-
-- the canonical policy. On a fresh pool it runs long before this file, so the
|
|
35
|
-
-- tables here would be born with RLS enabled and no policy at all — hence the
|
|
36
|
-
-- inline ones.
|
|
37
|
-
--
|
|
38
|
-
-- The sharper half: 002 is re-applied whenever its checksum moves or a pool is
|
|
39
|
-
-- rebuilt, and on THAT pass it does discover these tables and adds a PERMISSIVE
|
|
40
|
-
-- `tenant_isolation_insert` to each. Permissive policies OR together, so that
|
|
41
|
-
-- one policy would reopen every gate below — any member of the tenant could
|
|
42
|
-
-- write themselves a grant. The bench caught exactly this on its replay pass.
|
|
43
|
-
--
|
|
44
|
-
-- So the rule for every table in this file: a permissive policy carries the
|
|
45
|
-
-- tenant clause (which makes 002's sweep a no-op instead of a surprise), and
|
|
46
|
-
-- the ACTUAL rules are RESTRICTIVE. A restrictive policy cannot be widened by
|
|
47
|
-
-- anything added later, which is the only property worth having here.
|
|
48
|
-
--
|
|
49
|
-
-- Idempotent: safe to re-run, no-ops when already applied.
|
|
50
|
-
-- ============================================================================
|
|
51
|
-
|
|
52
|
-
-- ── The registry ────────────────────────────────────────────────────────────
|
|
53
|
-
-- Platform-owned, not tenant-owned: which tables carry a unit and an owner is a
|
|
54
|
-
-- property of the schema, and a tenant cannot opt a table in or out.
|
|
55
|
-
CREATE TABLE IF NOT EXISTS public.scoped_resources (
|
|
56
|
-
resource_table text PRIMARY KEY,
|
|
57
|
-
-- Named rather than assumed: `orders` and `schedules` have carried
|
|
58
|
-
-- `location_id` since 004_archetypes and that column IS the unit. Adding a
|
|
59
|
-
-- second one to the busiest tables in the product to satisfy a naming
|
|
60
|
-
-- preference would be the wrong trade.
|
|
61
|
-
unit_column text NOT NULL DEFAULT 'unit_id',
|
|
62
|
-
owner_column text NOT NULL DEFAULT 'owner_id',
|
|
63
|
-
-- A ledger is unit-scoped but not shareable: you do not hand someone a single
|
|
64
|
-
-- financial movement.
|
|
65
|
-
shareable boolean NOT NULL DEFAULT true,
|
|
66
|
-
-- The factory default a tenant starts from; per-tenant overrides live in
|
|
67
|
-
-- resource_visibility_defaults.
|
|
68
|
-
default_visibility text NOT NULL DEFAULT 'tenant'
|
|
69
|
-
CHECK (default_visibility IN ('tenant', 'unit', 'owner')),
|
|
70
|
-
label text
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
ALTER TABLE public.scoped_resources ENABLE ROW LEVEL SECURITY;
|
|
74
|
-
|
|
75
|
-
-- Readable by anyone signed in (the visibility settings screen lists it),
|
|
76
|
-
-- writable by nobody but the platform. There is deliberately no manage policy:
|
|
77
|
-
-- a row here is created by a migration, because adding a table to the security
|
|
78
|
-
-- model is a schema change.
|
|
79
|
-
DROP POLICY IF EXISTS "scoped_resources_select" ON public.scoped_resources;
|
|
80
|
-
CREATE POLICY "scoped_resources_select" ON public.scoped_resources FOR SELECT TO authenticated
|
|
81
|
-
USING (true);
|
|
82
|
-
|
|
83
|
-
GRANT SELECT ON public.scoped_resources TO authenticated;
|
|
84
|
-
GRANT ALL ON public.scoped_resources TO service_role;
|
|
85
|
-
|
|
86
|
-
-- ── The per-tenant default ──────────────────────────────────────────────────
|
|
87
|
-
CREATE TABLE IF NOT EXISTS public.resource_visibility_defaults (
|
|
88
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
89
|
-
resource_table text NOT NULL REFERENCES public.scoped_resources(resource_table) ON DELETE CASCADE,
|
|
90
|
-
default_visibility text NOT NULL CHECK (default_visibility IN ('tenant', 'unit', 'owner')),
|
|
91
|
-
updated_by uuid,
|
|
92
|
-
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
93
|
-
PRIMARY KEY (tenant_id, resource_table)
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
ALTER TABLE public.resource_visibility_defaults ENABLE ROW LEVEL SECURITY;
|
|
97
|
-
|
|
98
|
-
-- Everyone in the tenant reads it — a screen has to be able to say "clientes:
|
|
99
|
-
-- toda a organização" to the person looking at it. Only an admin changes it:
|
|
100
|
-
-- this is the setting that decides who sees what.
|
|
101
|
-
DROP POLICY IF EXISTS "rvd_select" ON public.resource_visibility_defaults;
|
|
102
|
-
CREATE POLICY "rvd_select" ON public.resource_visibility_defaults FOR SELECT TO authenticated
|
|
103
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
104
|
-
|
|
105
|
-
DROP POLICY IF EXISTS "rvd_manage" ON public.resource_visibility_defaults;
|
|
106
|
-
CREATE POLICY "rvd_manage" ON public.resource_visibility_defaults FOR ALL TO authenticated
|
|
107
|
-
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))
|
|
108
|
-
WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
|
|
109
|
-
|
|
110
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.resource_visibility_defaults TO authenticated;
|
|
111
|
-
GRANT ALL ON public.resource_visibility_defaults TO service_role;
|
|
112
|
-
|
|
113
|
-
-- ── Teams ───────────────────────────────────────────────────────────────────
|
|
114
|
-
-- A named recipient, so "os psicólogos" is written once instead of re-picking
|
|
115
|
-
-- five people on every record. A team has NO permissions of its own — it is a
|
|
116
|
-
-- grant target and nothing else, which is what keeps it from quietly becoming a
|
|
117
|
-
-- second, competing role system.
|
|
118
|
-
CREATE TABLE IF NOT EXISTS public.teams (
|
|
119
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
120
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
121
|
-
name text NOT NULL,
|
|
122
|
-
color text,
|
|
123
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
124
|
-
UNIQUE (tenant_id, name)
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
CREATE TABLE IF NOT EXISTS public.team_members (
|
|
128
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
129
|
-
team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
|
|
130
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
131
|
-
-- CASCADE and not SET NULL: a membership row whose person no longer exists is
|
|
132
|
-
-- a grant to nobody that still widens every predicate reading this table.
|
|
133
|
-
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
134
|
-
UNIQUE (team_id, user_id)
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
CREATE INDEX IF NOT EXISTS team_members_user_idx ON public.team_members (user_id, team_id);
|
|
138
|
-
|
|
139
|
-
ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY;
|
|
140
|
-
ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY;
|
|
141
|
-
|
|
142
|
-
DROP POLICY IF EXISTS "teams_select" ON public.teams;
|
|
143
|
-
CREATE POLICY "teams_select" ON public.teams FOR SELECT TO authenticated
|
|
144
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
145
|
-
|
|
146
|
-
DROP POLICY IF EXISTS "teams_manage" ON public.teams;
|
|
147
|
-
CREATE POLICY "teams_manage" ON public.teams FOR ALL TO authenticated
|
|
148
|
-
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))
|
|
149
|
-
WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
|
|
150
|
-
|
|
151
|
-
DROP POLICY IF EXISTS "team_members_select" ON public.team_members;
|
|
152
|
-
CREATE POLICY "team_members_select" ON public.team_members FOR SELECT TO authenticated
|
|
153
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
154
|
-
|
|
155
|
-
DROP POLICY IF EXISTS "team_members_manage" ON public.team_members;
|
|
156
|
-
CREATE POLICY "team_members_manage" ON public.team_members FOR ALL TO authenticated
|
|
157
|
-
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))
|
|
158
|
-
WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()));
|
|
159
|
-
|
|
160
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.teams, public.team_members TO authenticated;
|
|
161
|
-
GRANT ALL ON public.teams, public.team_members TO service_role;
|
|
162
|
-
|
|
163
|
-
-- ── The grants ──────────────────────────────────────────────────────────────
|
|
164
|
-
CREATE TABLE IF NOT EXISTS public.resource_grants (
|
|
165
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
166
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
167
|
-
resource_table text NOT NULL REFERENCES public.scoped_resources(resource_table) ON DELETE CASCADE,
|
|
168
|
-
resource_id uuid NOT NULL,
|
|
169
|
-
-- uuid and not text, and no 'role' member: a role grant would duplicate what
|
|
170
|
-
-- the per-unit role in 039 and the 'tenant' default already express, and a
|
|
171
|
-
-- text key would cost a cast on every index probe.
|
|
172
|
-
grantee_type text NOT NULL CHECK (grantee_type IN ('user', 'team', 'unit')),
|
|
173
|
-
grantee_id uuid NOT NULL,
|
|
174
|
-
access text NOT NULL DEFAULT 'read' CHECK (access IN ('read', 'write', 'full')),
|
|
175
|
-
-- 'manual' (someone clicked Compartilhar) vs 'catalog' (this product is sold
|
|
176
|
-
-- in these units). Same row, different story to tell on screen.
|
|
177
|
-
reason text NOT NULL DEFAULT 'manual',
|
|
178
|
-
-- SET NULL and not CASCADE: who handed the access out is history, and the
|
|
179
|
-
-- access itself must not disappear because that person left the company.
|
|
180
|
-
granted_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
181
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
-- The hot path: "what has been granted to me / to my units / to my teams".
|
|
185
|
-
CREATE INDEX IF NOT EXISTS resource_grants_grantee_idx
|
|
186
|
-
ON public.resource_grants (grantee_type, grantee_id, resource_table, resource_id);
|
|
187
|
-
|
|
188
|
-
-- The panel's direction ("who can see this record?") and the dedupe rule, in
|
|
189
|
-
-- one object. One grant per (record, recipient) — re-sharing changes the level,
|
|
190
|
-
-- it does not stack.
|
|
191
|
-
CREATE UNIQUE INDEX IF NOT EXISTS resource_grants_record_uniq
|
|
192
|
-
ON public.resource_grants (resource_table, resource_id, grantee_type, grantee_id);
|
|
193
|
-
|
|
194
|
-
-- Answers restricted_record_ids(): which rows of this table carry ANY unit
|
|
195
|
-
-- grant, i.e. which ones stopped meaning "available everywhere".
|
|
196
|
-
CREATE INDEX IF NOT EXISTS resource_grants_table_unit_idx
|
|
197
|
-
ON public.resource_grants (resource_table, resource_id)
|
|
198
|
-
WHERE grantee_type = 'unit';
|
|
199
|
-
|
|
200
|
-
ALTER TABLE public.resource_grants ENABLE ROW LEVEL SECURITY;
|
|
201
|
-
|
|
202
|
-
-- ── Helpers ─────────────────────────────────────────────────────────────────
|
|
203
|
-
-- Same discipline as 039: one argument at most, and it is a CONSTANT baked into
|
|
204
|
-
-- the policy text by 052's generator, never a column. A helper taking the row's
|
|
205
|
-
-- own values would be correlated and re-run per row.
|
|
206
|
-
--
|
|
207
|
-
-- SECURITY DEFINER because they read `resource_grants`, whose own policies
|
|
208
|
-
-- resolve through them — invoker semantics would recurse.
|
|
209
|
-
|
|
210
|
-
CREATE OR REPLACE FUNCTION public.shareable_tables()
|
|
211
|
-
RETURNS SETOF text
|
|
212
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
213
|
-
SET search_path = public, pg_temp
|
|
214
|
-
AS $$
|
|
215
|
-
SELECT resource_table FROM public.scoped_resources WHERE shareable;
|
|
216
|
-
$$;
|
|
217
|
-
|
|
218
|
-
-- Everything in `p_table` explicitly granted to me: to me by name, to a unit I
|
|
219
|
-
-- reach, or to a team I am in.
|
|
220
|
-
CREATE OR REPLACE FUNCTION public.granted_record_ids(p_table text)
|
|
221
|
-
RETURNS SETOF uuid
|
|
222
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
223
|
-
SET search_path = public, pg_temp
|
|
224
|
-
AS $$
|
|
225
|
-
SELECT g.resource_id
|
|
226
|
-
FROM public.resource_grants g
|
|
227
|
-
WHERE g.resource_table = p_table
|
|
228
|
-
AND (
|
|
229
|
-
(g.grantee_type = 'user' AND g.grantee_id = auth.uid())
|
|
230
|
-
OR (g.grantee_type = 'unit' AND g.grantee_id IN (SELECT public.user_unit_ids()))
|
|
231
|
-
OR (g.grantee_type = 'team' AND g.grantee_id IN (
|
|
232
|
-
SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = auth.uid()))
|
|
233
|
-
);
|
|
234
|
-
$$;
|
|
235
|
-
|
|
236
|
-
-- The rows of `p_table` that carry at least one UNIT grant.
|
|
237
|
-
--
|
|
238
|
-
-- This is what makes "Botox in 2 of 15" work without a second mechanism: a
|
|
239
|
-
-- product with no unit grant is sold everywhere, and the moment it gets two it
|
|
240
|
-
-- is sold in exactly those two. The predicate in 042 reads it as "a NULL unit
|
|
241
|
-
-- means the whole org, UNLESS somebody named the units".
|
|
242
|
-
CREATE OR REPLACE FUNCTION public.restricted_record_ids(p_table text)
|
|
243
|
-
RETURNS SETOF uuid
|
|
244
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
245
|
-
SET search_path = public, pg_temp
|
|
246
|
-
AS $$
|
|
247
|
-
SELECT DISTINCT g.resource_id
|
|
248
|
-
FROM public.resource_grants g
|
|
249
|
-
WHERE g.resource_table = p_table
|
|
250
|
-
AND g.grantee_type = 'unit';
|
|
251
|
-
$$;
|
|
252
|
-
|
|
253
|
-
-- Is this person one of ours? Used by the write gate to stop a grant pointing
|
|
254
|
-
-- at somebody outside the tenant.
|
|
255
|
-
--
|
|
256
|
-
-- SECURITY DEFINER and not an inline subquery on `tenant_members` on purpose:
|
|
257
|
-
-- that table's SELECT policy is `user_id = auth.uid()` on any pool that has not
|
|
258
|
-
-- taken 023, so an inline read would see only the caller and the gate would
|
|
259
|
-
-- silently allow granting to nobody but yourself.
|
|
260
|
-
CREATE OR REPLACE FUNCTION public.is_tenant_member(p_tenant_id uuid, p_user_id uuid)
|
|
261
|
-
RETURNS boolean
|
|
262
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
263
|
-
SET search_path = public, pg_temp
|
|
264
|
-
AS $$
|
|
265
|
-
SELECT EXISTS (
|
|
266
|
-
SELECT 1 FROM public.tenant_members tm
|
|
267
|
-
WHERE tm.tenant_id = p_tenant_id AND tm.user_id = p_user_id
|
|
268
|
-
);
|
|
269
|
-
$$;
|
|
270
|
-
|
|
271
|
-
-- Am I the owner of this one row? Used only in the write gate below, once per
|
|
272
|
-
-- inserted row.
|
|
273
|
-
--
|
|
274
|
-
-- The dynamic EXECUTE is safe because `p_table` is checked against the registry
|
|
275
|
-
-- FIRST — the table name comes from a controlled list, not from the caller. And
|
|
276
|
-
-- SECURITY DEFINER leaks nothing: the only thing this can ever tell you is
|
|
277
|
-
-- whether a row is yours, which you already know.
|
|
278
|
-
CREATE OR REPLACE FUNCTION public.owns_record(p_table text, p_id uuid)
|
|
279
|
-
RETURNS boolean
|
|
280
|
-
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
281
|
-
SET search_path = public, pg_temp
|
|
282
|
-
AS $$
|
|
283
|
-
DECLARE
|
|
284
|
-
v_owner_column text;
|
|
285
|
-
v_owner uuid;
|
|
286
|
-
BEGIN
|
|
287
|
-
IF auth.uid() IS NULL THEN
|
|
288
|
-
RETURN false;
|
|
289
|
-
END IF;
|
|
290
|
-
|
|
291
|
-
SELECT owner_column INTO v_owner_column
|
|
292
|
-
FROM public.scoped_resources WHERE resource_table = p_table;
|
|
293
|
-
IF v_owner_column IS NULL THEN
|
|
294
|
-
RETURN false;
|
|
295
|
-
END IF;
|
|
296
|
-
|
|
297
|
-
EXECUTE format('SELECT %I FROM public.%I WHERE id = $1', v_owner_column, p_table)
|
|
298
|
-
INTO v_owner USING p_id;
|
|
299
|
-
|
|
300
|
-
RETURN v_owner IS NOT NULL AND v_owner = auth.uid();
|
|
301
|
-
EXCEPTION WHEN OTHERS THEN
|
|
302
|
-
-- A registry row pointing at a table that has not been provisioned yet must
|
|
303
|
-
-- deny, not abort the caller's INSERT.
|
|
304
|
-
RETURN false;
|
|
305
|
-
END;
|
|
306
|
-
$$;
|
|
307
|
-
|
|
308
|
-
GRANT EXECUTE ON FUNCTION public.is_tenant_member(uuid, uuid) TO authenticated, service_role;
|
|
309
|
-
GRANT EXECUTE ON FUNCTION public.shareable_tables() TO authenticated, service_role;
|
|
310
|
-
GRANT EXECUTE ON FUNCTION public.granted_record_ids(text) TO authenticated, service_role;
|
|
311
|
-
GRANT EXECUTE ON FUNCTION public.restricted_record_ids(text) TO authenticated, service_role;
|
|
312
|
-
GRANT EXECUTE ON FUNCTION public.owns_record(text, uuid) TO authenticated, service_role;
|
|
313
|
-
|
|
314
|
-
-- ── Policies on the grants themselves ───────────────────────────────────────
|
|
315
|
-
-- This is where a sharing model is won or lost. If anyone can insert a row
|
|
316
|
-
-- here, anyone can grant themselves anything, and every predicate in 042 is
|
|
317
|
-
-- decoration.
|
|
318
|
-
--
|
|
319
|
-
-- NOTE, and do not "simplify" it away: none of these policies may call
|
|
320
|
-
-- granted_record_ids(). That function reads this table, and this table's
|
|
321
|
-
-- policies would then resolve through it — the self-loop 023's header warns
|
|
322
|
-
-- about, one table over.
|
|
323
|
-
DROP POLICY IF EXISTS "resource_grants_select" ON public.resource_grants;
|
|
324
|
-
CREATE POLICY "resource_grants_select" ON public.resource_grants FOR SELECT TO authenticated
|
|
325
|
-
USING (
|
|
326
|
-
tenant_id IN (SELECT public.user_tenant_ids())
|
|
327
|
-
AND (
|
|
328
|
-
tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
329
|
-
OR (grantee_type = 'user' AND grantee_id = (SELECT auth.uid()))
|
|
330
|
-
OR (grantee_type = 'unit' AND grantee_id IN (SELECT public.user_unit_ids()))
|
|
331
|
-
OR (grantee_type = 'team' AND grantee_id IN (
|
|
332
|
-
SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = (SELECT auth.uid())))
|
|
333
|
-
OR granted_by = (SELECT auth.uid())
|
|
334
|
-
)
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
-- The gate. Four conditions, each closing a different door:
|
|
338
|
-
--
|
|
339
|
-
-- (a) granted_by = me — you cannot write a grant that claims someone else
|
|
340
|
-
-- handed it out.
|
|
341
|
-
-- (b) the table is in the registry — without this, `resource_table` is free
|
|
342
|
-
-- text and someone grants themselves a row in a table the model never
|
|
343
|
-
-- meant to cover.
|
|
344
|
-
-- (c) owner-or-admin — deliberately NOT "anyone with write access". Answering
|
|
345
|
-
-- "may I write row R of table T?" needs dynamic SQL evaluated under that
|
|
346
|
-
-- table's own RLS, and that is exactly where subtle escalations live.
|
|
347
|
-
-- (d) the recipient belongs to this tenant — no granting across the boundary
|
|
348
|
-
-- that the whole product rests on.
|
|
349
|
-
DROP POLICY IF EXISTS "resource_grants_insert" ON public.resource_grants;
|
|
350
|
-
CREATE POLICY "resource_grants_insert" ON public.resource_grants FOR INSERT TO authenticated
|
|
351
|
-
WITH CHECK (
|
|
352
|
-
tenant_id IN (SELECT public.user_tenant_ids())
|
|
353
|
-
AND granted_by = (SELECT auth.uid())
|
|
354
|
-
AND resource_table IN (SELECT public.shareable_tables())
|
|
355
|
-
AND (
|
|
356
|
-
tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
357
|
-
OR public.owns_record(resource_table, resource_id)
|
|
358
|
-
)
|
|
359
|
-
AND (
|
|
360
|
-
(grantee_type = 'user' AND public.is_tenant_member(resource_grants.tenant_id, grantee_id))
|
|
361
|
-
OR (grantee_type = 'unit' AND grantee_id IN (
|
|
362
|
-
SELECT l.id FROM public.locations l WHERE l.tenant_id = resource_grants.tenant_id))
|
|
363
|
-
OR (grantee_type = 'team' AND grantee_id IN (
|
|
364
|
-
SELECT t.id FROM public.teams t WHERE t.tenant_id = resource_grants.tenant_id))
|
|
365
|
-
)
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
-- No UPDATE policy, on purpose. A grant is created or revoked, never edited:
|
|
369
|
-
-- mutating `access` in place makes the record's sharing history unreadable, the
|
|
370
|
-
-- same reason 026 keeps the audit trail append-only. Changing a level is a
|
|
371
|
-
-- delete and an insert, and both are visible.
|
|
372
|
-
DROP POLICY IF EXISTS "resource_grants_delete" ON public.resource_grants;
|
|
373
|
-
CREATE POLICY "resource_grants_delete" ON public.resource_grants FOR DELETE TO authenticated
|
|
374
|
-
USING (
|
|
375
|
-
tenant_id IN (SELECT public.user_tenant_ids())
|
|
376
|
-
AND (
|
|
377
|
-
tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
378
|
-
OR granted_by = (SELECT auth.uid())
|
|
379
|
-
OR public.owns_record(resource_table, resource_id)
|
|
380
|
-
)
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
GRANT SELECT, INSERT, DELETE ON public.resource_grants TO authenticated;
|
|
384
|
-
GRANT ALL ON public.resource_grants TO service_role;
|
|
385
|
-
|
|
386
|
-
-- ── The guards ──────────────────────────────────────────────────────────────
|
|
387
|
-
-- Everything above is PERMISSIVE, and permissive policies OR together — so any
|
|
388
|
-
-- policy added to these tables later (002's sweep is the concrete case, see the
|
|
389
|
-
-- header) silently widens them. These restrictive twins cannot be widened by
|
|
390
|
-
-- anything, ever, and they are what actually holds the line.
|
|
391
|
-
--
|
|
392
|
-
-- Written per command rather than as one FOR ALL: a restrictive policy's USING
|
|
393
|
-
-- clause applies to SELECT too, so a single FOR ALL admin guard would also stop
|
|
394
|
-
-- ordinary members from READING their own team list.
|
|
395
|
-
DO $$
|
|
396
|
-
DECLARE
|
|
397
|
-
t text;
|
|
398
|
-
admin_managed text[] := ARRAY['teams', 'team_members', 'resource_visibility_defaults'];
|
|
399
|
-
BEGIN
|
|
400
|
-
FOREACH t IN ARRAY admin_managed LOOP
|
|
401
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_insert_guard', t);
|
|
402
|
-
EXECUTE format(
|
|
403
|
-
'CREATE POLICY %I ON public.%I AS RESTRICTIVE FOR INSERT TO authenticated
|
|
404
|
-
WITH CHECK (tenant_id IN (SELECT public.user_admin_tenant_ids()))',
|
|
405
|
-
t || '_insert_guard', t);
|
|
406
|
-
|
|
407
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_update_guard', t);
|
|
408
|
-
EXECUTE format(
|
|
409
|
-
'CREATE POLICY %I ON public.%I AS RESTRICTIVE FOR UPDATE TO authenticated
|
|
410
|
-
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))',
|
|
411
|
-
t || '_update_guard', t);
|
|
412
|
-
|
|
413
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_delete_guard', t);
|
|
414
|
-
EXECUTE format(
|
|
415
|
-
'CREATE POLICY %I ON public.%I AS RESTRICTIVE FOR DELETE TO authenticated
|
|
416
|
-
USING (tenant_id IN (SELECT public.user_admin_tenant_ids()))',
|
|
417
|
-
t || '_delete_guard', t);
|
|
418
|
-
END LOOP;
|
|
419
|
-
END $$;
|
|
420
|
-
|
|
421
|
-
-- The grant gate itself, restated so it cannot be ORed away. This is the one
|
|
422
|
-
-- that matters: without it, a swept-in permissive INSERT policy lets any member
|
|
423
|
-
-- of the tenant write themselves access to any record.
|
|
424
|
-
DROP POLICY IF EXISTS "resource_grants_insert_guard" ON public.resource_grants;
|
|
425
|
-
CREATE POLICY "resource_grants_insert_guard" ON public.resource_grants
|
|
426
|
-
AS RESTRICTIVE FOR INSERT TO authenticated
|
|
427
|
-
WITH CHECK (
|
|
428
|
-
granted_by = (SELECT auth.uid())
|
|
429
|
-
AND resource_table IN (SELECT public.shareable_tables())
|
|
430
|
-
AND (
|
|
431
|
-
tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
432
|
-
OR public.owns_record(resource_table, resource_id)
|
|
433
|
-
)
|
|
434
|
-
AND (
|
|
435
|
-
(grantee_type = 'user' AND public.is_tenant_member(resource_grants.tenant_id, grantee_id))
|
|
436
|
-
OR (grantee_type = 'unit' AND grantee_id IN (
|
|
437
|
-
SELECT l.id FROM public.locations l WHERE l.tenant_id = resource_grants.tenant_id))
|
|
438
|
-
OR (grantee_type = 'team' AND grantee_id IN (
|
|
439
|
-
SELECT t.id FROM public.teams t WHERE t.tenant_id = resource_grants.tenant_id))
|
|
440
|
-
)
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
-- A grant is created or revoked, never edited (see the note above). Stated as a
|
|
444
|
-
-- restrictive `false` rather than as the absence of a policy, because absence is
|
|
445
|
-
-- exactly what a later sweep fills in.
|
|
446
|
-
DROP POLICY IF EXISTS "resource_grants_no_update" ON public.resource_grants;
|
|
447
|
-
CREATE POLICY "resource_grants_no_update" ON public.resource_grants
|
|
448
|
-
AS RESTRICTIVE FOR UPDATE TO authenticated
|
|
449
|
-
USING (false);
|
|
450
|
-
|
|
451
|
-
DROP POLICY IF EXISTS "resource_grants_delete_guard" ON public.resource_grants;
|
|
452
|
-
CREATE POLICY "resource_grants_delete_guard" ON public.resource_grants
|
|
453
|
-
AS RESTRICTIVE FOR DELETE TO authenticated
|
|
454
|
-
USING (
|
|
455
|
-
tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
456
|
-
OR granted_by = (SELECT auth.uid())
|
|
457
|
-
OR public.owns_record(resource_table, resource_id)
|
|
458
|
-
);
|
|
459
|
-
|
|
460
|
-
-- Who may even SEE that a record was shared. Restrictive for the same reason:
|
|
461
|
-
-- a swept-in permissive SELECT would turn the grant table into a map of every
|
|
462
|
-
-- private record in the tenant and who can read it.
|
|
463
|
-
DROP POLICY IF EXISTS "resource_grants_select_guard" ON public.resource_grants;
|
|
464
|
-
CREATE POLICY "resource_grants_select_guard" ON public.resource_grants
|
|
465
|
-
AS RESTRICTIVE FOR SELECT TO authenticated
|
|
466
|
-
USING (
|
|
467
|
-
tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
468
|
-
OR (grantee_type = 'user' AND grantee_id = (SELECT auth.uid()))
|
|
469
|
-
OR (grantee_type = 'unit' AND grantee_id IN (SELECT public.user_unit_ids()))
|
|
470
|
-
OR (grantee_type = 'team' AND grantee_id IN (
|
|
471
|
-
SELECT tm.team_id FROM public.team_members tm WHERE tm.user_id = (SELECT auth.uid())))
|
|
472
|
-
OR granted_by = (SELECT auth.uid())
|
|
473
|
-
OR public.owns_record(resource_table, resource_id)
|
|
474
|
-
);
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 051_scoped_columns.sql — the signature every record starts carrying.
|
|
3
|
-
--
|
|
4
|
-
-- 025 gave every core table a `created_by`, because "who registered this?" had
|
|
5
|
-
-- no answer anywhere. This file adds the other two halves of the same question:
|
|
6
|
-
--
|
|
7
|
-
-- unit_id WHERE it happened / which unit it belongs to. NULL is a real and
|
|
8
|
-
-- permanent answer: "the whole organization". A brand-level product
|
|
9
|
-
-- has no unit and never will.
|
|
10
|
-
-- owner_id WHO answers for it TODAY. Deliberately not the same column as
|
|
11
|
-
-- created_by: provenance is not responsibility. João registered the
|
|
12
|
-
-- sourdough; if João leaves, Ana owns it, and the history still says
|
|
13
|
-
-- João made it.
|
|
14
|
-
--
|
|
15
|
-
-- WHAT ALREADY EXISTS AND IS REUSED. `orders`, `appointments` and `schedules`
|
|
16
|
-
-- have carried `location_id` since 004_archetypes, pointing at the very table
|
|
17
|
-
-- 039 turned into the unit tree. That column IS the unit. Adding a second one
|
|
18
|
-
-- to the three busiest tables in the product to satisfy a naming preference
|
|
19
|
-
-- would be the wrong trade, so the registry names the column per table instead.
|
|
20
|
-
--
|
|
21
|
-
-- WHAT IS DELIBERATELY LEFT OUT:
|
|
22
|
-
-- locations it IS the tree; a unit does not belong to a unit
|
|
23
|
-
-- order_items, their policies already resolve through the parent row
|
|
24
|
-
-- appointment_items (006_archetype_rls), so they inherit for free
|
|
25
|
-
-- addresses already has an `owner_id` with completely different,
|
|
26
|
-
-- polymorphic meaning (shop 0012: owner_type + owner_id).
|
|
27
|
-
-- Two columns with one name and two meanings in one schema
|
|
28
|
-
-- is a bug waiting for a maintainer.
|
|
29
|
-
-- plg_* plugin-owned tables register themselves from their own
|
|
30
|
-
-- migrations. `documents` is the exception: 002 already
|
|
31
|
-
-- lists it among the core tables, and it is the patient
|
|
32
|
-
-- record the whole owner-visibility mode exists for.
|
|
33
|
-
--
|
|
34
|
-
-- FACTORY VISIBILITY, and why it is not uniform: a transaction belongs to the
|
|
35
|
-
-- unit where it happened; a catalogue belongs to the brand. So appointments,
|
|
36
|
-
-- orders, transactions and schedules start at 'unit' and everything else starts
|
|
37
|
-
-- at 'tenant'. A tenant that never turns unit scoping on is unaffected either
|
|
38
|
-
-- way — nothing in this file reads these values until 042, and 042 short
|
|
39
|
-
-- circuits while the switch is off.
|
|
40
|
-
--
|
|
41
|
-
-- Additive and idempotent. Nullable ADD COLUMN does not rewrite a table on
|
|
42
|
-
-- PG 11+, so this is safe on a populated pool.
|
|
43
|
-
-- ============================================================================
|
|
44
|
-
|
|
45
|
-
-- ── Columns, indexes and the stamping trigger, per table ────────────────────
|
|
46
|
-
DO $$
|
|
47
|
-
DECLARE
|
|
48
|
-
r record;
|
|
49
|
-
spec jsonb := '[
|
|
50
|
-
{"t": "people", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Pessoas"},
|
|
51
|
-
{"t": "categories", "unit": "unit_id", "vis": "tenant", "share": false, "label": "Categorias"},
|
|
52
|
-
{"t": "products", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Produtos"},
|
|
53
|
-
{"t": "services", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Serviços"},
|
|
54
|
-
{"t": "documents", "unit": "unit_id", "vis": "tenant", "share": true, "label": "Documentos"},
|
|
55
|
-
{"t": "orders", "unit": "location_id", "vis": "unit", "share": false, "label": "Pedidos"},
|
|
56
|
-
{"t": "transactions", "unit": "unit_id", "vis": "unit", "share": false, "label": "Lançamentos"},
|
|
57
|
-
{"t": "appointments", "unit": "location_id", "vis": "unit", "share": false, "label": "Agendamentos"},
|
|
58
|
-
{"t": "schedules", "unit": "location_id", "vis": "unit", "share": false, "label": "Horários"}
|
|
59
|
-
]'::jsonb;
|
|
60
|
-
v_t text;
|
|
61
|
-
v_unit text;
|
|
62
|
-
v_has_author boolean;
|
|
63
|
-
v_owner_default text;
|
|
64
|
-
BEGIN
|
|
65
|
-
FOR r IN SELECT * FROM jsonb_array_elements(spec) AS e(v) LOOP
|
|
66
|
-
v_t := r.v ->> 't';
|
|
67
|
-
v_unit := r.v ->> 'unit';
|
|
68
|
-
|
|
69
|
-
-- The direct answer to "the ledger is not to be trusted": a pool may or may
|
|
70
|
-
-- not have this table regardless of what any migration record claims.
|
|
71
|
-
IF to_regclass('public.' || v_t) IS NULL THEN
|
|
72
|
-
CONTINUE;
|
|
73
|
-
END IF;
|
|
74
|
-
|
|
75
|
-
-- ON DELETE SET NULL, not CASCADE: closing a unit must not delete its
|
|
76
|
-
-- history. The rows fall back to "the whole organization", which is the
|
|
77
|
-
-- honest state for a record whose unit no longer exists.
|
|
78
|
-
EXECUTE format(
|
|
79
|
-
'ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS %I uuid REFERENCES public.locations(id) ON DELETE SET NULL',
|
|
80
|
-
v_t, v_unit);
|
|
81
|
-
|
|
82
|
-
EXECUTE format(
|
|
83
|
-
'ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL',
|
|
84
|
-
v_t);
|
|
85
|
-
|
|
86
|
-
-- tenant_id leads because every app query already carries it. These indexes
|
|
87
|
-
-- are for the APPLICATION's own filters, not for the policy: a disjunction
|
|
88
|
-
-- in an RLS predicate can never be index-driven (the rewriter puts it in
|
|
89
|
-
-- securityQuals, where a sublink is never turned into a semijoin), so it is
|
|
90
|
-
-- always a filter. Do not add more indexes hoping to speed the policy up.
|
|
91
|
-
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I (tenant_id, %I)',
|
|
92
|
-
v_t || '_unit_idx', v_t, v_unit);
|
|
93
|
-
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I (tenant_id, owner_id) WHERE owner_id IS NOT NULL',
|
|
94
|
-
v_t || '_owner_idx', v_t);
|
|
95
|
-
|
|
96
|
-
-- `created_by` arrives with 025 for the archetypes and with plugin-forms
|
|
97
|
-
-- for `documents`. Checked rather than assumed: a pool assembled by hand
|
|
98
|
-
-- may be missing it, and a migration that fails on a drifted pool is a
|
|
99
|
-
-- migration nobody can apply.
|
|
100
|
-
SELECT EXISTS (
|
|
101
|
-
SELECT 1 FROM information_schema.columns
|
|
102
|
-
WHERE table_schema = 'public' AND table_name = v_t AND column_name = 'created_by'
|
|
103
|
-
) INTO v_has_author;
|
|
104
|
-
|
|
105
|
-
-- Existing rows get the author as owner where there is one. Rows that
|
|
106
|
-
-- predate 025 have no author and stay ownerless — inventing one would
|
|
107
|
-
-- fabricate responsibility, and 042 reads a NULL owner as "nobody owns
|
|
108
|
-
-- this", which is exactly right: an ownerless record can never be private.
|
|
109
|
-
IF v_has_author THEN
|
|
110
|
-
EXECUTE format('UPDATE public.%I SET owner_id = created_by WHERE owner_id IS NULL AND created_by IS NOT NULL', v_t);
|
|
111
|
-
END IF;
|
|
112
|
-
|
|
113
|
-
v_owner_default := CASE WHEN v_has_author THEN 'COALESCE(NEW.created_by, auth.uid())' ELSE 'auth.uid()' END;
|
|
114
|
-
|
|
115
|
-
-- One generated trigger function per table instead of one shared function
|
|
116
|
-
-- reading TG_ARGV: assigning to a column whose name is only known at
|
|
117
|
-
-- runtime means a to_jsonb/jsonb_populate_record round trip through every
|
|
118
|
-
-- inserted row, and that round trip is exactly where a text[] or a numeric
|
|
119
|
-
-- quietly changes shape. Baking the two column names in keeps it a plain
|
|
120
|
-
-- field assignment.
|
|
121
|
-
--
|
|
122
|
-
-- IT MUST NEVER RAISE. place_order, fn_invoice_from_order_internal and
|
|
123
|
-
-- every agent_* RPC are SECURITY DEFINER and insert into these tables. RLS
|
|
124
|
-
-- does not apply to them — but a BEFORE INSERT trigger still fires, so a
|
|
125
|
-
-- trigger that complains takes down anonymous checkout. Validation is the
|
|
126
|
-
-- WITH CHECK clause's job in 042, which those paths legitimately bypass.
|
|
127
|
-
EXECUTE format($f$
|
|
128
|
-
CREATE OR REPLACE FUNCTION public.%1$I() RETURNS trigger
|
|
129
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $body$
|
|
130
|
-
BEGIN
|
|
131
|
-
IF NEW.%2$I IS NULL THEN
|
|
132
|
-
NEW.%2$I := public.requested_unit();
|
|
133
|
-
END IF;
|
|
134
|
-
IF NEW.owner_id IS NULL THEN
|
|
135
|
-
NEW.owner_id := %3$s;
|
|
136
|
-
END IF;
|
|
137
|
-
RETURN NEW;
|
|
138
|
-
EXCEPTION WHEN OTHERS THEN
|
|
139
|
-
RETURN NEW;
|
|
140
|
-
END $body$;
|
|
141
|
-
$f$, 'stamp_scope_' || v_t, v_unit, v_owner_default);
|
|
142
|
-
|
|
143
|
-
EXECUTE format('REVOKE ALL ON FUNCTION public.%I() FROM public, anon', 'stamp_scope_' || v_t);
|
|
144
|
-
|
|
145
|
-
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', v_t || '_scope_stamp', v_t);
|
|
146
|
-
EXECUTE format(
|
|
147
|
-
'CREATE TRIGGER %I BEFORE INSERT ON public.%I FOR EACH ROW EXECUTE FUNCTION public.%I()',
|
|
148
|
-
v_t || '_scope_stamp', v_t, 'stamp_scope_' || v_t);
|
|
149
|
-
|
|
150
|
-
-- The registry row. This is the list 042 loops over and the settings screen
|
|
151
|
-
-- reads — one source of truth instead of the same array copied into three
|
|
152
|
-
-- files that will drift apart on the first table anyone adds.
|
|
153
|
-
INSERT INTO public.scoped_resources
|
|
154
|
-
(resource_table, unit_column, owner_column, shareable, default_visibility, label)
|
|
155
|
-
VALUES
|
|
156
|
-
(v_t, v_unit, 'owner_id', (r.v ->> 'share')::boolean, r.v ->> 'vis', r.v ->> 'label')
|
|
157
|
-
ON CONFLICT (resource_table) DO UPDATE
|
|
158
|
-
SET unit_column = EXCLUDED.unit_column,
|
|
159
|
-
owner_column = EXCLUDED.owner_column,
|
|
160
|
-
shareable = EXCLUDED.shareable,
|
|
161
|
-
label = EXCLUDED.label;
|
|
162
|
-
-- default_visibility is NOT updated on conflict: it is the factory setting,
|
|
163
|
-
-- and re-running a migration must not walk back a decision an operator made
|
|
164
|
-
-- (a tenant's own choice lives in resource_visibility_defaults regardless).
|
|
165
|
-
END LOOP;
|
|
166
|
-
END $$;
|
|
167
|
-
|
|
168
|
-
-- ── The effective visibility of a record type, per tenant ───────────────────
|
|
169
|
-
-- The tenant's own choice wins over the factory setting. Restricted to tenants
|
|
170
|
-
-- that actually turned unit scoping on, because that is the only set 042 ever
|
|
171
|
-
-- asks about — and it keeps this set small enough to hash once per query.
|
|
172
|
-
--
|
|
173
|
-
-- Two arguments, both constants baked into the policy text by 052's generator,
|
|
174
|
-
-- so it stays uncorrelated: one InitPlan per query, not one call per row.
|
|
175
|
-
CREATE OR REPLACE FUNCTION public.resource_mode_tenants(p_table text, p_mode text)
|
|
176
|
-
RETURNS SETOF uuid
|
|
177
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
178
|
-
SET search_path = public, pg_temp
|
|
179
|
-
AS $$
|
|
180
|
-
SELECT t.id
|
|
181
|
-
FROM public.tenants t
|
|
182
|
-
LEFT JOIN public.resource_visibility_defaults d
|
|
183
|
-
ON d.tenant_id = t.id AND d.resource_table = p_table
|
|
184
|
-
WHERE t.unit_scoping_enabled
|
|
185
|
-
AND COALESCE(
|
|
186
|
-
d.default_visibility,
|
|
187
|
-
(SELECT sr.default_visibility FROM public.scoped_resources sr
|
|
188
|
-
WHERE sr.resource_table = p_table)
|
|
189
|
-
) = p_mode;
|
|
190
|
-
$$;
|
|
191
|
-
|
|
192
|
-
GRANT EXECUTE ON FUNCTION public.resource_mode_tenants(text, text) TO authenticated, service_role;
|