@fayz-ai/db 0.10.0 → 0.12.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/dist/index.cjs +39 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -2
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +553 -0
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/025_created_by.sql +75 -0
- package/migrations/026_audit_trail.sql +73 -0
- package/migrations/027_domain_events.sql +266 -0
- package/migrations/028_tenant_scoped_token.sql +127 -0
- package/migrations/029_connections.sql +186 -0
- package/migrations/030_effect_idempotency.sql +159 -0
- package/migrations/031_sync_run_message.sql +39 -0
- package/migrations/032_connection_secrets.sql +227 -0
- package/migrations/033_sync_schedule.sql +651 -0
- package/migrations/034_custom_fields.sql +55 -0
- package/migrations/035_field_registry.sql +148 -0
- package/migrations/036_analytics_run_batch.sql +84 -0
- 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 +81 -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/package.json +7 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 030_effect_idempotency.sql — the run row IS the claim.
|
|
3
|
+
--
|
|
4
|
+
-- 029 drew the line the request plane needs: a QUOTE is not a run (no fetched,
|
|
5
|
+
-- no written, no cursor, and an `anon` shopper with no tenant-scoped write path
|
|
6
|
+
-- to file one with), while the EFFECTS a quote leads to — capture the payment,
|
|
7
|
+
-- buy the label — are ordinary rows of plg_sync_runs.
|
|
8
|
+
--
|
|
9
|
+
-- What 029 could not express is the half of an effect that the sync plane never
|
|
10
|
+
-- needed: nobody double-clicks a cron. A gateway that re-delivers its webhook,
|
|
11
|
+
-- and a buyer who clicks "pagar" twice, both arrive as a second call to capture
|
|
12
|
+
-- the same order for the same amount. The sync plane's answer — "the run is
|
|
13
|
+
-- filed after the work" — leaves exactly the window in which the second call
|
|
14
|
+
-- charges again.
|
|
15
|
+
--
|
|
16
|
+
-- So the claim and the run become one statement. `plg_claim_effect` inserts the
|
|
17
|
+
-- run row with the buyer-derived key and lets the unique index decide the race:
|
|
18
|
+
-- the caller that inserted owns the effect, the caller that conflicted is handed
|
|
19
|
+
-- the FIRST run's id and told not to call the provider. Two connectors need this
|
|
20
|
+
-- (AppMax capture, Melhor Envio label), which is why it is here and not in
|
|
21
|
+
-- either of them.
|
|
22
|
+
--
|
|
23
|
+
-- WHY A COLUMN AND NOT A TABLE. A second table would have to be kept in
|
|
24
|
+
-- agreement with plg_sync_runs about which effects happened — two sources of
|
|
25
|
+
-- truth for one fact, and the failure mode is a claim with no run behind it (or
|
|
26
|
+
-- the reverse) that nobody notices until a customer is charged twice. The claim
|
|
27
|
+
-- and the record of the effect are the same row on purpose.
|
|
28
|
+
--
|
|
29
|
+
-- The column is NULLABLE and the index PARTIAL: an ordinary sync run carries no
|
|
30
|
+
-- key and is completely unaffected. Nothing about the sync plane changes here.
|
|
31
|
+
-- ============================================================================
|
|
32
|
+
|
|
33
|
+
ALTER TABLE public.plg_sync_runs
|
|
34
|
+
ADD COLUMN IF NOT EXISTS idempotency_key text;
|
|
35
|
+
|
|
36
|
+
-- Scoped to (tenant, connector) rather than to the connection: an order is
|
|
37
|
+
-- captured once per gateway for a tenant, whichever instance of that gateway
|
|
38
|
+
-- answered. Including tenant_id is not decoration — a key is derived from an
|
|
39
|
+
-- order id, and two tenants of one pool must never be able to make each other's
|
|
40
|
+
-- capture look like a duplicate that was already done.
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_sync_runs_effect_key
|
|
42
|
+
ON public.plg_sync_runs (tenant_id, connector_id, idempotency_key)
|
|
43
|
+
WHERE idempotency_key IS NOT NULL;
|
|
44
|
+
|
|
45
|
+
COMMENT ON COLUMN public.plg_sync_runs.idempotency_key IS
|
|
46
|
+
'Buyer-derived key for an effect on the request plane (capture, label). NULL '
|
|
47
|
+
'for every sync run and for every quote — a quote is not a run at all. Unique '
|
|
48
|
+
'per (tenant, connector) so a re-delivered webhook claims the first run '
|
|
49
|
+
'instead of performing the effect a second time.';
|
|
50
|
+
|
|
51
|
+
-- ── Claim ───────────────────────────────────────────────────────────────────
|
|
52
|
+
-- Returns (run_id, claimed). `claimed = false` means the key already ran: hand
|
|
53
|
+
-- back the first run and DO NOT call the provider.
|
|
54
|
+
--
|
|
55
|
+
-- The tenant is resolved from the connection row and never taken from the
|
|
56
|
+
-- caller — the same discipline plugbank_import_movements follows, and the reason
|
|
57
|
+
-- a compromised data-plane call cannot file an effect against a tenant it does
|
|
58
|
+
-- not hold a connection for.
|
|
59
|
+
CREATE OR REPLACE FUNCTION public.plg_claim_effect(
|
|
60
|
+
p_connection_id uuid,
|
|
61
|
+
p_idempotency_key text,
|
|
62
|
+
p_direction text,
|
|
63
|
+
p_trigger_kind text,
|
|
64
|
+
p_stream text DEFAULT NULL
|
|
65
|
+
)
|
|
66
|
+
RETURNS TABLE (run_id uuid, claimed boolean)
|
|
67
|
+
LANGUAGE plpgsql
|
|
68
|
+
SECURITY DEFINER
|
|
69
|
+
SET search_path = public
|
|
70
|
+
AS $fn$
|
|
71
|
+
DECLARE
|
|
72
|
+
v_tenant_id uuid;
|
|
73
|
+
v_connector_id text;
|
|
74
|
+
v_id uuid;
|
|
75
|
+
BEGIN
|
|
76
|
+
IF p_idempotency_key IS NULL OR btrim(p_idempotency_key) = '' THEN
|
|
77
|
+
-- An effect nobody can name twice is an effect that runs twice. There is no
|
|
78
|
+
-- "just this once" path, because that path is the one a retry takes.
|
|
79
|
+
RAISE EXCEPTION 'plg_claim_effect requires an idempotency key';
|
|
80
|
+
END IF;
|
|
81
|
+
|
|
82
|
+
SELECT c.tenant_id, c.connector_id INTO v_tenant_id, v_connector_id
|
|
83
|
+
FROM public.plg_connections c
|
|
84
|
+
WHERE c.id = p_connection_id;
|
|
85
|
+
|
|
86
|
+
IF v_tenant_id IS NULL THEN
|
|
87
|
+
RAISE EXCEPTION 'plg_claim_effect: no connection %', p_connection_id;
|
|
88
|
+
END IF;
|
|
89
|
+
|
|
90
|
+
INSERT INTO public.plg_sync_runs
|
|
91
|
+
(tenant_id, connection_id, connector_id, direction, trigger_kind, status, stream, idempotency_key)
|
|
92
|
+
VALUES
|
|
93
|
+
(v_tenant_id, p_connection_id, v_connector_id, p_direction, p_trigger_kind, 'running', p_stream, p_idempotency_key)
|
|
94
|
+
ON CONFLICT (tenant_id, connector_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
|
95
|
+
DO NOTHING
|
|
96
|
+
RETURNING id INTO v_id;
|
|
97
|
+
|
|
98
|
+
IF v_id IS NOT NULL THEN
|
|
99
|
+
RETURN QUERY SELECT v_id, true;
|
|
100
|
+
RETURN;
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
SELECT r.id INTO v_id
|
|
104
|
+
FROM public.plg_sync_runs r
|
|
105
|
+
WHERE r.tenant_id = v_tenant_id
|
|
106
|
+
AND r.connector_id = v_connector_id
|
|
107
|
+
AND r.idempotency_key = p_idempotency_key;
|
|
108
|
+
|
|
109
|
+
RETURN QUERY SELECT v_id, false;
|
|
110
|
+
END;
|
|
111
|
+
$fn$;
|
|
112
|
+
|
|
113
|
+
-- ── Settle ─────────────────────────────────────────────────────────────────
|
|
114
|
+
-- Closes the run the claim opened. `finished_at IS NULL` in the WHERE is the
|
|
115
|
+
-- guard that matters: a replayed webhook cannot rewrite an effect that already
|
|
116
|
+
-- has a verdict, so "captured" never becomes "error" because the gateway
|
|
117
|
+
-- re-sent an old event.
|
|
118
|
+
CREATE OR REPLACE FUNCTION public.plg_settle_effect(
|
|
119
|
+
p_run_id uuid,
|
|
120
|
+
p_status text,
|
|
121
|
+
p_stats jsonb DEFAULT '{}'::jsonb,
|
|
122
|
+
p_error text DEFAULT NULL
|
|
123
|
+
)
|
|
124
|
+
RETURNS boolean
|
|
125
|
+
LANGUAGE plpgsql
|
|
126
|
+
SECURITY DEFINER
|
|
127
|
+
SET search_path = public
|
|
128
|
+
AS $fn$
|
|
129
|
+
DECLARE
|
|
130
|
+
v_rows integer;
|
|
131
|
+
BEGIN
|
|
132
|
+
UPDATE public.plg_sync_runs
|
|
133
|
+
SET status = p_status,
|
|
134
|
+
stats = COALESCE(p_stats, '{}'::jsonb),
|
|
135
|
+
error = p_error,
|
|
136
|
+
finished_at = now()
|
|
137
|
+
WHERE id = p_run_id
|
|
138
|
+
AND finished_at IS NULL;
|
|
139
|
+
GET DIAGNOSTICS v_rows = ROW_COUNT;
|
|
140
|
+
RETURN v_rows > 0;
|
|
141
|
+
END;
|
|
142
|
+
$fn$;
|
|
143
|
+
|
|
144
|
+
-- Server-side only, exactly like the runs table itself: 029 grants
|
|
145
|
+
-- `authenticated` SELECT and nothing else, and a SECURITY DEFINER function that
|
|
146
|
+
-- inserts runs would hand back the INSERT that grant deliberately withholds.
|
|
147
|
+
-- The shopper is `anon` and reaches neither.
|
|
148
|
+
REVOKE ALL ON FUNCTION public.plg_claim_effect(uuid, text, text, text, text) FROM PUBLIC;
|
|
149
|
+
REVOKE ALL ON FUNCTION public.plg_claim_effect(uuid, text, text, text, text) FROM anon, authenticated;
|
|
150
|
+
GRANT EXECUTE ON FUNCTION public.plg_claim_effect(uuid, text, text, text, text) TO service_role;
|
|
151
|
+
|
|
152
|
+
REVOKE ALL ON FUNCTION public.plg_settle_effect(uuid, text, jsonb, text) FROM PUBLIC;
|
|
153
|
+
REVOKE ALL ON FUNCTION public.plg_settle_effect(uuid, text, jsonb, text) FROM anon, authenticated;
|
|
154
|
+
GRANT EXECUTE ON FUNCTION public.plg_settle_effect(uuid, text, jsonb, text) TO service_role;
|
|
155
|
+
|
|
156
|
+
COMMENT ON FUNCTION public.plg_claim_effect(uuid, text, text, text, text) IS
|
|
157
|
+
'Claim an effect on the request plane. The run row is the claim: the caller '
|
|
158
|
+
'that inserts owns it, the caller that conflicts gets the first run back with '
|
|
159
|
+
'claimed=false and must not call the provider. Quotes never come here.';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 031_sync_run_message.sql — the one thing the generic shape split in two.
|
|
3
|
+
--
|
|
4
|
+
-- Four connectors brought a bespoke sync log and every one of them had a single
|
|
5
|
+
-- `message`/`error` column carrying whatever the run had to say. 029 kept only
|
|
6
|
+
-- the failure half (`error`), on the reasonable-sounding theory that a
|
|
7
|
+
-- successful run says everything in `fetched`/`written`.
|
|
8
|
+
--
|
|
9
|
+
-- It does not. "Importado do extrato de 01/08 a 12/08, 3 duplicados ignorados"
|
|
10
|
+
-- is a successful run with something to say, and RankLayer's panel had to
|
|
11
|
+
-- reconstruct it as `run.error ?? run.stats.message` — an ad-hoc key inside a
|
|
12
|
+
-- free-form jsonb, which is precisely the convention the generic tables exist
|
|
13
|
+
-- to stop the sixth connector from reinventing.
|
|
14
|
+
--
|
|
15
|
+
-- So: one nullable text column, and the rule that goes with it —
|
|
16
|
+
-- error is what went WRONG. Null on a successful run, always.
|
|
17
|
+
-- message is what the run has to SAY. Set on any status, including 'error',
|
|
18
|
+
-- where it is the human sentence next to the machine one.
|
|
19
|
+
--
|
|
20
|
+
-- Additive and idempotent: every pool has already applied 029, and `fayz db
|
|
21
|
+
-- apply` re-runs a file whose checksum moved, so this must be safe twice.
|
|
22
|
+
-- ============================================================================
|
|
23
|
+
|
|
24
|
+
ALTER TABLE public.plg_sync_runs
|
|
25
|
+
ADD COLUMN IF NOT EXISTS message text;
|
|
26
|
+
|
|
27
|
+
COMMENT ON COLUMN public.plg_sync_runs.message IS
|
|
28
|
+
'What the run has to say, on any status. `error` stays reserved for what went '
|
|
29
|
+
'wrong, so a successful run with something to report no longer has to smuggle '
|
|
30
|
+
'it through stats.';
|
|
31
|
+
|
|
32
|
+
-- No grant statement here, deliberately, and the omission is load-bearing.
|
|
33
|
+
--
|
|
34
|
+
-- A defensive `REVOKE INSERT, UPDATE, DELETE … FROM authenticated` looks like
|
|
35
|
+
-- belt and braces. It is the opposite: 029 is the file that owns the write ban,
|
|
36
|
+
-- and re-asserting it here means the bench's negative control (which deletes
|
|
37
|
+
-- 029's REVOKE and demands that C3 go red) passes with the ban gone. A second
|
|
38
|
+
-- REVOKE does not add a guarantee, it hides the loss of the first one.
|
|
39
|
+
-- The property is asserted instead — connections.regression.sql C10c.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 032_connection_secrets.sql — the credential holder 029 promised and nobody built.
|
|
3
|
+
--
|
|
4
|
+
-- 029 wrote, on the table itself: "secrets belong to the platform credential
|
|
5
|
+
-- store, never to a row a connector's own browser code can write." That was the
|
|
6
|
+
-- right call and it left a hole, because the platform credential store does not
|
|
7
|
+
-- exist (FAY-1381). The consequence is not theoretical:
|
|
8
|
+
--
|
|
9
|
+
-- * Bling declares authKind 'oauth' and its Connect button is disabled,
|
|
10
|
+
-- because there is nowhere to put the refresh token the consent returns.
|
|
11
|
+
-- * AppMax and Melhor Envio declare an api-key field, the hub routes it to
|
|
12
|
+
-- ConnectorCredentialSink, and no app installs a sink — so the honest
|
|
13
|
+
-- outcome is "cannot save", which is where they are now.
|
|
14
|
+
-- * google-calendar still cannot migrate off its bespoke table for exactly
|
|
15
|
+
-- this reason; 029 said so in its own header.
|
|
16
|
+
--
|
|
17
|
+
-- Three connectors, one missing thing. This file is that thing, scoped to the
|
|
18
|
+
-- pool rather than the platform: Supabase Vault is installed here
|
|
19
|
+
-- (`supabase_vault`, encrypted at rest, keyed outside the table), so the pool
|
|
20
|
+
-- CAN hold a tenant credential safely today. When the platform broker ships,
|
|
21
|
+
-- the data plane keeps calling the same three functions and the storage moves
|
|
22
|
+
-- underneath — which is the point of putting the functions in front of it.
|
|
23
|
+
--
|
|
24
|
+
-- ── Why a table of ids and not a column on plg_connections ──────────────────
|
|
25
|
+
-- Because 029's argument still holds in full. A credential column is a column
|
|
26
|
+
-- some later `select *` returns, some later RLS policy exposes, and some later
|
|
27
|
+
-- connector writes from the browser. Nothing here changes that: plg_connections
|
|
28
|
+
-- keeps no credential, and the bench's C1 keeps proving it.
|
|
29
|
+
--
|
|
30
|
+
-- What this table holds is a POINTER — `(connection, key) → vault secret id`.
|
|
31
|
+
-- The secret itself lives in vault.secrets, encrypted, reachable only through a
|
|
32
|
+
-- SECURITY DEFINER function that no client role may execute.
|
|
33
|
+
--
|
|
34
|
+
-- ── Why the mapping table at all, when the vault name is deterministic ──────
|
|
35
|
+
-- `plg:<connection_id>:<key>` could be recomputed instead of stored. The table
|
|
36
|
+
-- earns its place three times over: ON DELETE CASCADE means disconnecting a
|
|
37
|
+
-- connection takes its credentials with it without anyone remembering to; the
|
|
38
|
+
-- keys of a connection can be enumerated without granting a single privilege on
|
|
39
|
+
-- the vault schema; and the drop path deletes what it actually stored rather
|
|
40
|
+
-- than what it guesses the name should have been.
|
|
41
|
+
--
|
|
42
|
+
-- ── Why the table is granted to nobody ──────────────────────────────────────
|
|
43
|
+
-- It holds ids, not secrets. It is still shut to `anon` and `authenticated`,
|
|
44
|
+
-- because an id is a handle, and the whole design rests on the handle being
|
|
45
|
+
-- unreachable from the browser. A tenant that can read the handle is one
|
|
46
|
+
-- "GRANT SELECT ON vault.decrypted_secrets TO authenticated for debugging"
|
|
47
|
+
-- away from reading the credential — and that grant is a one-liner somebody
|
|
48
|
+
-- will be tempted by at 2am. Take the handle away and the temptation has
|
|
49
|
+
-- nothing to act on.
|
|
50
|
+
--
|
|
51
|
+
-- Idempotent, because `fayz db apply` re-runs a file whose checksum moved.
|
|
52
|
+
-- ============================================================================
|
|
53
|
+
|
|
54
|
+
-- ── The pointer ─────────────────────────────────────────────────────────────
|
|
55
|
+
-- One row per (connection, key). `key` is the connector's own field name —
|
|
56
|
+
-- 'access_token' / 'refresh_token' for an OAuth connector, the declared field
|
|
57
|
+
-- key ('apiKey', 'token') for an api-key one. Deliberately free text: the set
|
|
58
|
+
-- of keys belongs to the ConnectorDefinition, and a CHECK here would mean every
|
|
59
|
+
-- new connector needs a migration.
|
|
60
|
+
CREATE TABLE IF NOT EXISTS public.plg_connection_secrets (
|
|
61
|
+
connection_id uuid NOT NULL REFERENCES public.plg_connections(id) ON DELETE CASCADE,
|
|
62
|
+
key text NOT NULL,
|
|
63
|
+
vault_secret_id uuid NOT NULL,
|
|
64
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
65
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
66
|
+
PRIMARY KEY (connection_id, key)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
COMMENT ON TABLE public.plg_connection_secrets IS
|
|
70
|
+
'Pointer table: (connection, key) -> vault secret id. Holds NO secret value '
|
|
71
|
+
'and is granted to NO client role. The three plg_*_connection_secret(s) '
|
|
72
|
+
'functions are the only way in or out, and only service_role may call them.';
|
|
73
|
+
|
|
74
|
+
-- ── Grants: none, and the RLS has to agree ─────────────────────────────────
|
|
75
|
+
-- 011 shut `anon` out of every new table in this schema and left `authenticated`
|
|
76
|
+
-- born with the full set — the same trap 029 documented. Both are stripped, and
|
|
77
|
+
-- nothing is granted back. RLS is enabled with NO policy, which for a
|
|
78
|
+
-- non-owner role means deny-all; the REVOKE and the empty policy set have to
|
|
79
|
+
-- agree, or the next person to "fix the grant" reopens it silently.
|
|
80
|
+
REVOKE ALL ON public.plg_connection_secrets FROM PUBLIC;
|
|
81
|
+
REVOKE ALL ON public.plg_connection_secrets FROM anon, authenticated;
|
|
82
|
+
|
|
83
|
+
ALTER TABLE public.plg_connection_secrets ENABLE ROW LEVEL SECURITY;
|
|
84
|
+
|
|
85
|
+
-- ── Store ───────────────────────────────────────────────────────────────────
|
|
86
|
+
-- Upsert semantics, and they are load-bearing: Bling's refresh token is
|
|
87
|
+
-- replaced on every renewal, and a store that appended would leave the pool
|
|
88
|
+
-- holding a growing pile of dead credentials — each one still decryptable.
|
|
89
|
+
-- One connection+key is one vault secret, forever.
|
|
90
|
+
--
|
|
91
|
+
-- The vault name is derived, never supplied, so a caller cannot aim this at
|
|
92
|
+
-- another connection's secret or at an unrelated vault entry.
|
|
93
|
+
CREATE OR REPLACE FUNCTION public.plg_store_connection_secret(
|
|
94
|
+
p_connection_id uuid,
|
|
95
|
+
p_key text,
|
|
96
|
+
p_secret text
|
|
97
|
+
)
|
|
98
|
+
RETURNS uuid
|
|
99
|
+
LANGUAGE plpgsql
|
|
100
|
+
SECURITY DEFINER
|
|
101
|
+
SET search_path = public
|
|
102
|
+
AS $fn$
|
|
103
|
+
DECLARE
|
|
104
|
+
v_name text;
|
|
105
|
+
v_id uuid;
|
|
106
|
+
BEGIN
|
|
107
|
+
IF p_connection_id IS NULL OR coalesce(p_key, '') = '' THEN
|
|
108
|
+
RAISE EXCEPTION 'connection id and key are required';
|
|
109
|
+
END IF;
|
|
110
|
+
-- The key becomes part of a vault name; keep it to an identifier so it cannot
|
|
111
|
+
-- carry a colon and collide with another connection's namespace.
|
|
112
|
+
IF p_key !~ '^[a-zA-Z0-9_.-]{1,64}$' THEN
|
|
113
|
+
RAISE EXCEPTION 'invalid secret key';
|
|
114
|
+
END IF;
|
|
115
|
+
IF p_secret IS NULL OR length(p_secret) = 0 THEN
|
|
116
|
+
RAISE EXCEPTION 'refusing to store an empty secret';
|
|
117
|
+
END IF;
|
|
118
|
+
|
|
119
|
+
v_name := 'plg:' || p_connection_id::text || ':' || p_key;
|
|
120
|
+
|
|
121
|
+
SELECT vault_secret_id INTO v_id
|
|
122
|
+
FROM public.plg_connection_secrets
|
|
123
|
+
WHERE connection_id = p_connection_id AND key = p_key;
|
|
124
|
+
|
|
125
|
+
-- A vault entry may outlive its pointer (a manual DELETE, a half-applied
|
|
126
|
+
-- earlier run). Adopt it rather than create a second one under a name the
|
|
127
|
+
-- vault's unique index would reject anyway.
|
|
128
|
+
IF v_id IS NULL THEN
|
|
129
|
+
SELECT id INTO v_id FROM vault.secrets WHERE name = v_name;
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
IF v_id IS NULL THEN
|
|
133
|
+
v_id := vault.create_secret(p_secret, v_name, 'fayz connector credential');
|
|
134
|
+
ELSE
|
|
135
|
+
PERFORM vault.update_secret(v_id, p_secret);
|
|
136
|
+
END IF;
|
|
137
|
+
|
|
138
|
+
INSERT INTO public.plg_connection_secrets (connection_id, key, vault_secret_id)
|
|
139
|
+
VALUES (p_connection_id, p_key, v_id)
|
|
140
|
+
ON CONFLICT (connection_id, key)
|
|
141
|
+
DO UPDATE SET vault_secret_id = EXCLUDED.vault_secret_id, updated_at = now();
|
|
142
|
+
|
|
143
|
+
RETURN v_id;
|
|
144
|
+
END;
|
|
145
|
+
$fn$;
|
|
146
|
+
|
|
147
|
+
-- ── Read ────────────────────────────────────────────────────────────────────
|
|
148
|
+
-- What the data plane calls at run time, and the only decrypt path that exists.
|
|
149
|
+
-- Returns NULL rather than raising when there is nothing stored: "not connected"
|
|
150
|
+
-- is an ordinary answer for a connector, not an exception.
|
|
151
|
+
CREATE OR REPLACE FUNCTION public.plg_read_connection_secret(
|
|
152
|
+
p_connection_id uuid,
|
|
153
|
+
p_key text
|
|
154
|
+
)
|
|
155
|
+
RETURNS text
|
|
156
|
+
LANGUAGE plpgsql
|
|
157
|
+
SECURITY DEFINER
|
|
158
|
+
SET search_path = public
|
|
159
|
+
AS $fn$
|
|
160
|
+
DECLARE
|
|
161
|
+
v_secret text;
|
|
162
|
+
BEGIN
|
|
163
|
+
SELECT d.decrypted_secret INTO v_secret
|
|
164
|
+
FROM public.plg_connection_secrets s
|
|
165
|
+
JOIN vault.decrypted_secrets d ON d.id = s.vault_secret_id
|
|
166
|
+
WHERE s.connection_id = p_connection_id AND s.key = p_key;
|
|
167
|
+
RETURN v_secret;
|
|
168
|
+
END;
|
|
169
|
+
$fn$;
|
|
170
|
+
|
|
171
|
+
-- ── Drop ────────────────────────────────────────────────────────────────────
|
|
172
|
+
-- Disconnect. 029's C9g rule stands — a disconnect FREEZES the mirror and keeps
|
|
173
|
+
-- the connection row and its history — but the credential is exactly the thing
|
|
174
|
+
-- that must not survive it, so this deletes the vault entries as well as the
|
|
175
|
+
-- pointers. Returns the count so the caller can log a real number.
|
|
176
|
+
--
|
|
177
|
+
-- The CASCADE on the table only removes pointers when the whole connection is
|
|
178
|
+
-- deleted; the vault rows would be orphaned. That is why this function exists
|
|
179
|
+
-- rather than a `DELETE FROM plg_connection_secrets`.
|
|
180
|
+
CREATE OR REPLACE FUNCTION public.plg_drop_connection_secrets(p_connection_id uuid)
|
|
181
|
+
RETURNS integer
|
|
182
|
+
LANGUAGE plpgsql
|
|
183
|
+
SECURITY DEFINER
|
|
184
|
+
SET search_path = public
|
|
185
|
+
AS $fn$
|
|
186
|
+
DECLARE
|
|
187
|
+
v_ids uuid[];
|
|
188
|
+
v_rows integer;
|
|
189
|
+
BEGIN
|
|
190
|
+
SELECT array_agg(vault_secret_id) INTO v_ids
|
|
191
|
+
FROM public.plg_connection_secrets WHERE connection_id = p_connection_id;
|
|
192
|
+
|
|
193
|
+
DELETE FROM public.plg_connection_secrets WHERE connection_id = p_connection_id;
|
|
194
|
+
GET DIAGNOSTICS v_rows = ROW_COUNT;
|
|
195
|
+
|
|
196
|
+
IF v_ids IS NOT NULL THEN
|
|
197
|
+
DELETE FROM vault.secrets WHERE id = ANY (v_ids);
|
|
198
|
+
END IF;
|
|
199
|
+
|
|
200
|
+
RETURN v_rows;
|
|
201
|
+
END;
|
|
202
|
+
$fn$;
|
|
203
|
+
|
|
204
|
+
-- ── The grant discipline, three times ──────────────────────────────────────
|
|
205
|
+
-- PUBLIC first: EXECUTE on a new function is granted to PUBLIC by default, and
|
|
206
|
+
-- revoking from anon/authenticated alone leaves it reachable through that.
|
|
207
|
+
REVOKE ALL ON FUNCTION public.plg_store_connection_secret(uuid, text, text) FROM PUBLIC;
|
|
208
|
+
REVOKE ALL ON FUNCTION public.plg_store_connection_secret(uuid, text, text) FROM anon, authenticated;
|
|
209
|
+
GRANT EXECUTE ON FUNCTION public.plg_store_connection_secret(uuid, text, text) TO service_role;
|
|
210
|
+
|
|
211
|
+
REVOKE ALL ON FUNCTION public.plg_read_connection_secret(uuid, text) FROM PUBLIC;
|
|
212
|
+
REVOKE ALL ON FUNCTION public.plg_read_connection_secret(uuid, text) FROM anon, authenticated;
|
|
213
|
+
GRANT EXECUTE ON FUNCTION public.plg_read_connection_secret(uuid, text) TO service_role;
|
|
214
|
+
|
|
215
|
+
REVOKE ALL ON FUNCTION public.plg_drop_connection_secrets(uuid) FROM PUBLIC;
|
|
216
|
+
REVOKE ALL ON FUNCTION public.plg_drop_connection_secrets(uuid) FROM anon, authenticated;
|
|
217
|
+
GRANT EXECUTE ON FUNCTION public.plg_drop_connection_secrets(uuid) TO service_role;
|
|
218
|
+
|
|
219
|
+
COMMENT ON FUNCTION public.plg_store_connection_secret(uuid, text, text) IS
|
|
220
|
+
'Place or replace one credential for a connection. Vault name is derived from '
|
|
221
|
+
'the connection id and key, so a refresh replaces rather than accumulates. '
|
|
222
|
+
'service_role only.';
|
|
223
|
+
COMMENT ON FUNCTION public.plg_read_connection_secret(uuid, text) IS
|
|
224
|
+
'The only decrypt path. Called by the data plane at run time. service_role only.';
|
|
225
|
+
COMMENT ON FUNCTION public.plg_drop_connection_secrets(uuid) IS
|
|
226
|
+
'Disconnect: removes the pointers AND the vault entries behind them. '
|
|
227
|
+
'service_role only.';
|