@fayz-ai/db 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +6 -5
|
@@ -0,0 +1,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;
|