@fayz-ai/db 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -0,0 +1,118 @@
1
+ -- ============================================================================
2
+ -- 064_agent_guard_authz.sql — agent_guard reads the authz tables (#134, PRD 01 R9).
3
+ --
4
+ -- public.agent_guard(tenant, actor, feature, action, limit_key, used, n) is the
5
+ -- SQL mirror of resolveAccess/resolveLimit that every agent_* write RPC in the
6
+ -- fleet calls first (agenda 005/006/008/014, crm 011/012, financial 009, forms
7
+ -- 003, marketing 007, blog 002). Same signature, same return contract (NULL =
8
+ -- allowed; the AgentDenial jsonb otherwise), same actor-spoof guard as 016,
9
+ -- same plan/limit part as 015. What changes is WHERE the role decision comes
10
+ -- from: the actor's ACTIVE membership in app.memberships and the permission set
11
+ -- of its role in app.role_permissions. There is no owner special case any more:
12
+ -- an owner passes because its role holds the whole catalog.
13
+ --
14
+ -- Key resolution: '{feature}.{action}' passes when the role holds that key,
15
+ -- '{feature}.manage', or the plugin-root '{plugin}.manage' (the first segment
16
+ -- of the feature — the same "the resource's own {plugin}.manage" reading 041
17
+ -- uses for resource_grants). Sub-features such as 'financial.receivables' are
18
+ -- therefore covered by 'financial.manage' until #136 seeds each plugin's own
19
+ -- declaredFeatures keys.
20
+ --
21
+ -- Legacy tenant_role_overrides / role_permissions (public.*) are no longer
22
+ -- consulted: per-tenant customisation is app.roles + app.role_permissions.
23
+ -- ============================================================================
24
+
25
+ CREATE OR REPLACE FUNCTION public.agent_guard(
26
+ p_tenant_id uuid,
27
+ p_actor_user_id uuid,
28
+ p_feature text,
29
+ p_action text,
30
+ p_limit_key text DEFAULT NULL,
31
+ p_used integer DEFAULT NULL,
32
+ p_n integer DEFAULT 1
33
+ ) RETURNS jsonb
34
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
35
+ SET search_path = ''
36
+ AS $$
37
+ DECLARE
38
+ v_role_id uuid;
39
+ v_has boolean;
40
+ v_plan_id text;
41
+ v_ent jsonb;
42
+ v_feature_flag jsonb;
43
+ v_cap numeric;
44
+ BEGIN
45
+ -- ── actor integrity (016): a JWT caller can only act as themselves ───────
46
+ IF auth.uid() IS NOT NULL AND auth.uid() IS DISTINCT FROM p_actor_user_id THEN
47
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
48
+ 'upgradeUrl', '/settings/subscription');
49
+ END IF;
50
+
51
+ -- ── membership + role (app.*) ────────────────────────────────────────────
52
+ SELECT m.role_id INTO v_role_id
53
+ FROM app.memberships m
54
+ WHERE m.tenant_id = p_tenant_id AND m.user_id = p_actor_user_id AND m.active;
55
+ IF v_role_id IS NULL THEN
56
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
57
+ 'upgradeUrl', '/settings/subscription');
58
+ END IF;
59
+
60
+ SELECT EXISTS (
61
+ SELECT 1 FROM app.role_permissions rp
62
+ WHERE rp.tenant_id = p_tenant_id
63
+ AND rp.role_id = v_role_id
64
+ AND rp.permission IN (p_feature || '.' || p_action,
65
+ p_feature || '.manage',
66
+ split_part(p_feature, '.', 1) || '.manage')
67
+ ) INTO v_has;
68
+ IF NOT v_has THEN
69
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
70
+ 'upgradeUrl', '/settings/subscription');
71
+ END IF;
72
+
73
+ -- ── plan feature gate (015, unchanged; nobody bypasses the plan) ─────────
74
+ SELECT t.plan INTO v_plan_id FROM public.tenants t WHERE t.id = p_tenant_id;
75
+ SELECT p.entitlements INTO v_ent FROM public.plans p WHERE p.id = v_plan_id;
76
+
77
+ IF v_ent IS NOT NULL THEN
78
+ v_feature_flag := v_ent->'features'->p_feature;
79
+ IF v_feature_flag IS NOT NULL AND v_feature_flag = to_jsonb(false) THEN
80
+ RETURN jsonb_build_object('allowed', false, 'reason', 'plan',
81
+ 'upgradeUrl', '/settings/subscription');
82
+ END IF;
83
+
84
+ IF p_limit_key IS NOT NULL AND p_used IS NOT NULL THEN
85
+ v_cap := (v_ent->'limits'->>p_limit_key)::numeric;
86
+ IF v_cap IS NOT NULL AND v_cap <> -1 AND (p_used + COALESCE(p_n, 1)) > v_cap THEN
87
+ RETURN jsonb_build_object('allowed', false, 'reason', 'limit',
88
+ 'limit', jsonb_build_object('key', p_limit_key,
89
+ 'max', v_cap,
90
+ 'used', p_used),
91
+ 'upgradeUrl', '/settings/subscription');
92
+ END IF;
93
+ END IF;
94
+ END IF;
95
+
96
+ RETURN NULL;
97
+ END;
98
+ $$;
99
+
100
+ REVOKE ALL ON FUNCTION public.agent_guard(uuid, uuid, text, text, text, integer, integer) FROM public, anon;
101
+ GRANT EXECUTE ON FUNCTION public.agent_guard(uuid, uuid, text, text, text, integer, integer)
102
+ TO authenticated, service_role;
103
+
104
+ -- ── the allowlist on schema app: no trigger function is executable by anon ─
105
+ -- 040 set the default privileges for schema app, but trigger functions created
106
+ -- with CREATE OR REPLACE keep whatever ACL they were born with. A trigger fires
107
+ -- without an EXECUTE check, so nothing needs the grant; anon holds none.
108
+ DO $$
109
+ DECLARE r record;
110
+ BEGIN
111
+ FOR r IN
112
+ SELECT p.oid::regprocedure AS fn
113
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
114
+ WHERE n.nspname = 'app' AND (has_function_privilege('anon', p.oid, 'EXECUTE') OR has_function_privilege('public', p.oid, 'EXECUTE'))
115
+ LOOP
116
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon', r.fn);
117
+ END LOOP;
118
+ END $$;
@@ -0,0 +1,205 @@
1
+ -- ============================================================================
2
+ -- 064_supersede_unit_scope.sql — one authorization model, not two (#242).
3
+ --
4
+ -- `main` shipped a unit model in 039–044: `public.locations` as the unit,
5
+ -- `location_members` as the binding, `public.resource_grants` for sharing, and
6
+ -- one RESTRICTIVE `unit_scope` policy per table on nine tables. It works, it has
7
+ -- screens, and it is in production shape.
8
+ --
9
+ -- It also has no permission dimension. The predicate asks three things — is it
10
+ -- my tenant, is it a unit I reach, is it mine or shared with me — and none of
11
+ -- them is *may I write this*. That is why a franchisee can edit the network
12
+ -- price table: there is nowhere to say otherwise. In the model this chain
13
+ -- brings, `app.has_permission(perm, unit)` resolves through
14
+ -- `effective_role_id(tenant, uid, unit)`, which returns the unit's override role
15
+ -- or the base role — and a brand row (`unit_id IS NULL`) resolves to the BASE
16
+ -- role, so the override cannot reach it. The franchisee edits their own branch
17
+ -- and not the network's.
18
+ --
19
+ -- Backward compatibility is not a requirement here: a cut-over is acceptable.
20
+ -- What is NOT acceptable is losing what main's model already recorded, so this
21
+ -- file moves the data before it retires the mechanism.
22
+ --
23
+ -- WHAT IS KEPT FROM MAIN, ON PURPOSE
24
+ --
25
+ -- · `tenants.unit_scoping_enabled` — the per-account switch, honoured by
26
+ -- `app.unit_scoping_on()` in 051. Rolling out one account at a time is
27
+ -- better than the always-on this chain originally had.
28
+ -- · `public.locations` / `location_members` keep working: 063 already turned
29
+ -- them into write-through projections of `app.units` / `app.membership_units`,
30
+ -- so the units UI (10 components, all of them through one `data.ts`) and the
31
+ -- read models of 044/045/046 keep answering.
32
+ -- · `teams` and `team_members` stay as data. This model has no team subject,
33
+ -- so team grants are expanded into per-user grants below rather than lost.
34
+ --
35
+ -- WHAT CHANGES MEANING, AND IT IS DELIBERATE
36
+ --
37
+ -- `location_members.role` ADDS to the org role in main's model ("unions with
38
+ -- the org role, never subtracts"). Here it becomes an OVERRIDE for that unit.
39
+ -- That is the change that makes the franchisee case expressible at all — an
40
+ -- additive role can never take `catalog.edit` away from a brand row. Recorded
41
+ -- in DECISIONS.md.
42
+ --
43
+ -- Replay-safe: every step guarded on the object still being there.
44
+ -- ============================================================================
45
+
46
+ -- ── 1. the tree main grew, carried into app.units ───────────────────────
47
+ -- 063 built app.units from locations before `parent_id` and `code` existed.
48
+ -- Only ever ADOPTS a parent; never clears one. On a replay `locations` is already
49
+ -- the projection of app.units, so a symmetric "make them equal" would read back a
50
+ -- NULL the projection had not carried yet and flatten the real tree — which is
51
+ -- what it did, and the fan-out then took B1 and B2 away from the F1 manager.
52
+ UPDATE app.units u
53
+ SET parent_unit_id = coalesce(u.parent_unit_id, l.parent_id),
54
+ metadata = u.metadata || jsonb_strip_nulls(jsonb_build_object('code', l.code))
55
+ FROM public.locations l
56
+ WHERE l.id = u.id
57
+ AND ((l.parent_id IS NOT NULL AND u.parent_unit_id IS NULL)
58
+ OR (l.code IS NOT NULL AND u.metadata ->> 'code' IS DISTINCT FROM l.code));
59
+
60
+ -- a region is a unit that has children; main's `kind` vocabulary was
61
+ -- region|branch, this chain's is hq|branch, and the hq is the tenant's root
62
+ UPDATE app.units u SET kind = 'branch'
63
+ WHERE u.kind NOT IN ('hq', 'branch');
64
+
65
+ -- ── 2. the per-unit role, as an override ────────────────────────────────
66
+ DO $$
67
+ DECLARE v_n integer := 0;
68
+ BEGIN
69
+ IF to_regclass('public.location_members') IS NULL THEN RETURN; END IF;
70
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
71
+ WHERE table_schema = 'public' AND table_name = 'location_members' AND column_name = 'role') THEN
72
+ RETURN;
73
+ END IF;
74
+
75
+ UPDATE app.membership_units mu
76
+ SET role_override_id = r.id
77
+ FROM public.location_members lm
78
+ JOIN app.roles r ON r.key = lm.role
79
+ WHERE mu.unit_id = lm.location_id
80
+ AND mu.user_id = lm.user_id
81
+ AND r.tenant_id = mu.tenant_id
82
+ AND lm.role IS NOT NULL
83
+ AND mu.role_override_id IS DISTINCT FROM r.id;
84
+ GET DIAGNOSTICS v_n = ROW_COUNT;
85
+ RAISE NOTICE '064: % per-unit role(s) carried as overrides', v_n;
86
+ END $$;
87
+
88
+ -- ── 3. the grants main recorded ─────────────────────────────────────────
89
+ -- main: (resource_table, resource_id, grantee_type user|team|unit, access, reason)
90
+ -- here: (resource_type, resource_id, relation, subject_type user|unit|role)
91
+ --
92
+ -- `reason` is what separates the two relations: 'catalog' means "this item is
93
+ -- sold in these units" (a release), anything else means "somebody clicked
94
+ -- share". A team grant has no subject here, so it is expanded to its members —
95
+ -- losing the grouping is better than losing the access.
96
+ DO $$
97
+ DECLARE v_n integer := 0;
98
+ BEGIN
99
+ IF to_regclass('public.resource_grants') IS NULL THEN RETURN; END IF;
100
+
101
+ INSERT INTO app.resource_grants (tenant_id, resource_type, resource_id, relation, subject_type, subject_id, created_by)
102
+ SELECT g.tenant_id,
103
+ -- main keys grants by TABLE; this model keys them by resource type
104
+ coalesce(sr.resource_type, 'catalog.product'),
105
+ g.resource_id,
106
+ CASE WHEN g.reason = 'catalog' THEN 'available_in' ELSE 'shared_with' END,
107
+ g.grantee_type,
108
+ g.grantee_id,
109
+ g.granted_by
110
+ FROM public.resource_grants g
111
+ LEFT JOIN LATERAL (
112
+ SELECT r.resource_type FROM app.scaffold_registry r
113
+ WHERE r.table_schema = 'public' AND r.table_name = g.resource_table
114
+ LIMIT 1) sr ON true
115
+ WHERE g.grantee_type IN ('user', 'unit')
116
+ ON CONFLICT DO NOTHING;
117
+ GET DIAGNOSTICS v_n = ROW_COUNT;
118
+
119
+ -- team → its members, one grant each
120
+ IF to_regclass('public.team_members') IS NOT NULL THEN
121
+ INSERT INTO app.resource_grants (tenant_id, resource_type, resource_id, relation, subject_type, subject_id, created_by)
122
+ SELECT g.tenant_id, coalesce(sr.resource_type, 'catalog.product'), g.resource_id,
123
+ CASE WHEN g.reason = 'catalog' THEN 'available_in' ELSE 'shared_with' END,
124
+ 'user', tm.user_id, g.granted_by
125
+ FROM public.resource_grants g
126
+ JOIN public.team_members tm ON tm.team_id = g.grantee_id
127
+ LEFT JOIN LATERAL (
128
+ SELECT r.resource_type FROM app.scaffold_registry r
129
+ WHERE r.table_schema = 'public' AND r.table_name = g.resource_table
130
+ LIMIT 1) sr ON true
131
+ WHERE g.grantee_type = 'team'
132
+ ON CONFLICT DO NOTHING;
133
+ END IF;
134
+ RAISE NOTICE '064: % grant(s) carried', v_n;
135
+ END $$;
136
+
137
+ -- ── 4. retire the mechanism, now that the data moved ────────────────────
138
+ -- The nine RESTRICTIVE policies must go BEFORE the scaffold sweep (065) starts
139
+ -- putting this chain's template on the same tables: two restrictive predicates
140
+ -- AND together, and main's asks a question this model answers differently, so
141
+ -- leaving both would hide rows for reasons nobody could read off either one.
142
+ DO $$
143
+ DECLARE r record; v_n integer := 0;
144
+ BEGIN
145
+ FOR r IN SELECT schemaname, tablename FROM pg_policies
146
+ WHERE schemaname = 'public' AND policyname = 'unit_scope' LOOP
147
+ EXECUTE format('DROP POLICY IF EXISTS unit_scope ON %I.%I', r.schemaname, r.tablename);
148
+ v_n := v_n + 1;
149
+ END LOOP;
150
+ RAISE NOTICE '064: % unit_scope policy(ies) retired — the scaffold template takes over in 065', v_n;
151
+ END $$;
152
+
153
+ COMMENT ON TABLE public.resource_grants IS
154
+ 'SUPERSEDED by app.resource_grants (#242). Rows were carried across in 064; this table is left in place so main''s screens keep reading while they move. It no longer governs any policy.';
155
+
156
+ -- The switch survives and now drives this chain's predicate too.
157
+ COMMENT ON COLUMN public.tenants.unit_scoping_enabled IS
158
+ 'Per-account switch for unit scoping, from main 039 and kept (#242). Read by app.unit_scoping_on(): with it off, every unit of the account is reachable and the plans are the ones from before units existed.';
159
+
160
+ -- ── 5. main's write-stamp keeps working, against this model's constraints ──
161
+ -- `041_scoped_columns` put a BEFORE INSERT trigger on nine tables that fills
162
+ -- `unit_id` from the request header and `owner_id` from the actor. That is a good
163
+ -- feature and this chain does not have it: it is what makes "the appointment is
164
+ -- born in Ipanema without reception typing anything" true.
165
+ --
166
+ -- It does collide with one thing. The scaffold's `owner_id` carries a composite
167
+ -- FK to `app.memberships(tenant_id, user_id)` — an owner who is not a member of
168
+ -- the row's tenant is unrepresentable, which is the point. main's stamp writes
169
+ -- `auth.uid()` unconditionally, so a caller acting on a tenant they do not belong
170
+ -- to used to get a NULL owner and now gets a 23503.
171
+ --
172
+ -- The trigger is re-emitted with the membership test the FK expects. Same
173
+ -- behaviour for every legitimate write; the illegitimate one goes back to being
174
+ -- refused by the policy (42501) instead of by a constraint (23503), which is the
175
+ -- error the caller can act on.
176
+ DO $$
177
+ DECLARE r record; v_n integer := 0;
178
+ BEGIN
179
+ FOR r IN SELECT p.proname, replace(p.proname, 'stamp_scope_', '') AS tbl
180
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
181
+ WHERE n.nspname = 'public' AND p.proname LIKE 'stamp\_scope\_%' LOOP
182
+ CONTINUE WHEN to_regclass('public.' || r.tbl) IS NULL;
183
+ EXECUTE format($f$
184
+ CREATE OR REPLACE FUNCTION public.%I() RETURNS trigger
185
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'pg_temp'
186
+ AS $body$
187
+ BEGIN
188
+ IF NEW.unit_id IS NULL THEN
189
+ NEW.unit_id := public.requested_unit();
190
+ END IF;
191
+ -- only an actual member can own a row here: the composite FK says so
192
+ IF NEW.owner_id IS NULL AND EXISTS (
193
+ SELECT 1 FROM app.memberships m
194
+ WHERE m.tenant_id = NEW.tenant_id AND m.user_id = auth.uid() AND m.active) THEN
195
+ NEW.owner_id := auth.uid();
196
+ END IF;
197
+ RETURN NEW;
198
+ EXCEPTION WHEN OTHERS THEN
199
+ RETURN NEW;
200
+ END $body$;
201
+ $f$, r.proname);
202
+ v_n := v_n + 1;
203
+ END LOOP;
204
+ RAISE NOTICE '064b: % write-stamp trigger(s) re-emitted with the membership test', v_n;
205
+ END $$;
@@ -0,0 +1,139 @@
1
+ -- ============================================================================
2
+ -- 065_scaffold_sweep.sql — the remaining core-A public tables under the ONE
3
+ -- RLS template (043 app.scaffold_table), in SHADOW mode (PRD 01 R6 / #135).
4
+ --
5
+ -- Shadow: the table's existing canonical policies stay; the four
6
+ -- <t>_authz_* template policies are ADDED (permissive policies OR together, so
7
+ -- nothing is denied that was allowed) and app.shadow_report() (056) counts,
8
+ -- per member, what the old predicate shows vs what the new one would — the
9
+ -- flip to 'enforce' happens per table, on evidence, with
10
+ -- app.scaffold_set_mode(table, 'enforce').
11
+ --
12
+ -- Which tables (owned by no other Phase-0 slice):
13
+ -- public.appointments agenda.appointment plugin agenda owner scoping on
14
+ -- public.appointment_items agenda.appointment_item plugin agenda (gets tenant_id, backfilled from the appointment)
15
+ -- public.schedules agenda.schedule plugin agenda
16
+ -- public.transactions financial.transaction plugin financial (legacy generic archetype)
17
+ -- Not swept, on purpose:
18
+ -- public.addresses — its owner_id is a POLYMORPHIC domain column (person /
19
+ -- location / tenant, 017) and the template reads owner_id as "member who
20
+ -- owns the row"; scaffolding would add an FK to app.memberships that the
21
+ -- data violates and change the meaning of the column. Phase 1: rename to
22
+ -- holder_id, then scaffold under people. Documented in 01a.
23
+ -- people / categories / products / services / orders / order_items — the
24
+ -- people, catalog and O2C slices own them (enforce).
25
+ --
26
+ -- Backfills are guarded: unit_id ← location_id only where that id is an
27
+ -- app.units id of the same tenant (the tenancy slice decides whether locations
28
+ -- keep their ids as units; nothing here assumes it).
29
+ -- ============================================================================
30
+
31
+ -- A pre-existing unit_id (plugin tables carried one pointing at public.locations)
32
+ -- that is not an app.units id of the same tenant cannot take the composite FK
33
+ -- the scaffold adds. Rather than fail the migration or lose the value, the
34
+ -- retrofit files park it here and null the column; the tenancy slice / Phase 1
35
+ -- re-link once locations → units ids are settled.
36
+ CREATE TABLE IF NOT EXISTS app.legacy_unit_quarantine (
37
+ table_name text NOT NULL,
38
+ row_id uuid NOT NULL,
39
+ tenant_id uuid,
40
+ legacy_unit_id uuid NOT NULL,
41
+ quarantined_at timestamptz NOT NULL DEFAULT now(),
42
+ PRIMARY KEY (table_name, row_id)
43
+ );
44
+ ALTER TABLE app.legacy_unit_quarantine ENABLE ROW LEVEL SECURITY;
45
+ GRANT ALL ON app.legacy_unit_quarantine TO service_role;
46
+ COMMENT ON TABLE app.legacy_unit_quarantine IS
47
+ 'unit_id values that predate app.units and did not resolve to a unit of the row''s tenant when the table was scaffolded (#135). Re-link in Phase 1, then delete the row here.';
48
+
49
+ -- Park + null a legacy unit_id that does not resolve; returns rows quarantined.
50
+ CREATE OR REPLACE FUNCTION app.quarantine_legacy_unit_ids(p_table regclass)
51
+ RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
52
+ DECLARE v_schema text; v_name text; v_n integer := 0;
53
+ BEGIN
54
+ SELECT n.nspname, c.relname INTO v_schema, v_name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
55
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'unit_id')
56
+ OR NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'tenant_id')
57
+ OR to_regclass('app.units') IS NULL THEN
58
+ RETURN 0;
59
+ END IF;
60
+ EXECUTE format(
61
+ 'WITH bad AS (SELECT t.id, t.tenant_id, t.unit_id FROM %1$I.%2$I t WHERE t.unit_id IS NOT NULL
62
+ AND NOT EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = t.tenant_id AND u.id = t.unit_id)),
63
+ park AS (INSERT INTO app.legacy_unit_quarantine (table_name, row_id, tenant_id, legacy_unit_id)
64
+ SELECT %3$L, id, tenant_id, unit_id FROM bad ON CONFLICT DO NOTHING RETURNING 1),
65
+ fix AS (UPDATE %1$I.%2$I t SET unit_id = NULL WHERE t.id IN (SELECT id FROM bad) RETURNING 1)
66
+ SELECT count(*) FROM fix', v_schema, v_name, v_name) INTO v_n;
67
+ RETURN v_n;
68
+ END $$;
69
+ REVOKE ALL ON FUNCTION app.quarantine_legacy_unit_ids(regclass) FROM public, anon, authenticated;
70
+
71
+ DO $$
72
+ DECLARE r record;
73
+ BEGIN
74
+ -- ── appointments
75
+ IF to_regclass('public.appointments') IS NOT NULL THEN
76
+ PERFORM app.scaffold_table('public.appointments', 'agenda.appointment', 'agenda', true, 'shadow');
77
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'appointments' AND column_name = 'location_id')
78
+ AND to_regclass('app.units') IS NOT NULL THEN
79
+ UPDATE public.appointments a SET unit_id = a.location_id
80
+ WHERE a.unit_id IS NULL AND a.location_id IS NOT NULL
81
+ AND EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = a.tenant_id AND u.id = a.location_id);
82
+ END IF;
83
+ END IF;
84
+
85
+ -- ── appointment_items (child: tenant_id derived from the appointment)
86
+ IF to_regclass('public.appointment_items') IS NOT NULL THEN
87
+ PERFORM app.scaffold_table('public.appointment_items', 'agenda.appointment_item', 'agenda', false, 'shadow');
88
+ UPDATE public.appointment_items ai SET tenant_id = a.tenant_id
89
+ FROM public.appointments a
90
+ WHERE a.id = ai.booking_id AND ai.tenant_id IS NULL;
91
+ UPDATE public.appointment_items ai SET unit_id = a.unit_id
92
+ FROM public.appointments a
93
+ WHERE a.id = ai.booking_id AND ai.unit_id IS NULL AND a.unit_id IS NOT NULL;
94
+ -- tighten once every row has a tenant (fresh chains: immediately)
95
+ IF NOT EXISTS (SELECT 1 FROM public.appointment_items WHERE tenant_id IS NULL) THEN
96
+ EXECUTE 'ALTER TABLE public.appointment_items ALTER COLUMN tenant_id SET NOT NULL';
97
+ END IF;
98
+ -- keep the child in step with its parent's tenant/unit on insert
99
+ CREATE OR REPLACE FUNCTION public.trg_appointment_items_inherit_scope()
100
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $fn$
101
+ BEGIN
102
+ IF NEW.tenant_id IS NULL OR NEW.unit_id IS NULL THEN
103
+ SELECT coalesce(NEW.tenant_id, a.tenant_id), coalesce(NEW.unit_id, a.unit_id) INTO NEW.tenant_id, NEW.unit_id
104
+ FROM public.appointments a WHERE a.id = NEW.booking_id;
105
+ END IF;
106
+ RETURN NEW;
107
+ END $fn$;
108
+ EXECUTE 'DROP TRIGGER IF EXISTS appointment_items_inherit_scope ON public.appointment_items';
109
+ EXECUTE 'CREATE TRIGGER appointment_items_inherit_scope BEFORE INSERT ON public.appointment_items FOR EACH ROW EXECUTE FUNCTION public.trg_appointment_items_inherit_scope()';
110
+ END IF;
111
+
112
+ -- ── schedules
113
+ IF to_regclass('public.schedules') IS NOT NULL THEN
114
+ PERFORM app.scaffold_table('public.schedules', 'agenda.schedule', 'agenda', false, 'shadow');
115
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'schedules' AND column_name = 'location_id')
116
+ AND to_regclass('app.units') IS NOT NULL THEN
117
+ UPDATE public.schedules s SET unit_id = s.location_id
118
+ WHERE s.unit_id IS NULL AND s.location_id IS NOT NULL
119
+ AND EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = s.tenant_id AND u.id = s.location_id);
120
+ END IF;
121
+ END IF;
122
+
123
+ -- ── transactions (legacy generic archetype; O2C's ledger supersedes it)
124
+ IF to_regclass('public.transactions') IS NOT NULL THEN
125
+ PERFORM app.scaffold_table('public.transactions', 'financial.transaction', 'financial', false, 'shadow');
126
+ END IF;
127
+ END $$;
128
+
129
+ -- The API roles keep what they had; the template narrows through RLS.
130
+ DO $$
131
+ DECLARE t text;
132
+ BEGIN
133
+ FOREACH t IN ARRAY ARRAY['appointments', 'appointment_items', 'schedules', 'transactions'] LOOP
134
+ IF to_regclass('public.' || t) IS NOT NULL THEN
135
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
136
+ EXECUTE format('GRANT ALL ON public.%I TO service_role', t);
137
+ END IF;
138
+ END LOOP;
139
+ END $$;
@@ -0,0 +1,122 @@
1
+ -- ============================================================================
2
+ -- 066_shadow_report.sql — the shadow-mode report and the flip to enforce (#135).
3
+ --
4
+ -- app.shadow_report([tenant]) — for every table in app.scaffold_registry with
5
+ -- mode = 'shadow' and every active member of the tenant(s), how many rows the
6
+ -- OLD SELECT policies show (the permissive non-template policies still on the
7
+ -- table, OR-ed, exactly as Postgres combines them) vs how many the NEW template
8
+ -- policy (<t>_authz_select) would show, and the difference both ways:
9
+ -- unexpected_denials rows the old predicate shows and the new one hides
10
+ -- (what the flip would take away from that member)
11
+ -- newly_visible rows the new predicate shows and the old one hides
12
+ -- Both predicates are the policies' own text (pg_policies.qual), evaluated as
13
+ -- WHERE clauses with the member's JWT claims set transaction-locally
14
+ -- (request.jwt.claims / request.jwt.claim.sub, the same knobs PostgREST turns)
15
+ -- — no role switch: the function is SECURITY DEFINER (bypasses RLS) and the
16
+ -- helpers read auth.uid()/claims from those settings. The caller's own claims
17
+ -- are saved and restored.
18
+ --
19
+ -- Semantics on the synthetic fixture: only owners are mirrored in
20
+ -- public.tenant_members until the tenancy slice retires it, so the OLD
21
+ -- predicate (user_tenant_ids()) shows rows only to owners; a member without the
22
+ -- plugin's `.read` (reception on transactions) shows denials only once she is a
23
+ -- tenant_members row — the suite creates that condition and asserts the count.
24
+ --
25
+ -- app.scaffold_set_mode(table, 'enforce'|'shadow') — re-applies the registered
26
+ -- scaffold in the new mode (enforce drops the pre-existing policies and records
27
+ -- them in the registry; shadow re-adds nothing that was dropped).
28
+ -- ============================================================================
29
+
30
+ CREATE OR REPLACE FUNCTION app.shadow_report(p_tenant uuid DEFAULT NULL)
31
+ RETURNS TABLE (
32
+ tenant_id uuid,
33
+ table_name text,
34
+ member_user_id uuid,
35
+ old_visible integer,
36
+ new_visible integer,
37
+ unexpected_denials integer,
38
+ newly_visible integer
39
+ )
40
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
41
+ AS $$
42
+ DECLARE
43
+ v_prev_claims text := current_setting('request.jwt.claims', true);
44
+ v_prev_sub text := current_setting('request.jwt.claim.sub', true);
45
+ v_prev_role text := current_setting('request.jwt.claim.role', true);
46
+ t record;
47
+ m record;
48
+ v_old text;
49
+ v_new text;
50
+ v_o integer; v_n integer; v_d integer; v_w integer;
51
+ BEGIN
52
+ FOR t IN
53
+ SELECT r.table_schema, r.table_name FROM app.scaffold_registry r WHERE r.mode = 'shadow' ORDER BY 1, 2
54
+ LOOP
55
+ IF to_regclass(format('%I.%I', t.table_schema, t.table_name)) IS NULL THEN CONTINUE; END IF;
56
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns c WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name AND c.column_name = 'tenant_id') THEN CONTINUE; END IF;
57
+
58
+ SELECT coalesce(string_agg('(' || p.qual || ')', ' OR '), 'false') INTO v_old
59
+ FROM pg_policies p
60
+ WHERE p.schemaname = t.table_schema AND p.tablename = t.table_name
61
+ AND p.cmd IN ('SELECT', 'ALL') AND p.permissive = 'PERMISSIVE' AND p.qual IS NOT NULL
62
+ AND p.policyname <> ALL (ARRAY[t.table_name || '_authz_select', t.table_name || '_authz_insert', t.table_name || '_authz_update', t.table_name || '_authz_delete']);
63
+ SELECT p.qual INTO v_new FROM pg_policies p
64
+ WHERE p.schemaname = t.table_schema AND p.tablename = t.table_name AND p.policyname = t.table_name || '_authz_select';
65
+ IF v_new IS NULL THEN CONTINUE; END IF;
66
+
67
+ FOR m IN
68
+ SELECT ms.tenant_id, ms.user_id FROM app.memberships ms
69
+ WHERE ms.active AND (p_tenant IS NULL OR ms.tenant_id = p_tenant)
70
+ ORDER BY 1, 2
71
+ LOOP
72
+ PERFORM set_config('request.jwt.claims', jsonb_build_object('sub', m.user_id::text, 'role', 'authenticated', 'aud', 'authenticated', 'tenant_id', m.tenant_id::text)::text, true);
73
+ PERFORM set_config('request.jwt.claim.sub', m.user_id::text, true);
74
+ PERFORM set_config('request.jwt.claim.role', 'authenticated', true);
75
+ EXECUTE format(
76
+ 'SELECT count(*) FILTER (WHERE coalesce((%1$s), false)),
77
+ count(*) FILTER (WHERE coalesce((%2$s), false)),
78
+ count(*) FILTER (WHERE coalesce((%1$s), false) AND NOT coalesce((%2$s), false)),
79
+ count(*) FILTER (WHERE coalesce((%2$s), false) AND NOT coalesce((%1$s), false))
80
+ FROM %3$I.%4$I WHERE tenant_id = $1',
81
+ v_old, v_new, t.table_schema, t.table_name)
82
+ INTO v_o, v_n, v_d, v_w USING m.tenant_id;
83
+ tenant_id := m.tenant_id; table_name := t.table_name; member_user_id := m.user_id;
84
+ old_visible := v_o; new_visible := v_n; unexpected_denials := v_d; newly_visible := v_w;
85
+ RETURN NEXT;
86
+ END LOOP;
87
+ END LOOP;
88
+ PERFORM set_config('request.jwt.claims', coalesce(v_prev_claims, ''), true);
89
+ PERFORM set_config('request.jwt.claim.sub', coalesce(v_prev_sub, ''), true);
90
+ PERFORM set_config('request.jwt.claim.role', coalesce(v_prev_role, ''), true);
91
+ END $$;
92
+ REVOKE ALL ON FUNCTION app.shadow_report(uuid) FROM public, anon, authenticated;
93
+ GRANT EXECUTE ON FUNCTION app.shadow_report(uuid) TO service_role;
94
+
95
+ -- One row per shadow table: the roll-up an operator reads before flipping.
96
+ CREATE OR REPLACE FUNCTION app.shadow_report_summary(p_tenant uuid DEFAULT NULL)
97
+ RETURNS TABLE (table_name text, members integer, members_denied integer, unexpected_denials bigint, newly_visible bigint)
98
+ LANGUAGE sql SECURITY DEFINER SET search_path = ''
99
+ AS $$
100
+ SELECT r.table_name, count(*)::int, count(*) FILTER (WHERE r.unexpected_denials > 0)::int, sum(r.unexpected_denials), sum(r.newly_visible)
101
+ FROM app.shadow_report(p_tenant) r
102
+ GROUP BY r.table_name ORDER BY 1;
103
+ $$;
104
+ REVOKE ALL ON FUNCTION app.shadow_report_summary(uuid) FROM public, anon, authenticated;
105
+ GRANT EXECUTE ON FUNCTION app.shadow_report_summary(uuid) TO service_role;
106
+
107
+ -- The flip: same table, same resource/plugin/owner scoping, new mode.
108
+ CREATE OR REPLACE FUNCTION app.scaffold_set_mode(p_table regclass, p_mode text)
109
+ RETURNS void
110
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
111
+ AS $$
112
+ DECLARE r app.scaffold_registry%ROWTYPE; v_schema text; v_name text;
113
+ BEGIN
114
+ SELECT n.nspname, c.relname INTO v_schema, v_name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
115
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
116
+ IF NOT FOUND THEN
117
+ RAISE EXCEPTION 'scaffold_set_mode: %.% is not registered — call app.scaffold_table first', v_schema, v_name;
118
+ END IF;
119
+ PERFORM app.scaffold_table(p_table, r.resource_type, r.plugin, r.owner_scoping, p_mode);
120
+ END $$;
121
+ REVOKE ALL ON FUNCTION app.scaffold_set_mode(regclass, text) FROM public, anon, authenticated;
122
+ GRANT EXECUTE ON FUNCTION app.scaffold_set_mode(regclass, text) TO service_role;