Skip to content

Commit 18356d3

Browse files
velitchkoclaude
andauthored
Add update_vault_publication_with_rollup Postgres function (#150)
* feat: add update_vault_publication_with_rollup Postgres function * fix: actor context for updated_by trigger, restrict EXECUTE to service_role Copilot review on #150: - vault_publications has a BEFORE UPDATE trigger that unconditionally sets updated_by := auth.uid(). Since this function is only ever called under the service-role key (no JWT context), auth.uid() was NULL, silently discarding the explicit updated_by = p_actor_user_id on the sibling update -- it looked like it worked but didn't. Fixed by calling set_config('request.jwt.claim.sub', p_actor_user_id::text, true) before any updates, so the trigger picks up the real actor for every row it touches (target row included, which had the same latent gap even before this function existed). Removed the now-redundant explicit assignment. - No GRANT/REVOKE was set on the new function, unlike every other function in this schema. Rather than matching the common anon/authenticated/service_role pattern (meant for functions regular users call directly), restricted EXECUTE to service_role only: this function trusts its caller completely and has no independent authorization, so it must only be reachable through .netlify's own auth/scope/vault-access checks, never directly via PostgREST by an end user. This also resolves the SECURITY INVOKER/RLS concern raised in the same review -- RLS never becomes a factor once no other role can invoke it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: guard array-field rollup against JSON null/scalar patch values jsonb_array_elements_text() raises "cannot extract elements from a scalar" for any non-array jsonb input, including the JSON null literal returned by -> when a key's value is null. authors/editor/keywords used this unguarded, so a patch like {"authors": null} (or any non-array value) crashed the RPC as 502 publication_rollup_failed instead of clearing the field or leaving it untouched. Guard with jsonb_typeof: array values convert as before, JSON null clears the column to NULL (matching how ->>'field' already turns JSON null into SQL NULL for the scalar fields), anything else is left unchanged rather than erroring. * fix: guard year rollup against non-numeric patch values (p_patch->>'year')::integer crashes on any non-integer text, including "" or "abcd" -- a malformed year value from an API client would surface as 502 publication_rollup_failed instead of a clean validation error. Guard with jsonb_typeof: JSON null clears the column, a JSON number casts via numeric (avoiding a crash on decimal-looking text like "2020.5"), anything else is left unchanged rather than erroring -- same guard style already applied to the array fields. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4a3d506 commit 18356d3

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
-- 20260706000000_publication_bibliographic_rollup.sql
2+
--
3+
-- Rolls up bibliographic-field edits from one vault_publications copy to the
4+
-- canonical publications row and every sibling vault_publications copy
5+
-- (same original_publication_id, different vault), atomically. Parameters
6+
-- are p_-prefixed to avoid the ambiguous-column-reference bug fixed in
7+
-- migration 006 (a bare `vault_id = vault_id` inside a function body is
8+
-- parsed as comparing the column to itself, not to the parameter, if the
9+
-- parameter shares the column's name).
10+
--
11+
-- notes is intentionally never part of the bibliographic field list below —
12+
-- it's vault-local and, like tag_ids, must never propagate.
13+
14+
CREATE OR REPLACE FUNCTION "public"."update_vault_publication_with_rollup"(
15+
"p_vault_publication_id" "uuid",
16+
"p_vault_id" "uuid",
17+
"p_patch" "jsonb",
18+
"p_actor_user_id" "uuid"
19+
) RETURNS "void"
20+
LANGUAGE "plpgsql"
21+
AS $$
22+
DECLARE
23+
v_original_id uuid;
24+
v_has_bibliographic_patch boolean;
25+
BEGIN
26+
-- This function is only ever called under the service-role key (see
27+
-- .netlify's handleUpdateItem), which has no JWT context of its own, so
28+
-- auth.uid() would otherwise resolve to NULL for the rest of this
29+
-- transaction. vault_publications' own "set updated_by from auth.uid()"
30+
-- BEFORE UPDATE trigger fires on every UPDATE below regardless -- setting
31+
-- this makes it record the real actor instead of overwriting every touched
32+
-- row's updated_by with NULL.
33+
PERFORM set_config('request.jwt.claim.sub', p_actor_user_id::text, true);
34+
35+
UPDATE vault_publications SET
36+
title = CASE WHEN p_patch ? 'title' THEN p_patch->>'title' ELSE title END,
37+
authors = CASE
38+
WHEN p_patch ? 'authors' AND jsonb_typeof(p_patch->'authors') = 'array'
39+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'authors'))
40+
WHEN p_patch ? 'authors' AND jsonb_typeof(p_patch->'authors') = 'null'
41+
THEN NULL
42+
ELSE authors
43+
END,
44+
year = CASE
45+
WHEN p_patch ? 'year' AND jsonb_typeof(p_patch->'year') = 'null'
46+
THEN NULL
47+
WHEN p_patch ? 'year' AND jsonb_typeof(p_patch->'year') = 'number'
48+
THEN (p_patch->>'year')::numeric::integer
49+
WHEN p_patch ? 'year'
50+
THEN year
51+
ELSE year
52+
END,
53+
journal = CASE WHEN p_patch ? 'journal' THEN p_patch->>'journal' ELSE journal END,
54+
volume = CASE WHEN p_patch ? 'volume' THEN p_patch->>'volume' ELSE volume END,
55+
issue = CASE WHEN p_patch ? 'issue' THEN p_patch->>'issue' ELSE issue END,
56+
pages = CASE WHEN p_patch ? 'pages' THEN p_patch->>'pages' ELSE pages END,
57+
doi = CASE WHEN p_patch ? 'doi' THEN p_patch->>'doi' ELSE doi END,
58+
url = CASE WHEN p_patch ? 'url' THEN p_patch->>'url' ELSE url END,
59+
abstract = CASE WHEN p_patch ? 'abstract' THEN p_patch->>'abstract' ELSE abstract END,
60+
pdf_url = CASE WHEN p_patch ? 'pdf_url' THEN p_patch->>'pdf_url' ELSE pdf_url END,
61+
bibtex_key = CASE WHEN p_patch ? 'bibtex_key' THEN p_patch->>'bibtex_key' ELSE bibtex_key END,
62+
publication_type = CASE WHEN p_patch ? 'publication_type' THEN p_patch->>'publication_type' ELSE publication_type END,
63+
notes = CASE WHEN p_patch ? 'notes' THEN p_patch->>'notes' ELSE notes END,
64+
booktitle = CASE WHEN p_patch ? 'booktitle' THEN p_patch->>'booktitle' ELSE booktitle END,
65+
chapter = CASE WHEN p_patch ? 'chapter' THEN p_patch->>'chapter' ELSE chapter END,
66+
edition = CASE WHEN p_patch ? 'edition' THEN p_patch->>'edition' ELSE edition END,
67+
editor = CASE
68+
WHEN p_patch ? 'editor' AND jsonb_typeof(p_patch->'editor') = 'array'
69+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'editor'))
70+
WHEN p_patch ? 'editor' AND jsonb_typeof(p_patch->'editor') = 'null'
71+
THEN NULL
72+
ELSE editor
73+
END,
74+
howpublished = CASE WHEN p_patch ? 'howpublished' THEN p_patch->>'howpublished' ELSE howpublished END,
75+
institution = CASE WHEN p_patch ? 'institution' THEN p_patch->>'institution' ELSE institution END,
76+
number = CASE WHEN p_patch ? 'number' THEN p_patch->>'number' ELSE number END,
77+
organization = CASE WHEN p_patch ? 'organization' THEN p_patch->>'organization' ELSE organization END,
78+
publisher = CASE WHEN p_patch ? 'publisher' THEN p_patch->>'publisher' ELSE publisher END,
79+
school = CASE WHEN p_patch ? 'school' THEN p_patch->>'school' ELSE school END,
80+
series = CASE WHEN p_patch ? 'series' THEN p_patch->>'series' ELSE series END,
81+
type = CASE WHEN p_patch ? 'type' THEN p_patch->>'type' ELSE type END,
82+
eid = CASE WHEN p_patch ? 'eid' THEN p_patch->>'eid' ELSE eid END,
83+
isbn = CASE WHEN p_patch ? 'isbn' THEN p_patch->>'isbn' ELSE isbn END,
84+
issn = CASE WHEN p_patch ? 'issn' THEN p_patch->>'issn' ELSE issn END,
85+
keywords = CASE
86+
WHEN p_patch ? 'keywords' AND jsonb_typeof(p_patch->'keywords') = 'array'
87+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'keywords'))
88+
WHEN p_patch ? 'keywords' AND jsonb_typeof(p_patch->'keywords') = 'null'
89+
THEN NULL
90+
ELSE keywords
91+
END,
92+
version = version + 1,
93+
updated_at = now()
94+
WHERE id = p_vault_publication_id AND vault_id = p_vault_id
95+
RETURNING original_publication_id INTO v_original_id;
96+
97+
IF NOT FOUND THEN
98+
RAISE EXCEPTION 'vault publication % not found in vault %', p_vault_publication_id, p_vault_id
99+
USING ERRCODE = 'P0002';
100+
END IF;
101+
102+
v_has_bibliographic_patch := p_patch ?| ARRAY[
103+
'title', 'authors', 'year', 'journal', 'volume', 'issue', 'pages', 'doi', 'url',
104+
'abstract', 'pdf_url', 'bibtex_key', 'publication_type', 'booktitle', 'chapter',
105+
'edition', 'editor', 'howpublished', 'institution', 'number', 'organization',
106+
'publisher', 'school', 'series', 'type', 'eid', 'isbn', 'issn', 'keywords'
107+
];
108+
109+
IF v_original_id IS NOT NULL AND v_has_bibliographic_patch THEN
110+
UPDATE publications SET
111+
title = CASE WHEN p_patch ? 'title' THEN p_patch->>'title' ELSE title END,
112+
authors = CASE
113+
WHEN p_patch ? 'authors' AND jsonb_typeof(p_patch->'authors') = 'array'
114+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'authors'))
115+
WHEN p_patch ? 'authors' AND jsonb_typeof(p_patch->'authors') = 'null'
116+
THEN NULL
117+
ELSE authors
118+
END,
119+
year = CASE
120+
WHEN p_patch ? 'year' AND jsonb_typeof(p_patch->'year') = 'null'
121+
THEN NULL
122+
WHEN p_patch ? 'year' AND jsonb_typeof(p_patch->'year') = 'number'
123+
THEN (p_patch->>'year')::numeric::integer
124+
WHEN p_patch ? 'year'
125+
THEN year
126+
ELSE year
127+
END,
128+
journal = CASE WHEN p_patch ? 'journal' THEN p_patch->>'journal' ELSE journal END,
129+
volume = CASE WHEN p_patch ? 'volume' THEN p_patch->>'volume' ELSE volume END,
130+
issue = CASE WHEN p_patch ? 'issue' THEN p_patch->>'issue' ELSE issue END,
131+
pages = CASE WHEN p_patch ? 'pages' THEN p_patch->>'pages' ELSE pages END,
132+
doi = CASE WHEN p_patch ? 'doi' THEN p_patch->>'doi' ELSE doi END,
133+
url = CASE WHEN p_patch ? 'url' THEN p_patch->>'url' ELSE url END,
134+
abstract = CASE WHEN p_patch ? 'abstract' THEN p_patch->>'abstract' ELSE abstract END,
135+
pdf_url = CASE WHEN p_patch ? 'pdf_url' THEN p_patch->>'pdf_url' ELSE pdf_url END,
136+
bibtex_key = CASE WHEN p_patch ? 'bibtex_key' THEN p_patch->>'bibtex_key' ELSE bibtex_key END,
137+
publication_type = CASE WHEN p_patch ? 'publication_type' THEN p_patch->>'publication_type' ELSE publication_type END,
138+
booktitle = CASE WHEN p_patch ? 'booktitle' THEN p_patch->>'booktitle' ELSE booktitle END,
139+
chapter = CASE WHEN p_patch ? 'chapter' THEN p_patch->>'chapter' ELSE chapter END,
140+
edition = CASE WHEN p_patch ? 'edition' THEN p_patch->>'edition' ELSE edition END,
141+
editor = CASE
142+
WHEN p_patch ? 'editor' AND jsonb_typeof(p_patch->'editor') = 'array'
143+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'editor'))
144+
WHEN p_patch ? 'editor' AND jsonb_typeof(p_patch->'editor') = 'null'
145+
THEN NULL
146+
ELSE editor
147+
END,
148+
howpublished = CASE WHEN p_patch ? 'howpublished' THEN p_patch->>'howpublished' ELSE howpublished END,
149+
institution = CASE WHEN p_patch ? 'institution' THEN p_patch->>'institution' ELSE institution END,
150+
number = CASE WHEN p_patch ? 'number' THEN p_patch->>'number' ELSE number END,
151+
organization = CASE WHEN p_patch ? 'organization' THEN p_patch->>'organization' ELSE organization END,
152+
publisher = CASE WHEN p_patch ? 'publisher' THEN p_patch->>'publisher' ELSE publisher END,
153+
school = CASE WHEN p_patch ? 'school' THEN p_patch->>'school' ELSE school END,
154+
series = CASE WHEN p_patch ? 'series' THEN p_patch->>'series' ELSE series END,
155+
type = CASE WHEN p_patch ? 'type' THEN p_patch->>'type' ELSE type END,
156+
eid = CASE WHEN p_patch ? 'eid' THEN p_patch->>'eid' ELSE eid END,
157+
isbn = CASE WHEN p_patch ? 'isbn' THEN p_patch->>'isbn' ELSE isbn END,
158+
issn = CASE WHEN p_patch ? 'issn' THEN p_patch->>'issn' ELSE issn END,
159+
keywords = CASE
160+
WHEN p_patch ? 'keywords' AND jsonb_typeof(p_patch->'keywords') = 'array'
161+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'keywords'))
162+
WHEN p_patch ? 'keywords' AND jsonb_typeof(p_patch->'keywords') = 'null'
163+
THEN NULL
164+
ELSE keywords
165+
END,
166+
updated_at = now()
167+
WHERE id = v_original_id;
168+
169+
UPDATE vault_publications SET
170+
title = CASE WHEN p_patch ? 'title' THEN p_patch->>'title' ELSE title END,
171+
authors = CASE
172+
WHEN p_patch ? 'authors' AND jsonb_typeof(p_patch->'authors') = 'array'
173+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'authors'))
174+
WHEN p_patch ? 'authors' AND jsonb_typeof(p_patch->'authors') = 'null'
175+
THEN NULL
176+
ELSE authors
177+
END,
178+
year = CASE
179+
WHEN p_patch ? 'year' AND jsonb_typeof(p_patch->'year') = 'null'
180+
THEN NULL
181+
WHEN p_patch ? 'year' AND jsonb_typeof(p_patch->'year') = 'number'
182+
THEN (p_patch->>'year')::numeric::integer
183+
WHEN p_patch ? 'year'
184+
THEN year
185+
ELSE year
186+
END,
187+
journal = CASE WHEN p_patch ? 'journal' THEN p_patch->>'journal' ELSE journal END,
188+
volume = CASE WHEN p_patch ? 'volume' THEN p_patch->>'volume' ELSE volume END,
189+
issue = CASE WHEN p_patch ? 'issue' THEN p_patch->>'issue' ELSE issue END,
190+
pages = CASE WHEN p_patch ? 'pages' THEN p_patch->>'pages' ELSE pages END,
191+
doi = CASE WHEN p_patch ? 'doi' THEN p_patch->>'doi' ELSE doi END,
192+
url = CASE WHEN p_patch ? 'url' THEN p_patch->>'url' ELSE url END,
193+
abstract = CASE WHEN p_patch ? 'abstract' THEN p_patch->>'abstract' ELSE abstract END,
194+
pdf_url = CASE WHEN p_patch ? 'pdf_url' THEN p_patch->>'pdf_url' ELSE pdf_url END,
195+
bibtex_key = CASE WHEN p_patch ? 'bibtex_key' THEN p_patch->>'bibtex_key' ELSE bibtex_key END,
196+
publication_type = CASE WHEN p_patch ? 'publication_type' THEN p_patch->>'publication_type' ELSE publication_type END,
197+
booktitle = CASE WHEN p_patch ? 'booktitle' THEN p_patch->>'booktitle' ELSE booktitle END,
198+
chapter = CASE WHEN p_patch ? 'chapter' THEN p_patch->>'chapter' ELSE chapter END,
199+
edition = CASE WHEN p_patch ? 'edition' THEN p_patch->>'edition' ELSE edition END,
200+
editor = CASE
201+
WHEN p_patch ? 'editor' AND jsonb_typeof(p_patch->'editor') = 'array'
202+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'editor'))
203+
WHEN p_patch ? 'editor' AND jsonb_typeof(p_patch->'editor') = 'null'
204+
THEN NULL
205+
ELSE editor
206+
END,
207+
howpublished = CASE WHEN p_patch ? 'howpublished' THEN p_patch->>'howpublished' ELSE howpublished END,
208+
institution = CASE WHEN p_patch ? 'institution' THEN p_patch->>'institution' ELSE institution END,
209+
number = CASE WHEN p_patch ? 'number' THEN p_patch->>'number' ELSE number END,
210+
organization = CASE WHEN p_patch ? 'organization' THEN p_patch->>'organization' ELSE organization END,
211+
publisher = CASE WHEN p_patch ? 'publisher' THEN p_patch->>'publisher' ELSE publisher END,
212+
school = CASE WHEN p_patch ? 'school' THEN p_patch->>'school' ELSE school END,
213+
series = CASE WHEN p_patch ? 'series' THEN p_patch->>'series' ELSE series END,
214+
type = CASE WHEN p_patch ? 'type' THEN p_patch->>'type' ELSE type END,
215+
eid = CASE WHEN p_patch ? 'eid' THEN p_patch->>'eid' ELSE eid END,
216+
isbn = CASE WHEN p_patch ? 'isbn' THEN p_patch->>'isbn' ELSE isbn END,
217+
issn = CASE WHEN p_patch ? 'issn' THEN p_patch->>'issn' ELSE issn END,
218+
keywords = CASE
219+
WHEN p_patch ? 'keywords' AND jsonb_typeof(p_patch->'keywords') = 'array'
220+
THEN ARRAY(SELECT jsonb_array_elements_text(p_patch->'keywords'))
221+
WHEN p_patch ? 'keywords' AND jsonb_typeof(p_patch->'keywords') = 'null'
222+
THEN NULL
223+
ELSE keywords
224+
END,
225+
updated_at = now()
226+
WHERE original_publication_id = v_original_id AND id <> p_vault_publication_id;
227+
END IF;
228+
END;
229+
$$;
230+
231+
ALTER FUNCTION "public"."update_vault_publication_with_rollup"("p_vault_publication_id" "uuid", "p_vault_id" "uuid", "p_patch" "jsonb", "p_actor_user_id" "uuid") OWNER TO "postgres";
232+
233+
-- Unlike copy_publication_to_vault and most other functions in this schema
234+
-- (granted to anon/authenticated/service_role alike, since they're meant to
235+
-- be called by regular users), this function trusts its caller completely:
236+
-- it has no independent authorization of its own, no auth/scope/vault-access
237+
-- checks -- those already happened in .netlify's handleUpdateItem before the
238+
-- RPC call. Restricting EXECUTE to service_role only means the only caller
239+
-- that can ever invoke it is the one that already did those checks, and RLS
240+
-- (which service_role bypasses) never becomes a factor either way.
241+
REVOKE ALL ON FUNCTION "public"."update_vault_publication_with_rollup"("p_vault_publication_id" "uuid", "p_vault_id" "uuid", "p_patch" "jsonb", "p_actor_user_id" "uuid") FROM PUBLIC;
242+
GRANT ALL ON FUNCTION "public"."update_vault_publication_with_rollup"("p_vault_publication_id" "uuid", "p_vault_id" "uuid", "p_patch" "jsonb", "p_actor_user_id" "uuid") TO "service_role";

0 commit comments

Comments
 (0)