@fayz-ai/db 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,341 @@
1
+ -- ============================================================================
2
+ -- 134_field_rules_on_read.sql — a hidden field stops being a front-end
3
+ -- convention (#221).
4
+ --
5
+ -- `field_rules` (085) already models this: (tenant, unit, context, reference,
6
+ -- field_key, state hidden|visible|required, role_key), migrated from V1's
7
+ -- field_visibility_rules, with a per-unit override for free. "Reception does not
8
+ -- see the patient's phone" is ONE ROW, set by the tenant admin, no deploy.
9
+ --
10
+ -- What was missing is enforcement on READ. `field_rules_check` runs on the write
11
+ -- path (105/106) and validates `required`; `hidden` was honoured by the front
12
+ -- end, so the value was in the PostgREST response.
13
+ --
14
+ -- RLS cannot close this — it is row-level by construction, and no policy hides a
15
+ -- column. Column privileges cannot either: GRANT SELECT (col) is per DATABASE
16
+ -- role, and every logged-in user is `authenticated`.
17
+ --
18
+ -- WHY THE VIEW CARRIES THE PREDICATE INSTEAD OF `security_invoker`
19
+ --
20
+ -- `security_invoker = true` keeps RLS working, but it also means the caller
21
+ -- needs SELECT on the base table — which is exactly the privilege the boundary
22
+ -- has to remove. Owner rights is the other half of the trade: no table privilege
23
+ -- needed, but `postgres` holds BYPASSRLS, so the row filter has to be IN THE
24
+ -- VIEW. That is not a new pattern here: `v_public_services` (plugin-agenda 015)
25
+ -- made the same call for the same reason.
26
+ --
27
+ -- The duplication that normally makes this a bad idea is removed at the source:
28
+ -- `app.scaffold_scope_sql()` is extracted from 116 and is now the ONE place the
29
+ -- row predicate is written. `scaffold_table`, `scaffold_refresh_policies` and
30
+ -- the masked view all render from it, so the view cannot drift from the policy —
31
+ -- they are the same string.
32
+ --
33
+ -- The mask itself resolves ONCE PER STATEMENT, not once per row: the hidden set
34
+ -- is `(select app.hidden_fields(…))`, which the planner lifts to an InitPlan —
35
+ -- the same trick the authz helpers have used since 041. That is only affordable
36
+ -- because #220 gave the session one unit; a per-row unit would mean a per-row
37
+ -- rule lookup.
38
+ --
39
+ -- What this file does NOT do: revoke the base table. That is the step that turns
40
+ -- the view from available into mandatory, it has its own blast radius, and it
41
+ -- gets its own migration.
42
+ --
43
+ -- Replay-safe: CREATE OR REPLACE + a generated view rebuilt from the catalog.
44
+ -- ============================================================================
45
+
46
+ -- ── the row predicate, written once ─────────────────────────────────────
47
+ -- Lifted verbatim out of 126's scaffold_table / scaffold_refresh_policies. The
48
+ -- string it returns is byte-identical to the one those two built inline, so the
49
+ -- policies this migration re-emits are the policies that were already there.
50
+ -- The third parameter is defaulted and unused here: 126 gives `unit_id` a
51
+ -- declared class per table, and this is the one place the predicate is written,
52
+ -- so the signature is the final one from the start. Defining the 2-arg form now
53
+ -- and widening it later would leave both overloads alive on a replay — 124 would
54
+ -- recreate the narrow one beside 136's wide one, and every 2-arg call would stop
55
+ -- resolving. That is not hypothetical: it is what the force-replay pass caught.
56
+ CREATE OR REPLACE FUNCTION app.scaffold_scope_sql(p_resource_type text, p_plugin text, p_scope text DEFAULT NULL)
57
+ RETURNS text
58
+ LANGUAGE sql IMMUTABLE
59
+ SET search_path = ''
60
+ AS $$
61
+ SELECT format(
62
+ 'tenant_id = (select app.current_tenant_id())'
63
+ || CASE WHEN p_scope = 'unit'
64
+ THEN ' AND (select app.has_unit(unit_id))'
65
+ ELSE ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))' END
66
+ || ' AND (NOT (select app.owner_scoped(%L))'
67
+ || ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
68
+ || ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
69
+ || ' OR (select app.has_permission(%L, unit_id)))',
70
+ p_resource_type, p_resource_type, p_plugin || '.read_all');
71
+ $$;
72
+ REVOKE ALL ON FUNCTION app.scaffold_scope_sql(text, text, text) FROM public, anon, authenticated;
73
+
74
+ COMMENT ON FUNCTION app.scaffold_scope_sql(text, text, text) IS
75
+ 'The row-scope predicate of the §4/§7 template, as text. The ONE place it is written: scaffold_table, scaffold_refresh_policies and app.masked_view_refresh all render from it, so a masked view can never drift from the policy it mirrors (#221).';
76
+
77
+ -- ── which fields are hidden from me, right now ──────────────────────────
78
+ -- Thin on purpose: field_rules_resolve (085) already resolves tenant, effective
79
+ -- role per unit, the unit override and its precedence, and even the
80
+ -- `available_in` release on the RULE itself. This only picks the hidden ones and
81
+ -- returns an array, which is the shape a per-statement InitPlan wants.
82
+ --
83
+ -- The unit is the SESSION's unit (#220). Without a focus there is no unit whose
84
+ -- override could apply — "which branch's rule?" has no answer — so an unfocused
85
+ -- session gets the tenant-level rules. That is the honest reading, not a
86
+ -- fallback.
87
+ CREATE OR REPLACE FUNCTION app.hidden_fields(p_context text, p_reference text)
88
+ RETURNS text[]
89
+ LANGUAGE sql STABLE SECURITY DEFINER
90
+ SET search_path = ''
91
+ AS $$
92
+ SELECT coalesce(array_agg(r.field_key ORDER BY r.field_key), '{}'::text[])
93
+ FROM public.field_rules_resolve(
94
+ p_context, p_reference,
95
+ nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid)) r
96
+ WHERE r.state = 'hidden';
97
+ $$;
98
+ REVOKE ALL ON FUNCTION app.hidden_fields(text, text) FROM public, anon;
99
+ GRANT EXECUTE ON FUNCTION app.hidden_fields(text, text) TO authenticated, service_role;
100
+
101
+ COMMENT ON FUNCTION app.hidden_fields(text, text) IS
102
+ 'The field_keys `field_rules` hides from the caller, for the session''s unit (#221). Call it as (select app.hidden_fields(…)) so it resolves once per statement rather than once per row.';
103
+
104
+ -- ── the registry of masked views ────────────────────────────────────────
105
+ -- N16 refuses an owner-rights view over a tenant_id table, and it is right to:
106
+ -- an owner-invoked view IS an RLS bypass. A masked view is owner-rights on
107
+ -- purpose, so it needs to be distinguishable from the mistake N16 exists to
108
+ -- catch — and a name on a hand-maintained allowlist is not a distinction, it is
109
+ -- a hole with a comment next to it.
110
+ --
111
+ -- So the exception is earned, not granted: only app.masked_view_refresh writes
112
+ -- here, and 900_verification asserts that every view listed carries the tenant
113
+ -- filter and the read permission in its own body. Registering buys nothing on
114
+ -- its own.
115
+ CREATE TABLE IF NOT EXISTS app.masked_views (
116
+ table_schema text NOT NULL,
117
+ table_name text NOT NULL,
118
+ view_name text NOT NULL,
119
+ context text NOT NULL,
120
+ reference text NOT NULL,
121
+ applied_at timestamptz NOT NULL DEFAULT now(),
122
+ PRIMARY KEY (table_schema, table_name)
123
+ );
124
+ ALTER TABLE app.masked_views ENABLE ROW LEVEL SECURITY;
125
+ GRANT SELECT ON app.masked_views TO authenticated;
126
+ DROP POLICY IF EXISTS masked_views_select ON app.masked_views;
127
+ CREATE POLICY masked_views_select ON app.masked_views FOR SELECT TO authenticated USING (true);
128
+ COMMENT ON TABLE app.masked_views IS
129
+ 'Which tables read through a field_rules-masked view, and under which context/reference (#221). Written only by app.masked_view_refresh; 900_verification/001 checks that each listed view actually carries the row predicate, so a row here is not a bypass permit.';
130
+
131
+ -- ── generate the masked read view for a scaffolded table ────────────────
132
+ -- Structural columns pass through untouched — the view is still joined on and
133
+ -- filtered by them. Everything else is wrapped so a hidden field reads NULL.
134
+ --
135
+ -- DROP + CREATE rather than CREATE OR REPLACE: replace refuses a changed column
136
+ -- list, and a table that loses a column has to be able to lose it here too.
137
+ CREATE OR REPLACE FUNCTION app.masked_view_refresh(
138
+ p_table regclass,
139
+ p_context text,
140
+ p_reference text
141
+ )
142
+ RETURNS void
143
+ LANGUAGE plpgsql SECURITY DEFINER
144
+ SET search_path = ''
145
+ AS $$
146
+ DECLARE
147
+ v_schema text;
148
+ v_name text;
149
+ v_view text;
150
+ r app.scaffold_registry%ROWTYPE;
151
+ v_cols text;
152
+ v_structural constant text[] := ARRAY['id','tenant_id','unit_id','owner_id','created_at','updated_at'];
153
+ BEGIN
154
+ SELECT n.nspname, c.relname INTO v_schema, v_name
155
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
156
+
157
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
158
+ IF NOT FOUND THEN
159
+ RAISE EXCEPTION 'masked_view_refresh: %.% is not scaffolded — the view renders the scaffold''s own predicate', v_schema, v_name
160
+ USING ERRCODE = 'P0002';
161
+ END IF;
162
+ IF NOT EXISTS (SELECT 1 FROM public.field_rule_contexts WHERE key = p_context) THEN
163
+ RAISE EXCEPTION 'masked_view_refresh: unknown field-rule context %', p_context USING ERRCODE = '23503';
164
+ END IF;
165
+
166
+ v_view := 'v_' || v_name;
167
+
168
+ SELECT string_agg(
169
+ CASE WHEN c.column_name = ANY (v_structural)
170
+ THEN format('%I', c.column_name)
171
+ -- `@>` and not `= ANY (…)`: a parenthesised scalar subquery is
172
+ -- parsed as ANY's SUBQUERY form, which compares against rows and
173
+ -- chokes on a text[]. Containment takes the array directly and
174
+ -- still plans as a once-per-statement InitPlan.
175
+ ELSE format('CASE WHEN NOT ((select app.hidden_fields(%L, %L)) @> ARRAY[%L::text]) THEN %I END AS %I',
176
+ p_context, p_reference, c.column_name, c.column_name, c.column_name)
177
+ END, E',\n ' ORDER BY c.ordinal_position)
178
+ INTO v_cols
179
+ FROM information_schema.columns c
180
+ WHERE c.table_schema = v_schema AND c.table_name = v_name;
181
+
182
+ EXECUTE format('DROP VIEW IF EXISTS %I.%I', v_schema, v_view);
183
+ -- Owner rights on purpose (see the header): the predicate below IS the
184
+ -- scaffold's, rendered from the same function the policies render from.
185
+ EXECUTE format(
186
+ 'CREATE VIEW %I.%I AS SELECT %s FROM %I.%I WHERE %s AND (select app.has_permission(%L, unit_id))',
187
+ v_schema, v_view, v_cols, v_schema, v_name,
188
+ app.scaffold_scope_sql(r.resource_type, r.plugin), r.plugin || '.read');
189
+
190
+ EXECUTE format('REVOKE ALL ON %I.%I FROM public, anon', v_schema, v_view);
191
+ EXECUTE format('GRANT SELECT ON %I.%I TO authenticated, service_role', v_schema, v_view);
192
+ EXECUTE format(
193
+ 'COMMENT ON VIEW %I.%I IS %L',
194
+ v_schema, v_view,
195
+ format('Masked read of %I.%I (#221): every non-structural column reads NULL when field_rules hides it for the caller''s role and unit. Owner rights with the scaffold''s own row predicate — regenerate with app.masked_view_refresh(''%I.%I'', %L, %L), never edit by hand.',
196
+ v_schema, v_name, v_schema, v_name, p_context, p_reference));
197
+
198
+ INSERT INTO app.masked_views (table_schema, table_name, view_name, context, reference)
199
+ VALUES (v_schema, v_name, v_view, p_context, p_reference)
200
+ ON CONFLICT (table_schema, table_name) DO UPDATE
201
+ SET view_name = EXCLUDED.view_name, context = EXCLUDED.context,
202
+ reference = EXCLUDED.reference, applied_at = now();
203
+ END $$;
204
+ REVOKE ALL ON FUNCTION app.masked_view_refresh(regclass, text, text) FROM public, anon, authenticated;
205
+
206
+
207
+ -- ── scaffold_table, re-emitted to render from the shared predicate ──────
208
+ -- 126's function with one assignment changed: the inline format() that built
209
+ -- v_scope becomes the call to app.scaffold_scope_sql(). Everything else is 116
210
+ -- verbatim — this file lifts the text rather than retyping it, so "the policies
211
+ -- are the same policies" is a property of how it was produced, not a claim.
212
+ CREATE OR REPLACE FUNCTION app.scaffold_table(
213
+ p_table regclass,
214
+ p_resource_type text,
215
+ p_plugin text,
216
+ p_owner_scoping boolean DEFAULT true,
217
+ p_mode text DEFAULT 'enforce'
218
+ )
219
+ RETURNS void
220
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
221
+ AS $$
222
+ DECLARE
223
+ v_schema text;
224
+ v_name text;
225
+ v_q text; -- quoted qualified name
226
+ v_prev text[] := '{}';
227
+ v_rows bigint;
228
+ r record;
229
+ v_scope text; -- the row-scope predicate shared by every policy
230
+ BEGIN
231
+ IF p_mode NOT IN ('shadow', 'enforce') THEN
232
+ RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
233
+ END IF;
234
+ SELECT n.nspname, c.relname INTO v_schema, v_name
235
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
236
+ v_q := format('%I.%I', v_schema, v_name);
237
+
238
+ -- ── columns
239
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
240
+ RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
241
+ END IF;
242
+ EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
243
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'tenant_id') THEN
244
+ EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
245
+ IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
246
+ END IF;
247
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
248
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
249
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
250
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
251
+
252
+ -- ── unique(tenant_id, id) — enables composite FKs from other tables
253
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
254
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
255
+ END IF;
256
+ -- ── composite FKs: unit within tenant, owner a member of the tenant
257
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
258
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL', v_q, v_name || '_unit_fk');
259
+ END IF;
260
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
261
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL', v_q, v_name || '_owner_fk');
262
+ END IF;
263
+
264
+ -- ── tenant-first indexes
265
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
266
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, owner_id) WHERE owner_id IS NOT NULL', v_name || '_tenant_owner_idx', v_q);
267
+
268
+ -- ── updated_at
269
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
270
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
271
+
272
+ -- ── RLS
273
+ EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
274
+ EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
275
+ IF p_mode = 'enforce' THEN
276
+ FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
277
+ AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
278
+ v_prev := v_prev || r.policyname;
279
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
280
+ END LOOP;
281
+ END IF;
282
+
283
+ v_scope := app.scaffold_scope_sql(p_resource_type, p_plugin);
284
+
285
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
286
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
287
+ v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
288
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
289
+ EXECUTE format('CREATE POLICY %I ON %s FOR INSERT TO authenticated WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
290
+ v_name || '_authz_insert', v_q, p_plugin || '.create');
291
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
292
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
293
+ v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
294
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
295
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
296
+ v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
297
+
298
+ INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies)
299
+ VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev)
300
+ ON CONFLICT (table_schema, table_name) DO UPDATE
301
+ SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
302
+ mode = EXCLUDED.mode, applied_at = now(),
303
+ previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
304
+ THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
305
+ ELSE EXCLUDED.previous_policies END;
306
+ END $$;
307
+
308
+ -- ── scaffold_refresh_policies, same treatment ───────────────────────────
309
+ CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text)
310
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
311
+ DECLARE
312
+ v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
313
+ v_name text := split_part(replace(p_table, '"', ''), '.', 2);
314
+ v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
315
+ r app.scaffold_registry%ROWTYPE;
316
+ v_scope text;
317
+ BEGIN
318
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
319
+ IF NOT FOUND THEN
320
+ RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
321
+ END IF;
322
+
323
+ v_scope := app.scaffold_scope_sql(r.resource_type, r.plugin);
324
+
325
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
326
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
327
+ v_name || '_authz_select', v_q, v_scope, r.plugin || '.read');
328
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
329
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
330
+ v_name || '_authz_update', v_q, v_scope, r.plugin || '.edit', r.plugin || '.edit');
331
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
332
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
333
+ v_name || '_authz_delete', v_q, v_scope, r.plugin || '.delete');
334
+ -- the INSERT policy has no owner clause (there is no row yet to own)
335
+ END $$;
336
+ REVOKE ALL ON FUNCTION app.scaffold_refresh_policies(text) FROM public, anon, authenticated;
337
+
338
+ -- ── the pilot: people ───────────────────────────────────────────────────
339
+ -- context 'person' is 095's own ("The person record itself"); reference 'person'
340
+ -- is the plg_entity_fields reference the V1 rules migrated onto.
341
+ SELECT app.masked_view_refresh('public.people', 'person', 'person');
@@ -0,0 +1,158 @@
1
+ -- ============================================================================
2
+ -- 135_distribution_polarity.sql — distribution gets a subtractive form, so
3
+ -- "19 of 20 branches" stops costing 19 rows (#223).
4
+ --
5
+ -- `resource_grants` had only `available_in`, which is additive. Releasing a
6
+ -- service to 19 of 20 branches meant 19 tuples; there was no way to say
7
+ -- "everywhere except these two". And `products.unit_id` does not help — it holds
8
+ -- ONE value, so it covers exactly 1 of 20 and does nothing at 2.
9
+ --
10
+ -- an 'unavailable_in' tuple that applies to me → no
11
+ -- else, any 'available_in' tuple exists → only if one names a unit of mine
12
+ -- else → yes (default open, unchanged)
13
+ --
14
+ -- Cost becomes min(k, n−k): 1 of 20 is one row, 19 of 20 is one row.
15
+ --
16
+ -- BACKWARD COMPATIBLE BY CONSTRUCTION: with no `unavailable_in` tuples anywhere,
17
+ -- `excluded_in_my_units` is false and the expression reduces to 051's original.
18
+ -- No policy is regenerated either — the six RESTRICTIVE catalog policies call
19
+ -- `available_in_my_units`, so replacing the function is the whole change.
20
+ --
21
+ -- DENY IS "EVERY UNIT OF MINE", NOT "ANY"
22
+ --
23
+ -- RLS hides a row from the PERSON, not from a unit. A manager who holds b1 and
24
+ -- b2, with b2 excluded, must still see the row — she has a branch that offers
25
+ -- it. "Any" would hide it from her entirely, which is a different and wrong
26
+ -- statement. So the unit-subject exclusion only bites when no unit of hers
27
+ -- escapes it.
28
+ --
29
+ -- …and "of mine" respects the session's focus (#220). Focused on b2, b2 is the
30
+ -- only unit in scope, so an exclusion on b2 does hide the row — which is the
31
+ -- answer the person actually asked for. The two features compose: the focus is
32
+ -- what makes a per-unit exclusion legible at all.
33
+ --
34
+ -- A user-subject or role-subject exclusion has none of that subtlety: the
35
+ -- subject IS the person, so it simply bites.
36
+ --
37
+ -- Replay-safe: CREATE OR REPLACE only.
38
+ -- ============================================================================
39
+
40
+ -- ── excluded_in_my_units(type, id) ──────────────────────────────────────
41
+ CREATE OR REPLACE FUNCTION app.excluded_in_my_units(p_type text, p_id uuid)
42
+ RETURNS boolean
43
+ LANGUAGE sql STABLE SECURITY DEFINER
44
+ SET search_path = ''
45
+ AS $$
46
+ WITH me AS (
47
+ SELECT app.current_tenant_id() AS tenant_id,
48
+ auth.uid() AS uid,
49
+ nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
50
+ ),
51
+ ex AS (
52
+ SELECT g.subject_type, g.subject_id
53
+ FROM app.resource_grants g, me
54
+ WHERE g.tenant_id = me.tenant_id
55
+ AND g.resource_type = p_type
56
+ AND g.resource_id = p_id
57
+ AND g.relation = 'unavailable_in'
58
+ ),
59
+ -- the units in scope right now: what I hold, narrowed by the focus
60
+ mine AS (
61
+ SELECT a.unit_id
62
+ FROM app.user_unit_access a, me
63
+ WHERE a.tenant_id = me.tenant_id
64
+ AND a.user_id = me.uid
65
+ AND (me.focus IS NULL OR a.unit_id = me.focus)
66
+ )
67
+ SELECT
68
+ -- named directly: me, or a role I hold. No "every" about it.
69
+ EXISTS (
70
+ SELECT 1 FROM ex, me
71
+ WHERE (ex.subject_type = 'user' AND ex.subject_id = me.uid)
72
+ OR (ex.subject_type = 'role' AND ex.subject_id IN (
73
+ SELECT m.role_id FROM app.memberships m
74
+ WHERE m.tenant_id = me.tenant_id AND m.user_id = me.uid AND m.active
75
+ UNION
76
+ SELECT mu.role_override_id FROM app.membership_units mu
77
+ WHERE mu.tenant_id = me.tenant_id AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL)))
78
+ -- or every unit in scope is excluded — and there is at least one, so that a
79
+ -- caller who holds no units at all does not lose the catalogue to a rule
80
+ -- about units that cannot apply to them.
81
+ OR (
82
+ EXISTS (SELECT 1 FROM ex WHERE ex.subject_type = 'unit')
83
+ AND EXISTS (SELECT 1 FROM mine)
84
+ AND NOT EXISTS (
85
+ SELECT 1 FROM mine
86
+ WHERE NOT EXISTS (SELECT 1 FROM ex WHERE ex.subject_type = 'unit' AND ex.subject_id = mine.unit_id))
87
+ );
88
+ $$;
89
+ REVOKE ALL ON FUNCTION app.excluded_in_my_units(text, uuid) FROM public, anon;
90
+ GRANT EXECUTE ON FUNCTION app.excluded_in_my_units(text, uuid) TO authenticated, service_role;
91
+
92
+ COMMENT ON FUNCTION app.excluded_in_my_units(text, uuid) IS
93
+ 'Does an `unavailable_in` tuple hide this resource from the caller (#223)? A user- or role-subject tuple bites directly. A unit-subject tuple bites only when EVERY unit in scope is excluded — RLS hides a row from the person, not from a unit — and "in scope" is narrowed by the session''s operating unit (#220).';
94
+
95
+ -- ── has_grant(type, id, relations[]) — the unit subject respects the focus ──
96
+ -- Found by the test for "released to b1, focused on b2": the row stayed visible,
97
+ -- because 051's has_grant matches a unit-subject tuple against the caller's WHOLE
98
+ -- user_unit_access, and b1 is still hers even while she is operating in b2.
99
+ --
100
+ -- That is the same asymmetry the exclusion side had to solve, so it is solved in
101
+ -- the same place rather than worked around in the caller. The focus narrows what
102
+ -- "a unit of mine" means, for every relation and every caller — `available_in`
103
+ -- here, and `shared_with` in the owner clause of all 45 scaffolded tables. Both
104
+ -- readings are consistent: while operating in b2, a release to b1 is not offered
105
+ -- here, and a share with b1's staff is not mine to use here.
106
+ --
107
+ -- User- and role-subject tuples are untouched: the subject is the person, and a
108
+ -- person does not change branch.
109
+ CREATE OR REPLACE FUNCTION app.has_grant(p_type text, p_id uuid, p_relations text[])
110
+ RETURNS boolean
111
+ LANGUAGE sql STABLE SECURITY DEFINER
112
+ SET search_path = ''
113
+ AS $$
114
+ SELECT EXISTS (
115
+ SELECT 1
116
+ FROM app.resource_grants g
117
+ WHERE g.tenant_id = app.current_tenant_id()
118
+ AND g.resource_type = p_type
119
+ AND g.resource_id = p_id
120
+ AND g.relation = ANY (p_relations)
121
+ AND (
122
+ (g.subject_type = 'user' AND g.subject_id = auth.uid())
123
+ OR (g.subject_type = 'unit' AND EXISTS (
124
+ SELECT 1 FROM app.user_unit_access a
125
+ WHERE a.tenant_id = g.tenant_id AND a.user_id = auth.uid() AND a.unit_id = g.subject_id
126
+ AND (app.current_unit_id() IS NULL OR a.unit_id = app.current_unit_id())))
127
+ OR (g.subject_type = 'role' AND g.subject_id IN (
128
+ SELECT m.role_id FROM app.memberships m
129
+ WHERE m.tenant_id = g.tenant_id AND m.user_id = auth.uid() AND m.active
130
+ UNION
131
+ SELECT mu.role_override_id FROM app.membership_units mu
132
+ WHERE mu.tenant_id = g.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL))
133
+ )
134
+ );
135
+ $$;
136
+
137
+ COMMENT ON FUNCTION app.has_grant(text, uuid, text[]) IS
138
+ 'resource_grants where the subject is me, a role of mine, or a unit in scope — "in scope" being narrowed by the session''s operating unit (#220/#223). User and role subjects ignore the focus: the subject is the person.';
139
+
140
+ -- ── available_in_my_units(type, id) ─────────────────────────────────────
141
+ -- 051's expression with the exclusion ANDed in front. Deny wins: an explicit
142
+ -- exclusion beats an explicit inclusion, which is the precedence every access
143
+ -- system settles on and the only one that is safe to get wrong in one direction.
144
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
145
+ RETURNS boolean
146
+ LANGUAGE sql STABLE SECURITY DEFINER
147
+ SET search_path = ''
148
+ AS $$
149
+ SELECT NOT app.excluded_in_my_units(p_type, p_id)
150
+ AND (NOT EXISTS (
151
+ SELECT 1 FROM app.resource_grants g
152
+ WHERE g.tenant_id = app.current_tenant_id()
153
+ AND g.resource_type = p_type AND g.resource_id = p_id AND g.relation = 'available_in')
154
+ OR app.has_grant(p_type, p_id, ARRAY['available_in']));
155
+ $$;
156
+
157
+ COMMENT ON FUNCTION app.available_in_my_units(text, uuid) IS
158
+ 'The release rule with polarity (#223): an applicable `unavailable_in` tuple hides it; otherwise no `available_in` tuple ⇒ available everywhere, and one ⇒ only where it names. Cost is min(k, n−k) — 1 of 20 and 19 of 20 are both one row.';