@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,293 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 099_access_grants.sql — Delegated access grants: a signed grant becomes a
|
|
3
|
+
-- scoped session (PRD 07 R6 / #144; RN-PLT-03; US-PUB-DEV-04).
|
|
4
|
+
--
|
|
5
|
+
-- Someone with config.manage delegates a slice of her tenant — one unit, one
|
|
6
|
+
-- role, for a bounded time — to whoever holds a secret. The holder redeems the
|
|
7
|
+
-- secret and receives a scoped session. THE QR CODE IS ONE DELIVERY CHANNEL FOR
|
|
8
|
+
-- THAT SECRET, not the concept: the same grant works as a link in a message, a
|
|
9
|
+
-- totem at the door, or a code read out loud. V1 called the tables
|
|
10
|
+
-- qr_access_configs / qr_access_grants / qr_access_sessions and V2 mirrored the
|
|
11
|
+
-- name at first; nothing in the schema was ever QR-specific, so the delivery
|
|
12
|
+
-- method is now a column (`channel`) and the objects carry the concept's name.
|
|
13
|
+
--
|
|
14
|
+
-- grant access_grants: (tenant, unit, role, label, channel, expiry,
|
|
15
|
+
-- max_uses, session_ttl) + secret_hash. The delivery payload carries
|
|
16
|
+
-- {grant_id, signature}; the signature is the grant's secret, shown
|
|
17
|
+
-- ONCE at creation (access_grant_create) and stored only as sha256 —
|
|
18
|
+
-- a read of the table cannot forge a grant. Verification is a hash
|
|
19
|
+
-- compare.
|
|
20
|
+
-- redeem access_grant_redeem(grant_id, signature) — anon-callable (the phone
|
|
21
|
+
-- has no session yet): grant active, not expired, not exhausted,
|
|
22
|
+
-- signature right, burst counter under the threshold → uses+1, a
|
|
23
|
+
-- session row (token_hash, tenant, unit, role, channel, expires_at =
|
|
24
|
+
-- now()+session_ttl) and the opaque session_token returned once.
|
|
25
|
+
-- Every refusal is 42501 with one message: the caller learns nothing
|
|
26
|
+
-- about which check failed.
|
|
27
|
+
-- claims access_session_claims(session_token) — service-only (the edge
|
|
28
|
+
-- function that mints the JWT): single use (used_at), expired/used/
|
|
29
|
+
-- revoked → 42501. What it returns is what goes into the token:
|
|
30
|
+
-- tenant_id, unit_id, role_key, expires_at.
|
|
31
|
+
-- revoke access_session_revoke(session_id) — config.manage in the tenant.
|
|
32
|
+
--
|
|
33
|
+
-- Rate limit and anti-abuse live at the edge (IP/device throttling before the
|
|
34
|
+
-- RPC is reached). The database backstop is access_grant_attempts: a per-grant
|
|
35
|
+
-- sliding-minute counter that refuses more than N redemptions of one grant per
|
|
36
|
+
-- minute (a leaked grant cannot be mass-redeemed). Note the limit of a database
|
|
37
|
+
-- counter: a refused call rolls back its own increment, so only redemptions
|
|
38
|
+
-- that commit are counted — brute force of a 244-bit secret is not the threat,
|
|
39
|
+
-- replay of a real grant is, and that is what max_uses, expiry and the burst
|
|
40
|
+
-- counter bound.
|
|
41
|
+
--
|
|
42
|
+
-- Idempotent and replay-safe.
|
|
43
|
+
-- ============================================================================
|
|
44
|
+
|
|
45
|
+
-- ── grants ──────────────────────────────────────────────────────────────
|
|
46
|
+
CREATE TABLE IF NOT EXISTS public.access_grants (
|
|
47
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
48
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
49
|
+
unit_id uuid NOT NULL,
|
|
50
|
+
role_id uuid NOT NULL,
|
|
51
|
+
label text NOT NULL CHECK (btrim(label) <> ''),
|
|
52
|
+
channel text NOT NULL DEFAULT 'qr' CHECK (channel IN ('qr', 'link', 'totem', 'manual')),
|
|
53
|
+
secret_hash text NOT NULL, -- sha256 hex of the secret the delivery payload carries
|
|
54
|
+
expires_at timestamptz NOT NULL,
|
|
55
|
+
max_uses integer CHECK (max_uses IS NULL OR max_uses > 0),
|
|
56
|
+
uses integer NOT NULL DEFAULT 0,
|
|
57
|
+
session_ttl interval NOT NULL DEFAULT interval '12 hours',
|
|
58
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
59
|
+
created_by uuid,
|
|
60
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
61
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
62
|
+
CONSTRAINT access_grants_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE CASCADE,
|
|
63
|
+
CONSTRAINT access_grants_role_fk FOREIGN KEY (tenant_id, role_id) REFERENCES app.roles(tenant_id, id) ON DELETE CASCADE
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS access_grants_active ON public.access_grants (tenant_id, unit_id) WHERE is_active;
|
|
66
|
+
|
|
67
|
+
SELECT app.scaffold_table('public.access_grants', 'config.access_grant', 'config', false, 'enforce');
|
|
68
|
+
REVOKE ALL ON public.access_grants FROM anon;
|
|
69
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.access_grants TO authenticated;
|
|
70
|
+
GRANT ALL ON public.access_grants TO service_role;
|
|
71
|
+
|
|
72
|
+
CREATE OR REPLACE FUNCTION public.access_grants_stamp_actor()
|
|
73
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
74
|
+
BEGIN
|
|
75
|
+
IF TG_OP = 'INSERT' THEN NEW.created_by := coalesce(auth.uid(), NEW.created_by); END IF;
|
|
76
|
+
RETURN NEW;
|
|
77
|
+
END $$;
|
|
78
|
+
REVOKE ALL ON FUNCTION public.access_grants_stamp_actor() FROM public, anon, authenticated;
|
|
79
|
+
DROP TRIGGER IF EXISTS access_grants_actor ON public.access_grants;
|
|
80
|
+
CREATE TRIGGER access_grants_actor BEFORE INSERT ON public.access_grants
|
|
81
|
+
FOR EACH ROW EXECUTE FUNCTION public.access_grants_stamp_actor();
|
|
82
|
+
|
|
83
|
+
-- ── sessions (no API role reads or writes this table) ───────────────────
|
|
84
|
+
CREATE TABLE IF NOT EXISTS public.access_sessions (
|
|
85
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
86
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
87
|
+
unit_id uuid NOT NULL,
|
|
88
|
+
role_id uuid NOT NULL,
|
|
89
|
+
grant_id uuid NOT NULL REFERENCES public.access_grants(id) ON DELETE CASCADE,
|
|
90
|
+
channel text NOT NULL DEFAULT 'qr' CHECK (channel IN ('qr', 'link', 'totem', 'manual')),
|
|
91
|
+
token_hash text NOT NULL UNIQUE, -- sha256 hex of the opaque session token
|
|
92
|
+
expires_at timestamptz NOT NULL,
|
|
93
|
+
used_at timestamptz, -- claims consumed (single use)
|
|
94
|
+
revoked_at timestamptz,
|
|
95
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS access_sessions_grant ON public.access_sessions (grant_id, created_at DESC);
|
|
98
|
+
ALTER TABLE public.access_sessions ENABLE ROW LEVEL SECURITY;
|
|
99
|
+
REVOKE ALL ON public.access_sessions FROM anon, authenticated;
|
|
100
|
+
GRANT ALL ON public.access_sessions TO service_role;
|
|
101
|
+
DROP POLICY IF EXISTS tenant_isolation_select ON public.access_sessions;
|
|
102
|
+
DROP POLICY IF EXISTS tenant_isolation_insert ON public.access_sessions;
|
|
103
|
+
DROP POLICY IF EXISTS tenant_isolation_update ON public.access_sessions;
|
|
104
|
+
DROP POLICY IF EXISTS tenant_isolation_delete ON public.access_sessions;
|
|
105
|
+
|
|
106
|
+
-- ── burst counter (internal) ────────────────────────────────────────────
|
|
107
|
+
CREATE TABLE IF NOT EXISTS public.access_grant_attempts (
|
|
108
|
+
grant_id uuid NOT NULL REFERENCES public.access_grants(id) ON DELETE CASCADE,
|
|
109
|
+
minute timestamptz NOT NULL,
|
|
110
|
+
count integer NOT NULL DEFAULT 0,
|
|
111
|
+
PRIMARY KEY (grant_id, minute)
|
|
112
|
+
);
|
|
113
|
+
ALTER TABLE public.access_grant_attempts ENABLE ROW LEVEL SECURITY;
|
|
114
|
+
REVOKE ALL ON public.access_grant_attempts FROM anon, authenticated;
|
|
115
|
+
GRANT ALL ON public.access_grant_attempts TO service_role;
|
|
116
|
+
|
|
117
|
+
-- ── helpers ─────────────────────────────────────────────────────────────
|
|
118
|
+
CREATE OR REPLACE FUNCTION app.sha256_hex(p_text text)
|
|
119
|
+
RETURNS text LANGUAGE sql IMMUTABLE SET search_path = '' AS $$
|
|
120
|
+
SELECT encode(sha256(convert_to(p_text, 'UTF8')), 'hex');
|
|
121
|
+
$$;
|
|
122
|
+
GRANT EXECUTE ON FUNCTION app.sha256_hex(text) TO authenticated, service_role;
|
|
123
|
+
|
|
124
|
+
-- 244 bits from two v4 uuids — core Postgres, no extension schema to guess.
|
|
125
|
+
CREATE OR REPLACE FUNCTION app.opaque_token()
|
|
126
|
+
RETURNS text LANGUAGE sql VOLATILE SET search_path = '' AS $$
|
|
127
|
+
SELECT replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
|
|
128
|
+
$$;
|
|
129
|
+
REVOKE ALL ON FUNCTION app.opaque_token() FROM public, anon, authenticated;
|
|
130
|
+
|
|
131
|
+
-- ── create a grant: the secret is returned once ─────────────────────────
|
|
132
|
+
CREATE OR REPLACE FUNCTION public.access_grant_create(
|
|
133
|
+
p_unit uuid,
|
|
134
|
+
p_role_key text,
|
|
135
|
+
p_label text,
|
|
136
|
+
p_expires_at timestamptz DEFAULT NULL,
|
|
137
|
+
p_max_uses integer DEFAULT NULL,
|
|
138
|
+
p_session_ttl interval DEFAULT NULL,
|
|
139
|
+
p_channel text DEFAULT 'qr'
|
|
140
|
+
)
|
|
141
|
+
RETURNS jsonb
|
|
142
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
143
|
+
AS $$
|
|
144
|
+
DECLARE
|
|
145
|
+
v_tenant uuid := app.current_tenant_id();
|
|
146
|
+
v_role uuid;
|
|
147
|
+
v_secret text;
|
|
148
|
+
v_id uuid;
|
|
149
|
+
v_channel text := coalesce(nullif(btrim(p_channel), ''), 'qr');
|
|
150
|
+
BEGIN
|
|
151
|
+
IF v_tenant IS NULL OR auth.uid() IS NULL THEN
|
|
152
|
+
RAISE EXCEPTION 'access_grant_create: no tenant in scope' USING ERRCODE = '42501';
|
|
153
|
+
END IF;
|
|
154
|
+
IF NOT app.has_unit(p_unit) OR NOT app.has_permission('config.manage', p_unit) THEN
|
|
155
|
+
RAISE EXCEPTION 'access_grant_create: config.manage in the unit is required' USING ERRCODE = '42501';
|
|
156
|
+
END IF;
|
|
157
|
+
IF v_channel NOT IN ('qr', 'link', 'totem', 'manual') THEN
|
|
158
|
+
RAISE EXCEPTION 'access_grant_create: unknown channel %', v_channel USING ERRCODE = '22023';
|
|
159
|
+
END IF;
|
|
160
|
+
SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_role_key;
|
|
161
|
+
IF v_role IS NULL THEN
|
|
162
|
+
RAISE EXCEPTION 'access_grant_create: unknown role %', p_role_key USING ERRCODE = '22023';
|
|
163
|
+
END IF;
|
|
164
|
+
v_secret := app.opaque_token();
|
|
165
|
+
INSERT INTO public.access_grants (tenant_id, unit_id, role_id, label, channel, secret_hash, expires_at, max_uses, session_ttl, created_by)
|
|
166
|
+
VALUES (v_tenant, p_unit, v_role, p_label, v_channel, app.sha256_hex(v_secret),
|
|
167
|
+
coalesce(p_expires_at, now() + interval '365 days'), p_max_uses,
|
|
168
|
+
coalesce(p_session_ttl, interval '12 hours'), auth.uid())
|
|
169
|
+
RETURNING id INTO v_id;
|
|
170
|
+
RETURN jsonb_build_object('grant_id', v_id, 'signature', v_secret, 'channel', v_channel,
|
|
171
|
+
'grant_payload', jsonb_build_object('g', v_id, 's', v_secret));
|
|
172
|
+
END $$;
|
|
173
|
+
REVOKE ALL ON FUNCTION public.access_grant_create(uuid, text, text, timestamptz, integer, interval, text) FROM public, anon;
|
|
174
|
+
GRANT EXECUTE ON FUNCTION public.access_grant_create(uuid, text, text, timestamptz, integer, interval, text) TO authenticated, service_role;
|
|
175
|
+
|
|
176
|
+
-- ── redeem: signed grant → session token (anon-callable) ────────────────
|
|
177
|
+
CREATE OR REPLACE FUNCTION public.access_grant_redeem(p_grant_id uuid, p_signature text)
|
|
178
|
+
RETURNS jsonb
|
|
179
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
180
|
+
AS $$
|
|
181
|
+
DECLARE
|
|
182
|
+
c_limit constant integer := 10; -- redemptions per grant per sliding minute
|
|
183
|
+
v_grant public.access_grants%ROWTYPE;
|
|
184
|
+
v_role text;
|
|
185
|
+
v_token text;
|
|
186
|
+
v_sid uuid;
|
|
187
|
+
v_exp timestamptz;
|
|
188
|
+
v_burst integer;
|
|
189
|
+
BEGIN
|
|
190
|
+
IF p_grant_id IS NULL OR nullif(btrim(coalesce(p_signature, '')), '') IS NULL THEN
|
|
191
|
+
RAISE EXCEPTION 'access_grant: invalid or expired grant' USING ERRCODE = '42501';
|
|
192
|
+
END IF;
|
|
193
|
+
|
|
194
|
+
-- burst backstop: count this call, refuse over the threshold
|
|
195
|
+
DELETE FROM public.access_grant_attempts a WHERE a.grant_id = p_grant_id AND a.minute < now() - interval '1 hour';
|
|
196
|
+
INSERT INTO public.access_grant_attempts (grant_id, minute, count)
|
|
197
|
+
SELECT p_grant_id, date_trunc('minute', now()), 1
|
|
198
|
+
WHERE EXISTS (SELECT 1 FROM public.access_grants g WHERE g.id = p_grant_id)
|
|
199
|
+
ON CONFLICT (grant_id, minute) DO UPDATE SET count = public.access_grant_attempts.count + 1;
|
|
200
|
+
SELECT coalesce(sum(a.count), 0) INTO v_burst FROM public.access_grant_attempts a
|
|
201
|
+
WHERE a.grant_id = p_grant_id AND a.minute >= date_trunc('minute', now()) - interval '1 minute';
|
|
202
|
+
IF v_burst > c_limit THEN
|
|
203
|
+
RAISE EXCEPTION 'access_grant: too many attempts' USING ERRCODE = '42501';
|
|
204
|
+
END IF;
|
|
205
|
+
|
|
206
|
+
SELECT g.* INTO v_grant FROM public.access_grants g WHERE g.id = p_grant_id FOR UPDATE;
|
|
207
|
+
IF v_grant.id IS NULL
|
|
208
|
+
OR NOT v_grant.is_active
|
|
209
|
+
OR v_grant.expires_at <= now()
|
|
210
|
+
OR (v_grant.max_uses IS NOT NULL AND v_grant.uses >= v_grant.max_uses)
|
|
211
|
+
OR v_grant.secret_hash <> app.sha256_hex(p_signature)
|
|
212
|
+
OR NOT EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = v_grant.tenant_id AND u.id = v_grant.unit_id AND u.active) THEN
|
|
213
|
+
RAISE EXCEPTION 'access_grant: invalid or expired grant' USING ERRCODE = '42501';
|
|
214
|
+
END IF;
|
|
215
|
+
|
|
216
|
+
SELECT r.key INTO v_role FROM app.roles r WHERE r.tenant_id = v_grant.tenant_id AND r.id = v_grant.role_id;
|
|
217
|
+
v_token := app.opaque_token();
|
|
218
|
+
v_exp := now() + v_grant.session_ttl;
|
|
219
|
+
INSERT INTO public.access_sessions (tenant_id, unit_id, role_id, grant_id, channel, token_hash, expires_at)
|
|
220
|
+
VALUES (v_grant.tenant_id, v_grant.unit_id, v_grant.role_id, v_grant.id, v_grant.channel, app.sha256_hex(v_token), v_exp)
|
|
221
|
+
RETURNING id INTO v_sid;
|
|
222
|
+
UPDATE public.access_grants SET uses = uses + 1 WHERE id = v_grant.id;
|
|
223
|
+
|
|
224
|
+
RETURN jsonb_build_object(
|
|
225
|
+
'session_id', v_sid,
|
|
226
|
+
'session_token', v_token,
|
|
227
|
+
'tenant_id', v_grant.tenant_id,
|
|
228
|
+
'unit_id', v_grant.unit_id,
|
|
229
|
+
'role_key', v_role,
|
|
230
|
+
'channel', v_grant.channel,
|
|
231
|
+
'expires_at', v_exp
|
|
232
|
+
);
|
|
233
|
+
END $$;
|
|
234
|
+
REVOKE ALL ON FUNCTION public.access_grant_redeem(uuid, text) FROM public;
|
|
235
|
+
GRANT EXECUTE ON FUNCTION public.access_grant_redeem(uuid, text) TO anon, authenticated, service_role;
|
|
236
|
+
|
|
237
|
+
-- ── claims: session token → what the JWT carries (service-only, single use) ─
|
|
238
|
+
CREATE OR REPLACE FUNCTION public.access_session_claims(p_session_token text)
|
|
239
|
+
RETURNS jsonb
|
|
240
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
241
|
+
AS $$
|
|
242
|
+
DECLARE
|
|
243
|
+
v_s public.access_sessions%ROWTYPE;
|
|
244
|
+
v_role text;
|
|
245
|
+
BEGIN
|
|
246
|
+
IF auth.uid() IS NOT NULL THEN
|
|
247
|
+
RAISE EXCEPTION 'access_session_claims: service-only' USING ERRCODE = '42501';
|
|
248
|
+
END IF;
|
|
249
|
+
SELECT s.* INTO v_s FROM public.access_sessions s
|
|
250
|
+
WHERE s.token_hash = app.sha256_hex(coalesce(p_session_token, '')) FOR UPDATE;
|
|
251
|
+
IF v_s.id IS NULL OR v_s.used_at IS NOT NULL OR v_s.revoked_at IS NOT NULL OR v_s.expires_at <= now() THEN
|
|
252
|
+
RAISE EXCEPTION 'access_grant: invalid, used or expired session' USING ERRCODE = '42501';
|
|
253
|
+
END IF;
|
|
254
|
+
UPDATE public.access_sessions SET used_at = now() WHERE id = v_s.id;
|
|
255
|
+
SELECT r.key INTO v_role FROM app.roles r WHERE r.tenant_id = v_s.tenant_id AND r.id = v_s.role_id;
|
|
256
|
+
RETURN jsonb_build_object(
|
|
257
|
+
'session_id', v_s.id,
|
|
258
|
+
'tenant_id', v_s.tenant_id,
|
|
259
|
+
'unit_id', v_s.unit_id,
|
|
260
|
+
'role_id', v_s.role_id,
|
|
261
|
+
'role_key', v_role,
|
|
262
|
+
'expires_at', v_s.expires_at
|
|
263
|
+
);
|
|
264
|
+
END $$;
|
|
265
|
+
REVOKE ALL ON FUNCTION public.access_session_claims(text) FROM public, anon, authenticated;
|
|
266
|
+
GRANT EXECUTE ON FUNCTION public.access_session_claims(text) TO service_role;
|
|
267
|
+
|
|
268
|
+
-- ── revoke a session (config.manage in the tenant) ──────────────────────
|
|
269
|
+
CREATE OR REPLACE FUNCTION public.access_session_revoke(p_session_id uuid)
|
|
270
|
+
RETURNS boolean
|
|
271
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
272
|
+
AS $$
|
|
273
|
+
DECLARE v_tenant uuid := app.current_tenant_id(); v_n integer;
|
|
274
|
+
BEGIN
|
|
275
|
+
IF v_tenant IS NULL OR NOT app.has_permission('config.manage') THEN
|
|
276
|
+
RAISE EXCEPTION 'access_session_revoke: config.manage is required' USING ERRCODE = '42501';
|
|
277
|
+
END IF;
|
|
278
|
+
UPDATE public.access_sessions SET revoked_at = coalesce(revoked_at, now())
|
|
279
|
+
WHERE id = p_session_id AND tenant_id = v_tenant;
|
|
280
|
+
GET DIAGNOSTICS v_n = ROW_COUNT;
|
|
281
|
+
RETURN v_n > 0;
|
|
282
|
+
END $$;
|
|
283
|
+
REVOKE ALL ON FUNCTION public.access_session_revoke(uuid) FROM public, anon;
|
|
284
|
+
GRANT EXECUTE ON FUNCTION public.access_session_revoke(uuid) TO authenticated, service_role;
|
|
285
|
+
|
|
286
|
+
COMMENT ON TABLE public.access_grants IS
|
|
287
|
+
'Delegated access grants (PRD 07 R6): unit + role + expiry + max_uses; the delivery payload carries {grant_id, signature} and only sha256(signature) is stored. '
|
|
288
|
+
'channel records HOW the secret was delivered (qr | link | totem | manual) — the QR code is one channel, not the concept. '
|
|
289
|
+
'access_grant_redeem() turns a valid grant into a single-use session; access_session_claims() (service-only) exchanges it for JWT claims.';
|
|
290
|
+
COMMENT ON TABLE public.access_sessions IS
|
|
291
|
+
'Opaque scoped sessions minted by a grant (token_hash only), stamped with the channel that minted them. Single use (used_at), expiry, revocation. No API role reads this table.';
|
|
292
|
+
COMMENT ON COLUMN public.access_grants.channel IS
|
|
293
|
+
'Delivery channel of the grant secret: qr (printed/scanned code), link (message or e-mail), totem (kiosk at the unit), manual (read out loud / typed).';
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 100_migration_schema.sql — the private migration ledger (PRD 10 / #148,
|
|
3
|
+
-- ADR 0007, ADR 0008): schema `migration`, its tables, and the grant posture.
|
|
4
|
+
--
|
|
5
|
+
-- The SDK is the DESTINATION of the V1→V2 extraction. The extractor tool runs
|
|
6
|
+
-- elsewhere with a service credential; what lives here is what the destination
|
|
7
|
+
-- guarantees: one ledger row per source row, quarantine instead of silent
|
|
8
|
+
-- correction, a frozen table allowlist, a per-tenant write fence with a
|
|
9
|
+
-- kill switch that dies at the point of no return, and reconciliation reports.
|
|
10
|
+
--
|
|
11
|
+
-- Nothing in this schema is API surface: PostgREST does not expose it, anon and
|
|
12
|
+
-- authenticated hold no privilege on it, every table has RLS enabled with no
|
|
13
|
+
-- policy for an API role. Only service_role (the extractor) and the migration
|
|
14
|
+
-- owner (the RPCs, SECURITY DEFINER) touch it.
|
|
15
|
+
--
|
|
16
|
+
-- Idempotent and replay-safe: IF NOT EXISTS / guarded DO blocks throughout.
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
|
|
19
|
+
CREATE SCHEMA IF NOT EXISTS migration;
|
|
20
|
+
REVOKE ALL ON SCHEMA migration FROM public, anon, authenticated;
|
|
21
|
+
GRANT USAGE ON SCHEMA migration TO service_role;
|
|
22
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA migration REVOKE ALL ON TABLES FROM public, anon, authenticated;
|
|
23
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA migration REVOKE ALL ON SEQUENCES FROM public, anon, authenticated;
|
|
24
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA migration REVOKE EXECUTE ON FUNCTIONS FROM public, anon, authenticated;
|
|
25
|
+
|
|
26
|
+
-- ─────────────────────────────── batches ───────────────────────────────
|
|
27
|
+
-- One extractor run (or resume) for one tenant from one source project.
|
|
28
|
+
-- source_tenant_id is the V1 licence id: a source row whose tenant_id disagrees
|
|
29
|
+
-- with it is quarantined ('tenant'), never re-homed.
|
|
30
|
+
CREATE TABLE IF NOT EXISTS migration.batches (
|
|
31
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
32
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
33
|
+
source_project text NOT NULL,
|
|
34
|
+
source_tenant_id bigint,
|
|
35
|
+
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'paused', 'done', 'failed')),
|
|
36
|
+
started_at timestamptz NOT NULL DEFAULT now(),
|
|
37
|
+
finished_at timestamptz,
|
|
38
|
+
cursor_from text,
|
|
39
|
+
cursor_to text,
|
|
40
|
+
counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
41
|
+
notes text,
|
|
42
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS batches_tenant_idx ON migration.batches (tenant_id, started_at DESC);
|
|
46
|
+
|
|
47
|
+
-- ─────────────────────────────── ledger ────────────────────────────────
|
|
48
|
+
-- One row per source row ever offered to the destination. status is the state
|
|
49
|
+
-- of the SOURCE ROW at the destination, not the outcome of the last call:
|
|
50
|
+
-- a no-op replay leaves the row untouched (that is what makes the batch
|
|
51
|
+
-- checksum stable). `financial` holds the financial values as written, so the
|
|
52
|
+
-- reconciliation can compare Σ(ledger) with Σ(destination) to the cent.
|
|
53
|
+
CREATE TABLE IF NOT EXISTS migration.ledger (
|
|
54
|
+
id bigserial PRIMARY KEY,
|
|
55
|
+
source_project text NOT NULL,
|
|
56
|
+
source_table text NOT NULL,
|
|
57
|
+
source_numeric_id bigint,
|
|
58
|
+
source_uuid uuid,
|
|
59
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
60
|
+
target_table text,
|
|
61
|
+
target_id uuid,
|
|
62
|
+
checksum text NOT NULL,
|
|
63
|
+
cursor text,
|
|
64
|
+
batch_id uuid REFERENCES migration.batches(id) ON DELETE SET NULL,
|
|
65
|
+
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'migrated', 'quarantined', 'skipped')),
|
|
66
|
+
reason text,
|
|
67
|
+
financial jsonb,
|
|
68
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
69
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
70
|
+
CHECK (source_numeric_id IS NOT NULL OR source_uuid IS NOT NULL)
|
|
71
|
+
);
|
|
72
|
+
-- One row per (source row, destination table): a split (Venda → invoice) is a
|
|
73
|
+
-- second row for the same source row; a merge is N source rows on one target_id.
|
|
74
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ledger_source_numeric_key
|
|
75
|
+
ON migration.ledger (source_project, source_table, source_numeric_id, coalesce(target_table, '')) WHERE source_numeric_id IS NOT NULL;
|
|
76
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ledger_source_uuid_key
|
|
77
|
+
ON migration.ledger (source_project, source_table, source_uuid, coalesce(target_table, '')) WHERE source_numeric_id IS NULL AND source_uuid IS NOT NULL;
|
|
78
|
+
CREATE INDEX IF NOT EXISTS ledger_source_uuid_idx ON migration.ledger (source_project, source_table, source_uuid) WHERE source_uuid IS NOT NULL;
|
|
79
|
+
CREATE INDEX IF NOT EXISTS ledger_batch_idx ON migration.ledger (batch_id);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS ledger_tenant_target_idx ON migration.ledger (tenant_id, target_table, target_id);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS ledger_tenant_source_idx ON migration.ledger (tenant_id, source_project, source_table);
|
|
82
|
+
|
|
83
|
+
-- ─────────────────────────────── quarantine ────────────────────────────
|
|
84
|
+
-- A source row the destination refused to write, with WHY and the payload the
|
|
85
|
+
-- extractor sent (the source row — synthetic in every fixture, real only on
|
|
86
|
+
-- the rehearsal/production destination, never in Git). A human resolves it
|
|
87
|
+
-- (resolved_at + resolution) and the extractor re-offers the row.
|
|
88
|
+
CREATE TABLE IF NOT EXISTS migration.quarantine (
|
|
89
|
+
id bigserial PRIMARY KEY,
|
|
90
|
+
ledger_id bigint NOT NULL REFERENCES migration.ledger(id) ON DELETE CASCADE,
|
|
91
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
92
|
+
batch_id uuid REFERENCES migration.batches(id) ON DELETE SET NULL,
|
|
93
|
+
kind text NOT NULL CHECK (kind IN ('tenant', 'identity', 'state', 'value', 'fk', 'allowlist')),
|
|
94
|
+
reason text NOT NULL,
|
|
95
|
+
payload jsonb,
|
|
96
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
97
|
+
resolved_at timestamptz,
|
|
98
|
+
resolution text
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS quarantine_ledger_idx ON migration.quarantine (ledger_id);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS quarantine_open_idx ON migration.quarantine (tenant_id, kind) WHERE resolved_at IS NULL;
|
|
102
|
+
|
|
103
|
+
-- ─────────────────────────────── allowlist ─────────────────────────────
|
|
104
|
+
-- Only these source tables may reach the destination (core-A, core-beauty-chef
|
|
105
|
+
-- §3.1). columns: source column → target spec ("col" | "col@ref_source_table"
|
|
106
|
+
-- resolved through the ledger | "jsonb:path" into metadata | "-" dropped on
|
|
107
|
+
-- purpose). writer: the typed destination writer of 092 ('person', 'unit',
|
|
108
|
+
-- 'category', 'product', 'order', 'invoice', NULL = recorded as pending until a
|
|
109
|
+
-- writer exists). financial_columns: target columns that are NEVER overwritten
|
|
110
|
+
-- once written (EF lesson #3). frozen rows refuse UPDATE/DELETE unless the
|
|
111
|
+
-- session declares migration.allowlist_unlock = 'on' (a migration file does).
|
|
112
|
+
CREATE TABLE IF NOT EXISTS migration.allowlist (
|
|
113
|
+
source_table text PRIMARY KEY,
|
|
114
|
+
target_table text,
|
|
115
|
+
layer text NOT NULL DEFAULT 'core-A',
|
|
116
|
+
domain text,
|
|
117
|
+
columns jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
118
|
+
writer text CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice')),
|
|
119
|
+
writer_args jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
120
|
+
financial_columns text[] NOT NULL DEFAULT '{}',
|
|
121
|
+
frozen boolean NOT NULL DEFAULT false,
|
|
122
|
+
notes text,
|
|
123
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
124
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
CREATE OR REPLACE FUNCTION migration.trg_allowlist_frozen()
|
|
128
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
129
|
+
BEGIN
|
|
130
|
+
IF OLD.frozen AND coalesce(current_setting('migration.allowlist_unlock', true), '') <> 'on' THEN
|
|
131
|
+
RAISE EXCEPTION 'migration.allowlist: % is frozen (core-A allowlist changes are a migration file, not a write)', OLD.source_table
|
|
132
|
+
USING ERRCODE = '55000';
|
|
133
|
+
END IF;
|
|
134
|
+
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
|
135
|
+
RETURN NEW;
|
|
136
|
+
END $$;
|
|
137
|
+
DROP TRIGGER IF EXISTS allowlist_frozen ON migration.allowlist;
|
|
138
|
+
CREATE TRIGGER allowlist_frozen BEFORE UPDATE OR DELETE ON migration.allowlist
|
|
139
|
+
FOR EACH ROW EXECUTE FUNCTION migration.trg_allowlist_frozen();
|
|
140
|
+
|
|
141
|
+
-- Tables the extractor must never send: dead legacy tables and the previous
|
|
142
|
+
-- vendor's one-off import. An upsert for one of these is quarantined
|
|
143
|
+
-- ('allowlist') with the reason below.
|
|
144
|
+
CREATE TABLE IF NOT EXISTS migration.excluded_tables (
|
|
145
|
+
source_table text PRIMARY KEY,
|
|
146
|
+
reason text NOT NULL,
|
|
147
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
-- ─────────────────────────────── tenant fence ──────────────────────────
|
|
151
|
+
-- ADR 0008. v1_writer: V1 owns the tenant, the destination refuses migration
|
|
152
|
+
-- writes. fenced: V1 frozen for cut-over, migration writes allowed, V2 app
|
|
153
|
+
-- writes are the point of no return. v2_writer: live on V2. The kill switch
|
|
154
|
+
-- returns the tenant to V1 ONLY while first_v2_write_at is null; after the
|
|
155
|
+
-- first exclusive V2 write there is no way back (no reverse sync).
|
|
156
|
+
CREATE TABLE IF NOT EXISTS migration.tenant_fence (
|
|
157
|
+
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
158
|
+
state text NOT NULL DEFAULT 'v1_writer' CHECK (state IN ('v1_writer', 'fenced', 'v2_writer')),
|
|
159
|
+
fenced_at timestamptz,
|
|
160
|
+
first_v2_write_at timestamptz,
|
|
161
|
+
kill_switch boolean NOT NULL DEFAULT false,
|
|
162
|
+
notes text,
|
|
163
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
164
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
-- ─────────────────────────────── reconciliation ────────────────────────
|
|
168
|
+
-- Approved differences: a value mismatch (target_table.column) or a source
|
|
169
|
+
-- table the report may ignore, signed by a human. Anything not listed here
|
|
170
|
+
-- keeps the report red.
|
|
171
|
+
CREATE TABLE IF NOT EXISTS migration.exceptions (
|
|
172
|
+
id bigserial PRIMARY KEY,
|
|
173
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
174
|
+
batch_id uuid REFERENCES migration.batches(id) ON DELETE SET NULL,
|
|
175
|
+
source_table text,
|
|
176
|
+
key text NOT NULL,
|
|
177
|
+
reason text NOT NULL,
|
|
178
|
+
approved_by text NOT NULL,
|
|
179
|
+
approved_at timestamptz NOT NULL DEFAULT now(),
|
|
180
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
181
|
+
);
|
|
182
|
+
CREATE INDEX IF NOT EXISTS exceptions_tenant_idx ON migration.exceptions (tenant_id, key);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE IF NOT EXISTS migration.reconciliation_reports (
|
|
185
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
186
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
187
|
+
batch_id uuid REFERENCES migration.batches(id) ON DELETE SET NULL,
|
|
188
|
+
green boolean NOT NULL,
|
|
189
|
+
report jsonb NOT NULL,
|
|
190
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
191
|
+
);
|
|
192
|
+
CREATE INDEX IF NOT EXISTS reconciliation_reports_batch_idx ON migration.reconciliation_reports (batch_id, created_at DESC);
|
|
193
|
+
|
|
194
|
+
-- ─────────────────────────────── updated_at ────────────────────────────
|
|
195
|
+
DROP TRIGGER IF EXISTS batches_updated_at ON migration.batches;
|
|
196
|
+
CREATE TRIGGER batches_updated_at BEFORE UPDATE ON migration.batches FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
197
|
+
DROP TRIGGER IF EXISTS ledger_updated_at ON migration.ledger;
|
|
198
|
+
CREATE TRIGGER ledger_updated_at BEFORE UPDATE ON migration.ledger FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
199
|
+
DROP TRIGGER IF EXISTS allowlist_updated_at ON migration.allowlist;
|
|
200
|
+
CREATE TRIGGER allowlist_updated_at BEFORE UPDATE ON migration.allowlist FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
201
|
+
DROP TRIGGER IF EXISTS tenant_fence_updated_at ON migration.tenant_fence;
|
|
202
|
+
CREATE TRIGGER tenant_fence_updated_at BEFORE UPDATE ON migration.tenant_fence FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
203
|
+
|
|
204
|
+
-- ─────────────────────────────── posture ───────────────────────────────
|
|
205
|
+
-- RLS on, no policy for any API role; service_role bypasses RLS and is the
|
|
206
|
+
-- only role granted; the RPC owner reaches the tables as owner.
|
|
207
|
+
DO $$
|
|
208
|
+
DECLARE t text;
|
|
209
|
+
BEGIN
|
|
210
|
+
FOREACH t IN ARRAY ARRAY['batches', 'ledger', 'quarantine', 'allowlist', 'excluded_tables', 'tenant_fence', 'exceptions', 'reconciliation_reports'] LOOP
|
|
211
|
+
EXECUTE format('ALTER TABLE migration.%I ENABLE ROW LEVEL SECURITY', t);
|
|
212
|
+
EXECUTE format('REVOKE ALL ON migration.%I FROM public, anon, authenticated', t);
|
|
213
|
+
EXECUTE format('GRANT ALL ON migration.%I TO service_role', t);
|
|
214
|
+
END LOOP;
|
|
215
|
+
END $$;
|
|
216
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA migration TO service_role;
|
|
217
|
+
REVOKE ALL ON FUNCTION migration.trg_allowlist_frozen() FROM public, anon, authenticated;
|