create-bw-app 0.27.4 → 0.27.5
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/package.json +2 -1
- package/src/constants.mjs +8 -4
- package/src/generator.mjs +13 -9
- package/src/migration-order.mjs +8 -0
- package/src/migrations.mjs +47 -8
- package/src/update.mjs +152 -5
- package/src/upgrade.mjs +91 -14
- package/template/modules/projects/app/api/account/projects/[id]/tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/account/projects/[id]/tasks/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/route.ts +2 -0
- package/template/supabase/modules/admin/migrations/20261014183138_auth_verified_admin_acceptance.sql +232 -0
- package/template/supabase/modules/core/migrations/20261014183138_protect_profile_identity.sql +272 -0
- package/template/supabase/modules/orgs/migrations/20261014183138_auth_verified_organization_membership.sql +194 -0
- package/template/supabase/modules/projects/migrations/20261015135430_enforce_task_milestone_project.sql +52 -0
- package/template/supabase/modules/projects/migrations/20261015143936_client_task_collaboration.sql +763 -0
package/template/supabase/modules/admin/migrations/20261014183138_auth_verified_admin_acceptance.sql
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
-- auth_verified_admin_acceptance
|
|
2
|
+
-- target: admin
|
|
3
|
+
-- created_at: 2026-10-14T18:31:38.413Z
|
|
4
|
+
|
|
5
|
+
-- One atomic statement protects old CLI/manual continue-on-error application:
|
|
6
|
+
-- failed prerequisites or later DDL leave every existing guard/function intact.
|
|
7
|
+
DO $identity_migration$
|
|
8
|
+
BEGIN
|
|
9
|
+
IF to_regprocedure('public.assert_profile_auth_identity_v1()') IS NULL THEN
|
|
10
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply Core 20261014183138_protect_profile_identity before Admin/Orgs identity migrations.' USING ERRCODE = '55000';
|
|
11
|
+
END IF;
|
|
12
|
+
PERFORM public.assert_profile_auth_identity_v1();
|
|
13
|
+
EXECUTE $identity_ddl$
|
|
14
|
+
-- Core now guards browser identity writes for every global role. Its invoker
|
|
15
|
+
-- UPDATE guard also permits the restricted Auth sync when invoked from staff
|
|
16
|
+
-- sessions; the former definer guard incorrectly rejected that trusted sync.
|
|
17
|
+
DROP TRIGGER IF EXISTS guard_privileged_profile_fields ON public.profiles;
|
|
18
|
+
DROP FUNCTION IF EXISTS public.guard_privileged_profile_fields();
|
|
19
|
+
|
|
20
|
+
-- atomic_admin_invitation_acceptance
|
|
21
|
+
-- target: admin
|
|
22
|
+
-- created_at: 2026-09-06T10:16:16.616Z
|
|
23
|
+
|
|
24
|
+
CREATE OR REPLACE FUNCTION public.accept_admin_user_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
|
|
25
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
26
|
+
DECLARE
|
|
27
|
+
v_invitation public.admin_user_invitations%ROWTYPE;
|
|
28
|
+
v_user_id uuid;
|
|
29
|
+
v_old_role text;
|
|
30
|
+
BEGIN
|
|
31
|
+
v_user_id := public.lock_confirmed_profile_auth_identity(p_profile_id, p_user_email);
|
|
32
|
+
-- Lock the same ordered set as direct assignment, including terminal retries.
|
|
33
|
+
SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id;
|
|
34
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
|
|
35
|
+
PERFORM id FROM public.admin_user_invitations
|
|
36
|
+
WHERE lower(btrim(invited_email)) = lower(btrim(v_invitation.invited_email))
|
|
37
|
+
ORDER BY id FOR UPDATE;
|
|
38
|
+
SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id FOR UPDATE;
|
|
39
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
|
|
40
|
+
IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
|
|
41
|
+
RAISE EXCEPTION 'Este convite pertence a outro email.';
|
|
42
|
+
END IF;
|
|
43
|
+
-- Recheck the profile link after Auth and invitations are locked.
|
|
44
|
+
PERFORM p.id FROM public.profiles p
|
|
45
|
+
WHERE p.id = p_profile_id AND p.user_id = v_user_id
|
|
46
|
+
AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
|
|
47
|
+
FOR UPDATE OF p;
|
|
48
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.' USING ERRCODE = 'BW002'; END IF;
|
|
49
|
+
IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
|
|
50
|
+
RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
|
|
51
|
+
END IF;
|
|
52
|
+
IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
|
|
53
|
+
IF v_invitation.expires_at <= clock_timestamp() THEN
|
|
54
|
+
UPDATE public.admin_user_invitations SET status = 'expired' WHERE id = p_invitation_id;
|
|
55
|
+
RETURN jsonb_build_object('status', 'expired');
|
|
56
|
+
END IF;
|
|
57
|
+
|
|
58
|
+
SELECT role_code INTO v_old_role FROM public.user_role_assignments WHERE profile_id = p_profile_id FOR UPDATE;
|
|
59
|
+
INSERT INTO public.user_role_assignments(profile_id, role_code, assigned_by_profile_id, assigned_at, reason)
|
|
60
|
+
VALUES(p_profile_id, v_invitation.role_code, v_invitation.invited_by_profile_id, now(), 'Convite de utilizador do portal aceite.')
|
|
61
|
+
ON CONFLICT(profile_id) DO UPDATE SET role_code = EXCLUDED.role_code,
|
|
62
|
+
assigned_by_profile_id = EXCLUDED.assigned_by_profile_id, assigned_at = EXCLUDED.assigned_at, reason = EXCLUDED.reason;
|
|
63
|
+
IF v_invitation.invited_by_profile_id IS NOT NULL THEN
|
|
64
|
+
INSERT INTO public.role_change_audit(target_profile_id, changed_by_profile_id, old_role_code, new_role_code, reason)
|
|
65
|
+
VALUES(p_profile_id, v_invitation.invited_by_profile_id, v_old_role, v_invitation.role_code, 'Convite de utilizador do portal aceite.');
|
|
66
|
+
END IF;
|
|
67
|
+
UPDATE public.admin_user_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id
|
|
68
|
+
WHERE id = p_invitation_id;
|
|
69
|
+
PERFORM public.log_app_activity_event('admin', 'admin_user_invitation_accepted', 'admin_user_invitations', p_invitation_id,
|
|
70
|
+
'Convite de utilizador aceite.', jsonb_build_object('email', v_invitation.invited_email, 'role', v_invitation.role_code,
|
|
71
|
+
'status', 'accepted', 'accepted_by_profile_id', p_profile_id), p_profile_id);
|
|
72
|
+
RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
|
|
73
|
+
END;
|
|
74
|
+
$$;
|
|
75
|
+
REVOKE ALL ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
|
|
76
|
+
GRANT EXECUTE ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) TO service_role;
|
|
77
|
+
|
|
78
|
+
-- Bootstrap also touches Auth and profiles. Lock Auth before the advisory and
|
|
79
|
+
-- profile locks, so a stale service request cannot overwrite a concurrent email
|
|
80
|
+
-- change and a missing-profile INSERT cannot invert the identity lock order.
|
|
81
|
+
CREATE OR REPLACE FUNCTION public.bootstrap_first_admin(
|
|
82
|
+
p_user_id uuid,
|
|
83
|
+
p_email text
|
|
84
|
+
)
|
|
85
|
+
RETURNS TABLE (
|
|
86
|
+
profile_id uuid,
|
|
87
|
+
previous_role_code text
|
|
88
|
+
)
|
|
89
|
+
LANGUAGE plpgsql
|
|
90
|
+
SECURITY DEFINER
|
|
91
|
+
SET search_path = ''
|
|
92
|
+
AS $$
|
|
93
|
+
DECLARE
|
|
94
|
+
v_email text;
|
|
95
|
+
v_auth_email text;
|
|
96
|
+
v_auth_confirmed_at timestamptz;
|
|
97
|
+
v_profile_id uuid;
|
|
98
|
+
v_previous_role_code text;
|
|
99
|
+
BEGIN
|
|
100
|
+
IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' THEN
|
|
101
|
+
RAISE EXCEPTION 'bootstrap_first_admin requires service_role'
|
|
102
|
+
USING ERRCODE = '42501';
|
|
103
|
+
END IF;
|
|
104
|
+
|
|
105
|
+
v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
|
|
106
|
+
IF p_user_id IS NULL OR v_email IS NULL THEN
|
|
107
|
+
RAISE EXCEPTION 'A user id and email are required'
|
|
108
|
+
USING ERRCODE = '22023';
|
|
109
|
+
END IF;
|
|
110
|
+
|
|
111
|
+
SELECT lower(trim(u.email)), u.email_confirmed_at
|
|
112
|
+
INTO v_auth_email, v_auth_confirmed_at
|
|
113
|
+
FROM auth.users u
|
|
114
|
+
WHERE u.id = p_user_id
|
|
115
|
+
FOR NO KEY UPDATE;
|
|
116
|
+
|
|
117
|
+
IF v_auth_email IS NULL OR v_auth_email <> v_email OR v_auth_confirmed_at IS NULL THEN
|
|
118
|
+
RAISE EXCEPTION 'Auth user and confirmed email do not match'
|
|
119
|
+
USING ERRCODE = '22023';
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
IF (SELECT count(*) FROM auth.users WHERE lower(btrim(email)) = v_email) <> 1 THEN
|
|
123
|
+
RAISE EXCEPTION 'Ambiguous Auth identity; reconcile before granting access.' USING ERRCODE = '21000';
|
|
124
|
+
END IF;
|
|
125
|
+
|
|
126
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('brightweb:first-admin-bootstrap', 0));
|
|
127
|
+
|
|
128
|
+
IF EXISTS (
|
|
129
|
+
SELECT 1
|
|
130
|
+
FROM public.user_role_assignments ura
|
|
131
|
+
WHERE ura.role_code = 'admin'
|
|
132
|
+
) THEN
|
|
133
|
+
RAISE EXCEPTION 'A project administrator already exists'
|
|
134
|
+
USING ERRCODE = '42501';
|
|
135
|
+
END IF;
|
|
136
|
+
|
|
137
|
+
SELECT p.id
|
|
138
|
+
INTO v_profile_id
|
|
139
|
+
FROM public.profiles p
|
|
140
|
+
WHERE p.user_id = p_user_id
|
|
141
|
+
FOR UPDATE;
|
|
142
|
+
|
|
143
|
+
IF v_profile_id IS NULL THEN
|
|
144
|
+
IF EXISTS (
|
|
145
|
+
SELECT 1
|
|
146
|
+
FROM public.profiles p
|
|
147
|
+
WHERE lower(p.email) = v_email
|
|
148
|
+
) THEN
|
|
149
|
+
RAISE EXCEPTION 'A profile already exists for this email'
|
|
150
|
+
USING ERRCODE = '23505';
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
INSERT INTO public.profiles (user_id, email)
|
|
154
|
+
VALUES (p_user_id, v_email)
|
|
155
|
+
RETURNING id INTO v_profile_id;
|
|
156
|
+
ELSE
|
|
157
|
+
UPDATE public.profiles
|
|
158
|
+
SET email = v_email,
|
|
159
|
+
updated_at = now()
|
|
160
|
+
WHERE id = v_profile_id;
|
|
161
|
+
END IF;
|
|
162
|
+
|
|
163
|
+
SELECT ura.role_code
|
|
164
|
+
INTO v_previous_role_code
|
|
165
|
+
FROM public.user_role_assignments ura
|
|
166
|
+
WHERE ura.profile_id = v_profile_id
|
|
167
|
+
FOR UPDATE;
|
|
168
|
+
|
|
169
|
+
INSERT INTO public.user_role_assignments (
|
|
170
|
+
profile_id,
|
|
171
|
+
role_code,
|
|
172
|
+
assigned_by_profile_id,
|
|
173
|
+
assigned_at,
|
|
174
|
+
reason
|
|
175
|
+
)
|
|
176
|
+
VALUES (
|
|
177
|
+
v_profile_id,
|
|
178
|
+
'admin',
|
|
179
|
+
NULL,
|
|
180
|
+
now(),
|
|
181
|
+
'bw_admin_create_bootstrap'
|
|
182
|
+
)
|
|
183
|
+
ON CONFLICT ON CONSTRAINT user_role_assignments_profile_id_key
|
|
184
|
+
DO UPDATE SET
|
|
185
|
+
role_code = EXCLUDED.role_code,
|
|
186
|
+
assigned_by_profile_id = NULL,
|
|
187
|
+
assigned_at = EXCLUDED.assigned_at,
|
|
188
|
+
reason = EXCLUDED.reason;
|
|
189
|
+
|
|
190
|
+
RETURN QUERY SELECT v_profile_id, v_previous_role_code;
|
|
191
|
+
END;
|
|
192
|
+
$$;
|
|
193
|
+
|
|
194
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM PUBLIC;
|
|
195
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM anon;
|
|
196
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM authenticated;
|
|
197
|
+
GRANT EXECUTE ON FUNCTION public.bootstrap_first_admin(uuid, text) TO service_role;
|
|
198
|
+
|
|
199
|
+
-- A new package must not silently call pre-repair module RPCs.
|
|
200
|
+
CREATE OR REPLACE FUNCTION public.assert_admin_auth_identity_v1()
|
|
201
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
202
|
+
DECLARE
|
|
203
|
+
expected record;
|
|
204
|
+
actual pg_catalog.pg_proc%ROWTYPE;
|
|
205
|
+
BEGIN
|
|
206
|
+
PERFORM public.assert_profile_auth_identity_v1();
|
|
207
|
+
FOR expected IN SELECT * FROM (VALUES
|
|
208
|
+
('public.accept_admin_user_invitation(uuid,uuid,text)','9a188c42971bb420261b1d61be4cf188',false,'jsonb',true),
|
|
209
|
+
('public.bootstrap_first_admin(uuid,text)','7492c436da8e3e1721bf2b8a07149f59',true,'record',true)
|
|
210
|
+
) AS contracts(signature, body_hash, definer, result_type, service_callable)
|
|
211
|
+
LOOP
|
|
212
|
+
SELECT * INTO actual FROM pg_catalog.pg_proc WHERE oid = to_regprocedure(expected.signature);
|
|
213
|
+
IF NOT FOUND OR md5(actual.prosrc) IS DISTINCT FROM expected.body_hash
|
|
214
|
+
OR actual.prosecdef IS DISTINCT FROM expected.definer
|
|
215
|
+
OR actual.prorettype IS DISTINCT FROM to_regtype(expected.result_type)
|
|
216
|
+
OR actual.proconfig IS DISTINCT FROM ARRAY['search_path=""']::text[]
|
|
217
|
+
OR pg_catalog.pg_get_userbyid(actual.proowner) IN ('anon', 'authenticated')
|
|
218
|
+
OR has_function_privilege('anon', actual.oid, 'EXECUTE')
|
|
219
|
+
OR has_function_privilege('authenticated', actual.oid, 'EXECUTE')
|
|
220
|
+
OR (expected.service_callable AND NOT has_function_privilege('service_role', actual.oid, 'EXECUTE')) THEN
|
|
221
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply complete Core profile_auth_identity_v1 (%).', expected.signature USING ERRCODE = '55000';
|
|
222
|
+
END IF;
|
|
223
|
+
END LOOP;
|
|
224
|
+
END;
|
|
225
|
+
$$;
|
|
226
|
+
REVOKE ALL ON FUNCTION public.assert_admin_auth_identity_v1() FROM PUBLIC, anon, authenticated;
|
|
227
|
+
GRANT EXECUTE ON FUNCTION public.assert_admin_auth_identity_v1() TO service_role;
|
|
228
|
+
|
|
229
|
+
$identity_ddl$;
|
|
230
|
+
PERFORM public.assert_admin_auth_identity_v1();
|
|
231
|
+
END;
|
|
232
|
+
$identity_migration$;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
-- protect_profile_identity
|
|
2
|
+
-- target: core
|
|
3
|
+
-- created_at: 2026-10-14T18:31:38.339Z
|
|
4
|
+
|
|
5
|
+
-- One atomic statement protects old CLI/manual continue-on-error application:
|
|
6
|
+
-- failed prerequisites or later DDL leave every existing guard/function intact.
|
|
7
|
+
DO $identity_migration$
|
|
8
|
+
BEGIN
|
|
9
|
+
EXECUTE $identity_ddl$
|
|
10
|
+
-- Browser UPDATE already owns a profile lock. Reject identity changes without
|
|
11
|
+
-- touching Auth, so an Auth email change (Auth -> profile) cannot deadlock it.
|
|
12
|
+
-- This guard is INVOKER: nested trusted Auth sync retains its effective owner;
|
|
13
|
+
-- ordinary browser roles never receive that authority via a definer guard.
|
|
14
|
+
CREATE OR REPLACE FUNCTION public.guard_profile_identity_update()
|
|
15
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
16
|
+
BEGIN
|
|
17
|
+
IF current_user IN ('anon', 'authenticated') AND (
|
|
18
|
+
NEW.email IS DISTINCT FROM OLD.email OR
|
|
19
|
+
NEW.user_id IS DISTINCT FROM OLD.user_id OR
|
|
20
|
+
NEW.created_at IS DISTINCT FROM OLD.created_at
|
|
21
|
+
) THEN
|
|
22
|
+
RAISE EXCEPTION 'Profile identity is managed by Auth.' USING ERRCODE = '42501';
|
|
23
|
+
END IF;
|
|
24
|
+
RETURN NEW;
|
|
25
|
+
END;
|
|
26
|
+
$$;
|
|
27
|
+
REVOKE ALL ON FUNCTION public.guard_profile_identity_update() FROM PUBLIC, anon, authenticated;
|
|
28
|
+
CREATE OR REPLACE TRIGGER guard_profile_identity_update BEFORE UPDATE ON public.profiles
|
|
29
|
+
FOR EACH ROW EXECUTE FUNCTION public.guard_profile_identity_update();
|
|
30
|
+
|
|
31
|
+
-- BEFORE INSERT runs before uniqueness/conflict locks, including on an upsert.
|
|
32
|
+
-- SHARE conflicts with Auth email UPDATE; KEY SHARE would not protect email.
|
|
33
|
+
-- Existing account upserts omit created_at, so preserve its original value.
|
|
34
|
+
CREATE OR REPLACE FUNCTION public.guard_profile_identity_insert()
|
|
35
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
36
|
+
DECLARE
|
|
37
|
+
v_email text;
|
|
38
|
+
v_created_at timestamptz;
|
|
39
|
+
BEGIN
|
|
40
|
+
IF current_setting('role', true) IN ('anon', 'authenticated') AND
|
|
41
|
+
(NEW.user_id IS NULL OR NEW.user_id IS DISTINCT FROM auth.uid()) THEN
|
|
42
|
+
RAISE EXCEPTION 'Profile identity is managed by Auth.' USING ERRCODE = '42501';
|
|
43
|
+
END IF;
|
|
44
|
+
IF NEW.user_id IS NOT NULL THEN
|
|
45
|
+
SELECT lower(btrim(email)) INTO v_email FROM auth.users
|
|
46
|
+
WHERE id = NEW.user_id FOR SHARE;
|
|
47
|
+
IF NOT FOUND OR v_email IS NULL OR v_email = '' OR
|
|
48
|
+
lower(btrim(NEW.email)) IS DISTINCT FROM v_email THEN
|
|
49
|
+
RAISE EXCEPTION 'Profile identity is managed by Auth.' USING ERRCODE = '42501';
|
|
50
|
+
END IF;
|
|
51
|
+
NEW.email := v_email;
|
|
52
|
+
END IF;
|
|
53
|
+
SELECT created_at INTO v_created_at FROM public.profiles WHERE user_id = NEW.user_id;
|
|
54
|
+
IF current_setting('role', true) IN ('anon', 'authenticated') THEN
|
|
55
|
+
IF NEW.created_at IS DISTINCT FROM now() AND
|
|
56
|
+
NEW.created_at IS DISTINCT FROM v_created_at THEN
|
|
57
|
+
RAISE EXCEPTION 'Profile creation timestamp is managed by the database.' USING ERRCODE = '42501';
|
|
58
|
+
END IF;
|
|
59
|
+
NEW.created_at := COALESCE(v_created_at, now());
|
|
60
|
+
END IF;
|
|
61
|
+
RETURN NEW;
|
|
62
|
+
END;
|
|
63
|
+
$$;
|
|
64
|
+
REVOKE ALL ON FUNCTION public.guard_profile_identity_insert() FROM PUBLIC, anon, authenticated;
|
|
65
|
+
CREATE OR REPLACE TRIGGER guard_profile_identity_insert BEFORE INSERT ON public.profiles
|
|
66
|
+
FOR EACH ROW EXECUTE FUNCTION public.guard_profile_identity_insert();
|
|
67
|
+
|
|
68
|
+
CREATE OR REPLACE FUNCTION public.sync_profile_from_auth_identity(
|
|
69
|
+
p_user_id uuid,
|
|
70
|
+
p_email text,
|
|
71
|
+
p_metadata jsonb DEFAULT '{}'::jsonb
|
|
72
|
+
)
|
|
73
|
+
RETURNS void
|
|
74
|
+
LANGUAGE plpgsql
|
|
75
|
+
SECURITY DEFINER
|
|
76
|
+
SET search_path = ''
|
|
77
|
+
AS $$
|
|
78
|
+
DECLARE
|
|
79
|
+
v_email text;
|
|
80
|
+
v_first_name text;
|
|
81
|
+
v_last_name text;
|
|
82
|
+
v_full_name text;
|
|
83
|
+
v_name_parts text[];
|
|
84
|
+
BEGIN
|
|
85
|
+
-- Keep the legacy argument for callers, but only current Auth state is authority.
|
|
86
|
+
-- Lock Auth before the upsert takes any profile locks; stale registration input
|
|
87
|
+
-- must never restore an email which Auth has already changed.
|
|
88
|
+
SELECT NULLIF(lower(btrim(u.email)), '') INTO v_email
|
|
89
|
+
FROM auth.users u WHERE u.id = p_user_id FOR NO KEY UPDATE;
|
|
90
|
+
IF NOT FOUND THEN
|
|
91
|
+
RAISE EXCEPTION 'Auth identity not found.' USING ERRCODE = '23503';
|
|
92
|
+
END IF;
|
|
93
|
+
|
|
94
|
+
IF v_email IS NULL THEN
|
|
95
|
+
RETURN;
|
|
96
|
+
END IF;
|
|
97
|
+
|
|
98
|
+
v_first_name := NULLIF(trim(COALESCE(p_metadata ->> 'first_name', '')), '');
|
|
99
|
+
v_last_name := NULLIF(trim(COALESCE(p_metadata ->> 'last_name', '')), '');
|
|
100
|
+
v_full_name := NULLIF(
|
|
101
|
+
trim(
|
|
102
|
+
COALESCE(
|
|
103
|
+
p_metadata ->> 'full_name',
|
|
104
|
+
p_metadata ->> 'name',
|
|
105
|
+
p_metadata ->> 'display_name',
|
|
106
|
+
''
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
''
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
IF v_first_name IS NULL AND v_full_name IS NOT NULL THEN
|
|
113
|
+
v_name_parts := regexp_split_to_array(v_full_name, '\s+');
|
|
114
|
+
IF array_length(v_name_parts, 1) >= 1 THEN
|
|
115
|
+
v_first_name := v_name_parts[1];
|
|
116
|
+
END IF;
|
|
117
|
+
IF array_length(v_name_parts, 1) >= 2 THEN
|
|
118
|
+
v_last_name := array_to_string(v_name_parts[2:array_length(v_name_parts, 1)], ' ');
|
|
119
|
+
END IF;
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
INSERT INTO public.profiles (
|
|
123
|
+
user_id,
|
|
124
|
+
email,
|
|
125
|
+
first_name,
|
|
126
|
+
last_name,
|
|
127
|
+
created_at,
|
|
128
|
+
updated_at
|
|
129
|
+
)
|
|
130
|
+
VALUES (
|
|
131
|
+
p_user_id,
|
|
132
|
+
v_email,
|
|
133
|
+
v_first_name,
|
|
134
|
+
v_last_name,
|
|
135
|
+
now(),
|
|
136
|
+
now()
|
|
137
|
+
)
|
|
138
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
139
|
+
SET
|
|
140
|
+
email = EXCLUDED.email,
|
|
141
|
+
first_name = COALESCE(NULLIF(trim(public.profiles.first_name), ''), EXCLUDED.first_name),
|
|
142
|
+
last_name = COALESCE(NULLIF(trim(public.profiles.last_name), ''), EXCLUDED.last_name),
|
|
143
|
+
updated_at = now();
|
|
144
|
+
END;
|
|
145
|
+
$$;
|
|
146
|
+
|
|
147
|
+
REVOKE ALL ON FUNCTION public.sync_profile_from_auth_identity(uuid,text,jsonb) FROM PUBLIC,anon,authenticated;
|
|
148
|
+
GRANT EXECUTE ON FUNCTION public.sync_profile_from_auth_identity(uuid,text,jsonb) TO service_role;
|
|
149
|
+
|
|
150
|
+
-- Narrow server boundary: no auth table grants or browser email lookup.
|
|
151
|
+
-- More than one normalized identity is an error, never a silent legacy relink.
|
|
152
|
+
CREATE OR REPLACE FUNCTION public.resolve_auth_identity(p_email text)
|
|
153
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
154
|
+
DECLARE
|
|
155
|
+
v_user_id uuid;
|
|
156
|
+
v_profile_id uuid;
|
|
157
|
+
v_identities jsonb;
|
|
158
|
+
v_confirmed boolean;
|
|
159
|
+
BEGIN
|
|
160
|
+
SELECT jsonb_agg(jsonb_build_object('userId', id,
|
|
161
|
+
'confirmed', email_confirmed_at IS NOT NULL)) INTO v_identities
|
|
162
|
+
FROM auth.users WHERE lower(btrim(email)) = lower(btrim(p_email));
|
|
163
|
+
IF v_identities IS NULL THEN
|
|
164
|
+
IF EXISTS (SELECT 1 FROM public.profiles WHERE lower(btrim(email)) = lower(btrim(p_email))) THEN
|
|
165
|
+
RAISE EXCEPTION 'IDENTITY_RECONCILIATION_REQUIRED: existing profile has no matching Auth identity.' USING ERRCODE = 'BW002';
|
|
166
|
+
END IF;
|
|
167
|
+
RETURN NULL;
|
|
168
|
+
END IF;
|
|
169
|
+
IF jsonb_array_length(v_identities) <> 1 THEN
|
|
170
|
+
RAISE EXCEPTION 'Ambiguous Auth identity; reconcile before granting access.' USING ERRCODE = '21000';
|
|
171
|
+
END IF;
|
|
172
|
+
v_user_id := (v_identities -> 0 ->> 'userId')::uuid;
|
|
173
|
+
v_confirmed := (v_identities -> 0 ->> 'confirmed')::boolean;
|
|
174
|
+
IF EXISTS (SELECT 1 FROM public.profiles
|
|
175
|
+
WHERE (user_id = v_user_id AND lower(btrim(email)) IS DISTINCT FROM lower(btrim(p_email)))
|
|
176
|
+
OR (lower(btrim(email)) = lower(btrim(p_email)) AND user_id IS DISTINCT FROM v_user_id)) THEN
|
|
177
|
+
RAISE EXCEPTION 'IDENTITY_RECONCILIATION_REQUIRED: profile and current Auth identity disagree.' USING ERRCODE = 'BW002';
|
|
178
|
+
END IF;
|
|
179
|
+
SELECT id INTO v_profile_id FROM public.profiles
|
|
180
|
+
WHERE user_id = v_user_id AND v_confirmed AND lower(btrim(email)) = lower(btrim(p_email));
|
|
181
|
+
RETURN jsonb_build_object('userId', v_user_id, 'profileId', v_profile_id,
|
|
182
|
+
'email', lower(btrim(p_email)), 'confirmed', v_confirmed);
|
|
183
|
+
END;
|
|
184
|
+
$$;
|
|
185
|
+
REVOKE ALL ON FUNCTION public.resolve_auth_identity(text) FROM PUBLIC,anon,authenticated;
|
|
186
|
+
GRANT EXECUTE ON FUNCTION public.resolve_auth_identity(text) TO service_role;
|
|
187
|
+
|
|
188
|
+
-- The profile read is only a candidate snapshot. Callers must recheck user_id
|
|
189
|
+
-- under their later profile lock. This function must precede invitation locks.
|
|
190
|
+
CREATE OR REPLACE FUNCTION public.lock_confirmed_profile_auth_identity(p_profile_id uuid, p_email text)
|
|
191
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
192
|
+
DECLARE
|
|
193
|
+
v_user_id uuid;
|
|
194
|
+
v_email text;
|
|
195
|
+
v_confirmed_at timestamptz;
|
|
196
|
+
BEGIN
|
|
197
|
+
SELECT user_id INTO v_user_id FROM public.profiles WHERE id = p_profile_id;
|
|
198
|
+
IF v_user_id IS NULL THEN
|
|
199
|
+
RAISE EXCEPTION 'O perfil não corresponde ao email confirmado; este convite pertence a outro email.' USING ERRCODE = 'BW002';
|
|
200
|
+
END IF;
|
|
201
|
+
SELECT email, email_confirmed_at INTO v_email, v_confirmed_at
|
|
202
|
+
FROM auth.users WHERE id = v_user_id FOR NO KEY UPDATE;
|
|
203
|
+
IF NOT FOUND OR v_confirmed_at IS NULL OR p_email IS NULL OR btrim(p_email) = '' OR
|
|
204
|
+
lower(btrim(v_email)) IS DISTINCT FROM lower(btrim(p_email)) THEN
|
|
205
|
+
RAISE EXCEPTION 'O perfil não corresponde ao email confirmado; este convite pertence a outro email.';
|
|
206
|
+
END IF;
|
|
207
|
+
IF (SELECT count(*) FROM auth.users WHERE lower(btrim(email)) = lower(btrim(p_email))) <> 1 THEN
|
|
208
|
+
RAISE EXCEPTION 'Ambiguous Auth identity; reconcile before granting access.' USING ERRCODE = '21000';
|
|
209
|
+
END IF;
|
|
210
|
+
RETURN v_user_id;
|
|
211
|
+
END;
|
|
212
|
+
$$;
|
|
213
|
+
REVOKE ALL ON FUNCTION public.lock_confirmed_profile_auth_identity(uuid,text) FROM PUBLIC,anon,authenticated;
|
|
214
|
+
GRANT EXECUTE ON FUNCTION public.lock_confirmed_profile_auth_identity(uuid,text) TO service_role;
|
|
215
|
+
|
|
216
|
+
-- Installed last: dependents validate both complete installation and subsequent
|
|
217
|
+
-- catalog drift. The whole migration is one statement, including this check.
|
|
218
|
+
CREATE OR REPLACE FUNCTION public.assert_profile_auth_identity_v1()
|
|
219
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
220
|
+
DECLARE
|
|
221
|
+
expected record;
|
|
222
|
+
actual pg_catalog.pg_proc%ROWTYPE;
|
|
223
|
+
BEGIN
|
|
224
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_attribute
|
|
225
|
+
WHERE attrelid = 'auth.users'::regclass AND attname = 'email_confirmed_at' AND NOT attisdropped) THEN
|
|
226
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: Core Auth confirmation schema is missing.' USING ERRCODE = '55000';
|
|
227
|
+
END IF;
|
|
228
|
+
FOR expected IN SELECT * FROM (VALUES
|
|
229
|
+
('public.guard_profile_identity_update()','bf0274d7fd4c164296db7363b1678989',false,'trigger',false),
|
|
230
|
+
('public.guard_profile_identity_insert()','14dc0765f2ab4d343c51a76f29c71909',true,'trigger',false),
|
|
231
|
+
('public.sync_profile_from_auth_identity(uuid,text,jsonb)','c94aa40d153e255d8108c43c4c5ad21e',true,'void',true),
|
|
232
|
+
('public.resolve_auth_identity(text)','60cb0cf2f919d738b46bde4fad4fcd61',true,'jsonb',true),
|
|
233
|
+
('public.lock_confirmed_profile_auth_identity(uuid,text)','a14ba875979007cb0a2d6f9cdda57b19',true,'uuid',true)
|
|
234
|
+
) AS contracts(signature, body_hash, definer, result_type, service_callable)
|
|
235
|
+
LOOP
|
|
236
|
+
SELECT * INTO actual FROM pg_catalog.pg_proc WHERE oid = to_regprocedure(expected.signature);
|
|
237
|
+
IF NOT FOUND OR md5(actual.prosrc) IS DISTINCT FROM expected.body_hash
|
|
238
|
+
OR actual.prosecdef IS DISTINCT FROM expected.definer
|
|
239
|
+
OR actual.prorettype IS DISTINCT FROM to_regtype(expected.result_type)
|
|
240
|
+
OR actual.proconfig IS DISTINCT FROM ARRAY['search_path=""']::text[]
|
|
241
|
+
OR pg_catalog.pg_get_userbyid(actual.proowner) IN ('anon', 'authenticated')
|
|
242
|
+
OR has_function_privilege('anon', actual.oid, 'EXECUTE')
|
|
243
|
+
OR has_function_privilege('authenticated', actual.oid, 'EXECUTE')
|
|
244
|
+
OR (expected.service_callable AND NOT has_function_privilege('service_role', actual.oid, 'EXECUTE')) THEN
|
|
245
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply complete Core profile_auth_identity_v1 (%).', expected.signature USING ERRCODE = '55000';
|
|
246
|
+
END IF;
|
|
247
|
+
END LOOP;
|
|
248
|
+
FOR expected IN SELECT * FROM (VALUES
|
|
249
|
+
('guard_profile_identity_insert', 7), ('guard_profile_identity_update', 19)
|
|
250
|
+
) AS guards(name, kind)
|
|
251
|
+
LOOP
|
|
252
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_trigger
|
|
253
|
+
WHERE tgrelid = 'public.profiles'::regclass AND tgname = expected.name
|
|
254
|
+
AND tgfoid = to_regprocedure('public.' || expected.name || '()')
|
|
255
|
+
AND tgtype = expected.kind AND tgenabled IN ('O', 'A')
|
|
256
|
+
AND NOT tgisinternal AND tgqual IS NULL AND tgnargs = 0 AND tgattr = ''::int2vector) THEN
|
|
257
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: Core identity guard % must be enabled.', expected.name USING ERRCODE = '55000';
|
|
258
|
+
END IF;
|
|
259
|
+
END LOOP;
|
|
260
|
+
END;
|
|
261
|
+
$$;
|
|
262
|
+
REVOKE ALL ON FUNCTION public.assert_profile_auth_identity_v1() FROM PUBLIC, anon, authenticated;
|
|
263
|
+
GRANT EXECUTE ON FUNCTION public.assert_profile_auth_identity_v1() TO service_role;
|
|
264
|
+
|
|
265
|
+
$identity_ddl$;
|
|
266
|
+
PERFORM public.assert_profile_auth_identity_v1();
|
|
267
|
+
-- Retire the superseded Admin guard only after Core protection is proven.
|
|
268
|
+
-- Core must also support scoped Orgs upgrades without advancing Admin SQL.
|
|
269
|
+
DROP TRIGGER IF EXISTS guard_privileged_profile_fields ON public.profiles;
|
|
270
|
+
DROP FUNCTION IF EXISTS public.guard_privileged_profile_fields();
|
|
271
|
+
END;
|
|
272
|
+
$identity_migration$;
|