@opengeni/db 3.2.0-canary.0 → 3.4.0-canary.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/canonical-human-identities.js +3 -3
- package/dist/{chunk-SIBYBA2D.js → chunk-6DPJA36C.js} +30 -11
- package/dist/chunk-6DPJA36C.js.map +1 -0
- package/dist/{chunk-KRLPEMLH.js → chunk-C2AFP7SB.js} +184 -6
- package/dist/chunk-C2AFP7SB.js.map +1 -0
- package/dist/{chunk-AZHW7R5V.js → chunk-H7MXYG7R.js} +12 -8
- package/dist/chunk-H7MXYG7R.js.map +1 -0
- package/dist/{chunk-2PRS4J5Q.js → chunk-HY3F5Q4E.js} +3 -3
- package/dist/{chunk-NAB6FKFN.js → chunk-O6FTELKH.js} +3 -3
- package/dist/{chunk-UZNXG44K.js → chunk-PYMY3AJW.js} +324 -8
- package/dist/chunk-PYMY3AJW.js.map +1 -0
- package/dist/{chunk-6HMM2R5T.js → chunk-TRBOWUG3.js} +98 -4
- package/dist/chunk-TRBOWUG3.js.map +1 -0
- package/dist/{chunk-E6CLMKEE.js → chunk-V3TRUXFJ.js} +2 -2
- package/dist/{chunk-2EH4WRHL.js → chunk-ZWRIPCTR.js} +2 -2
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/governed-learning-activation.d.ts +16 -0
- package/dist/index.d.ts +108 -7
- package/dist/index.js +3605 -1417
- package/dist/index.js.map +1 -1
- package/dist/insights-model-bundle.d.ts +30 -0
- package/dist/insights-usage-bundle.d.ts +32 -0
- package/dist/insights.d.ts +10 -2
- package/dist/lossless-json.d.ts +1 -1
- package/dist/managed-user-setup.d.ts +82 -0
- package/dist/memory-domain.d.ts +2 -2
- package/dist/migrate.d.ts +3 -1
- package/dist/migrate.js +1 -1
- package/dist/new-session-drafts.d.ts +4 -4
- package/dist/organization-membership-lifecycle.d.ts +60 -1
- package/dist/pr-review.d.ts +44 -3
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +12 -7
- package/dist/schema.d.ts +839 -101
- package/dist/schema.js +9 -1
- package/dist/session-background-commands.js +3 -3
- package/dist/session-tenancy.d.ts +12 -1
- package/dist/session-tenancy.js +3 -3
- package/dist/video-generation.js +3 -3
- package/drizzle/0348_named_signup_and_user_setup.sql +838 -0
- package/drizzle/0349_greenfield_session_tenancy_activation.sql +792 -0
- package/drizzle/0350_organization_shared_workspace_administration.sql +889 -0
- package/drizzle/0351_organization_user_setup_delivery.sql +978 -0
- package/drizzle/0352_session_variable_set_attachments.sql +1661 -0
- package/drizzle/0353_automatic_session_title_policy_fence.sql +476 -0
- package/drizzle/0354_automatic_session_title_quarantine_index.sql +9 -0
- package/drizzle/0355_automatic_session_title_quarantine.sql +92 -0
- package/drizzle/0356_set_based_insights_session_visibility.sql +237 -0
- package/drizzle/0357_rig_platform_base_only.sql +44 -0
- package/drizzle/0358_pr_review_managed_github_app.sql +108 -0
- package/drizzle/0359_insights_force_rls_read_capability.sql +936 -0
- package/drizzle/0360_organization_identity_confirmation_prompt.sql +103 -0
- package/drizzle/0361_remember_knowledge_memory_materialization.sql +249 -0
- package/package.json +6 -6
- package/src/database.ts +12 -8
- package/src/governed-learning-activation.ts +74 -2
- package/src/index.ts +879 -92
- package/src/insights-model-bundle.ts +681 -0
- package/src/insights-usage-bundle.ts +240 -0
- package/src/insights.ts +265 -152
- package/src/lossless-json.ts +2 -1
- package/src/managed-user-setup.ts +316 -0
- package/src/memory-domain.ts +3 -3
- package/src/migrate.ts +38 -14
- package/src/new-session-drafts.ts +22 -15
- package/src/organization-membership-lifecycle.ts +253 -1
- package/src/pr-review.ts +449 -6
- package/src/provision-roles.ts +92 -0
- package/src/runtime-posture.ts +247 -4
- package/src/schema.ts +210 -4
- package/src/session-tenancy.ts +120 -2
- package/dist/chunk-6HMM2R5T.js.map +0 -1
- package/dist/chunk-AZHW7R5V.js.map +0 -1
- package/dist/chunk-KRLPEMLH.js.map +0 -1
- package/dist/chunk-SIBYBA2D.js.map +0 -1
- package/dist/chunk-UZNXG44K.js.map +0 -1
- /package/dist/{chunk-2PRS4J5Q.js.map → chunk-HY3F5Q4E.js.map} +0 -0
- /package/dist/{chunk-NAB6FKFN.js.map → chunk-O6FTELKH.js.map} +0 -0
- /package/dist/{chunk-E6CLMKEE.js.map → chunk-V3TRUXFJ.js.map} +0 -0
- /package/dist/{chunk-2EH4WRHL.js.map → chunk-ZWRIPCTR.js.map} +0 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
-- deployment-mode: maintenance
|
|
2
|
+
-- Post-sign-in self-service organization onboarding and invitation-bound
|
|
3
|
+
-- account setup. Ordinary Better Auth signup remains an ordinary user create;
|
|
4
|
+
-- a separate authenticated lifecycle creates exactly one active organization
|
|
5
|
+
-- membership and its canonical personal workspace. Invitation setup remains a
|
|
6
|
+
-- separate signed-out, hashed-bearer transaction.
|
|
7
|
+
-- This is a one-way application protocol cutover: stop every API, control
|
|
8
|
+
-- worker, and turn worker before applying it, and never restart a pre-0348
|
|
9
|
+
-- image. Old managed-access writers can still synthesize a fallback
|
|
10
|
+
-- organization and Default workspace, while old clients do not speak the
|
|
11
|
+
-- Personal-only setup contract.
|
|
12
|
+
|
|
13
|
+
SET LOCAL lock_timeout = '5s';
|
|
14
|
+
SET LOCAL statement_timeout = '5min';
|
|
15
|
+
|
|
16
|
+
DO $named_signup_writer_drain_before_lock$
|
|
17
|
+
DECLARE
|
|
18
|
+
configured_roles_text text := nullif(
|
|
19
|
+
current_setting('opengeni.migration_application_roles', true), ''
|
|
20
|
+
);
|
|
21
|
+
configured_roles jsonb;
|
|
22
|
+
BEGIN
|
|
23
|
+
IF configured_roles_text IS NULL THEN
|
|
24
|
+
RAISE EXCEPTION
|
|
25
|
+
'0348 named signup activation requires an explicit application database role list'
|
|
26
|
+
USING ERRCODE = '55000';
|
|
27
|
+
END IF;
|
|
28
|
+
BEGIN
|
|
29
|
+
configured_roles := configured_roles_text::jsonb;
|
|
30
|
+
EXCEPTION WHEN OTHERS THEN
|
|
31
|
+
RAISE EXCEPTION
|
|
32
|
+
'0348 named signup activation received a malformed application database role list'
|
|
33
|
+
USING ERRCODE = '55000';
|
|
34
|
+
END;
|
|
35
|
+
IF jsonb_typeof(configured_roles) <> 'array'
|
|
36
|
+
OR jsonb_array_length(configured_roles) NOT BETWEEN 1 AND 16
|
|
37
|
+
OR EXISTS (
|
|
38
|
+
SELECT 1 FROM jsonb_array_elements(configured_roles) AS roles(value)
|
|
39
|
+
WHERE jsonb_typeof(value) <> 'string'
|
|
40
|
+
OR btrim(value #>> '{}') = ''
|
|
41
|
+
OR octet_length(value #>> '{}') > 63
|
|
42
|
+
)
|
|
43
|
+
OR (
|
|
44
|
+
SELECT count(*) FROM jsonb_array_elements_text(configured_roles)
|
|
45
|
+
) <> (
|
|
46
|
+
SELECT count(DISTINCT value)
|
|
47
|
+
FROM jsonb_array_elements_text(configured_roles) AS roles(value)
|
|
48
|
+
)
|
|
49
|
+
THEN
|
|
50
|
+
RAISE EXCEPTION
|
|
51
|
+
'0348 named signup activation received an invalid application database role list'
|
|
52
|
+
USING ERRCODE = '55000';
|
|
53
|
+
END IF;
|
|
54
|
+
IF EXISTS (
|
|
55
|
+
SELECT 1 FROM pg_stat_activity activity
|
|
56
|
+
JOIN jsonb_array_elements_text(configured_roles) roles(role_name)
|
|
57
|
+
ON roles.role_name = activity.usename
|
|
58
|
+
WHERE activity.datname = current_database()
|
|
59
|
+
AND activity.pid <> pg_backend_pid()
|
|
60
|
+
)
|
|
61
|
+
THEN
|
|
62
|
+
RAISE EXCEPTION
|
|
63
|
+
'0348 named signup activation requires all configured OpenGeni application database sessions to be stopped'
|
|
64
|
+
USING ERRCODE = '55000';
|
|
65
|
+
END IF;
|
|
66
|
+
END
|
|
67
|
+
$named_signup_writer_drain_before_lock$;
|
|
68
|
+
|
|
69
|
+
LOCK TABLE auth_users IN ACCESS EXCLUSIVE MODE;
|
|
70
|
+
LOCK TABLE auth_identities IN ACCESS EXCLUSIVE MODE;
|
|
71
|
+
LOCK TABLE managed_accounts IN ACCESS EXCLUSIVE MODE;
|
|
72
|
+
LOCK TABLE organization_membership_invitations IN ACCESS EXCLUSIVE MODE;
|
|
73
|
+
LOCK TABLE organization_memberships IN ACCESS EXCLUSIVE MODE;
|
|
74
|
+
LOCK TABLE workspaces IN ACCESS EXCLUSIVE MODE;
|
|
75
|
+
LOCK TABLE workspace_memberships IN ACCESS EXCLUSIVE MODE;
|
|
76
|
+
|
|
77
|
+
DO $named_signup_writer_drain_after_lock$
|
|
78
|
+
DECLARE
|
|
79
|
+
configured_roles jsonb := current_setting(
|
|
80
|
+
'opengeni.migration_application_roles', false
|
|
81
|
+
)::jsonb;
|
|
82
|
+
BEGIN
|
|
83
|
+
IF EXISTS (
|
|
84
|
+
SELECT 1 FROM pg_stat_activity activity
|
|
85
|
+
JOIN jsonb_array_elements_text(configured_roles) roles(role_name)
|
|
86
|
+
ON roles.role_name = activity.usename
|
|
87
|
+
WHERE activity.datname = current_database()
|
|
88
|
+
AND activity.pid <> pg_backend_pid()
|
|
89
|
+
)
|
|
90
|
+
THEN
|
|
91
|
+
RAISE EXCEPTION
|
|
92
|
+
'0348 named signup activation requires all configured OpenGeni application database sessions to be stopped'
|
|
93
|
+
USING ERRCODE = '55000';
|
|
94
|
+
END IF;
|
|
95
|
+
END
|
|
96
|
+
$named_signup_writer_drain_after_lock$;
|
|
97
|
+
|
|
98
|
+
CREATE TABLE self_service_organization_setup_receipts (
|
|
99
|
+
auth_user_id text PRIMARY KEY REFERENCES auth_users(id) ON DELETE RESTRICT,
|
|
100
|
+
operation_id uuid NOT NULL UNIQUE,
|
|
101
|
+
request_fingerprint text NOT NULL,
|
|
102
|
+
account_id uuid NOT NULL UNIQUE REFERENCES managed_accounts(id) ON DELETE RESTRICT,
|
|
103
|
+
personal_workspace_id uuid NOT NULL UNIQUE REFERENCES workspaces(id) ON DELETE RESTRICT,
|
|
104
|
+
organization_membership_id uuid NOT NULL UNIQUE
|
|
105
|
+
REFERENCES organization_memberships(id) ON DELETE RESTRICT,
|
|
106
|
+
organization_name text NOT NULL,
|
|
107
|
+
result jsonb NOT NULL,
|
|
108
|
+
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
109
|
+
CONSTRAINT self_service_organization_setup_fingerprint_check CHECK (
|
|
110
|
+
request_fingerprint ~ '^[0-9a-f]{64}$'
|
|
111
|
+
),
|
|
112
|
+
CONSTRAINT self_service_organization_setup_name_check CHECK (
|
|
113
|
+
organization_name = btrim(organization_name)
|
|
114
|
+
AND octet_length(convert_to(organization_name, 'UTF8')) BETWEEN 1 AND 120
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
ALTER TABLE self_service_organization_setup_receipts ENABLE ROW LEVEL SECURITY;
|
|
118
|
+
ALTER TABLE self_service_organization_setup_receipts FORCE ROW LEVEL SECURITY;
|
|
119
|
+
CREATE POLICY organization_tenancy_lifecycle
|
|
120
|
+
ON self_service_organization_setup_receipts
|
|
121
|
+
USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
|
|
122
|
+
= 'organization_membership_lifecycle')
|
|
123
|
+
WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
|
|
124
|
+
= 'organization_membership_lifecycle');
|
|
125
|
+
|
|
126
|
+
CREATE FUNCTION complete_self_service_organization_setup(p_command jsonb)
|
|
127
|
+
RETURNS jsonb
|
|
128
|
+
LANGUAGE plpgsql
|
|
129
|
+
SECURITY DEFINER
|
|
130
|
+
SET search_path FROM CURRENT
|
|
131
|
+
AS $body$
|
|
132
|
+
DECLARE
|
|
133
|
+
auth_user_id_value text := p_command ->> 'authUserId';
|
|
134
|
+
actor_subject text := p_command ->> 'actorSubjectId';
|
|
135
|
+
organization_name_value text := nullif(btrim(p_command ->> 'organizationName'), '');
|
|
136
|
+
operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
|
|
137
|
+
fingerprint_value text := p_command ->> 'requestFingerprint';
|
|
138
|
+
auth_user auth_users%ROWTYPE;
|
|
139
|
+
account_row managed_accounts%ROWTYPE;
|
|
140
|
+
existing_receipt self_service_organization_setup_receipts%ROWTYPE;
|
|
141
|
+
adopted_account managed_accounts%ROWTYPE;
|
|
142
|
+
adopt_existing_account boolean := false;
|
|
143
|
+
account_id_value uuid := gen_random_uuid();
|
|
144
|
+
workspace_id_value uuid := gen_random_uuid();
|
|
145
|
+
membership_id_value uuid;
|
|
146
|
+
existing_membership_count integer;
|
|
147
|
+
public_result jsonb;
|
|
148
|
+
BEGIN
|
|
149
|
+
IF auth_user_id_value IS NULL
|
|
150
|
+
OR octet_length(convert_to(auth_user_id_value, 'UTF8')) NOT BETWEEN 1 AND 1019
|
|
151
|
+
OR actor_subject IS NULL
|
|
152
|
+
OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
|
|
153
|
+
OR actor_subject IS DISTINCT FROM 'user:' || auth_user_id_value
|
|
154
|
+
OR organization_name_value IS NULL
|
|
155
|
+
OR octet_length(convert_to(organization_name_value, 'UTF8')) NOT BETWEEN 1 AND 120
|
|
156
|
+
OR operation_id_value IS NULL
|
|
157
|
+
OR fingerprint_value !~ '^[0-9a-f]{64}$'
|
|
158
|
+
THEN
|
|
159
|
+
RAISE EXCEPTION 'self-service organization setup input is invalid'
|
|
160
|
+
USING ERRCODE = '22023';
|
|
161
|
+
END IF;
|
|
162
|
+
|
|
163
|
+
-- Serialize all setup attempts for this exact Better Auth identity. The
|
|
164
|
+
-- verified-email invitation binder then takes its canonical email fence,
|
|
165
|
+
-- ensuring a pre-existing invitation always wins over organization creation.
|
|
166
|
+
PERFORM pg_catalog.pg_advisory_xact_lock(
|
|
167
|
+
pg_catalog.hashtextextended('self-service-organization:' || auth_user_id_value, 0)
|
|
168
|
+
);
|
|
169
|
+
SELECT * INTO auth_user FROM auth_users candidate
|
|
170
|
+
WHERE candidate.id = auth_user_id_value FOR UPDATE;
|
|
171
|
+
IF NOT FOUND
|
|
172
|
+
OR auth_user.email_verified IS NOT TRUE
|
|
173
|
+
OR lower(btrim(auth_user.email)) = ''
|
|
174
|
+
THEN
|
|
175
|
+
RAISE EXCEPTION 'verified managed user required' USING ERRCODE = '42501';
|
|
176
|
+
END IF;
|
|
177
|
+
|
|
178
|
+
PERFORM pg_catalog.set_config(
|
|
179
|
+
'opengeni.organization_tenancy_lifecycle',
|
|
180
|
+
'organization_membership_lifecycle', true
|
|
181
|
+
);
|
|
182
|
+
PERFORM bind_pending_organization_invitations_for_verified_email(
|
|
183
|
+
actor_subject, auth_user.email
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
SELECT * INTO existing_receipt
|
|
187
|
+
FROM self_service_organization_setup_receipts receipt
|
|
188
|
+
WHERE receipt.auth_user_id = auth_user_id_value
|
|
189
|
+
FOR UPDATE;
|
|
190
|
+
IF FOUND THEN
|
|
191
|
+
IF existing_receipt.operation_id IS DISTINCT FROM operation_id_value
|
|
192
|
+
OR existing_receipt.request_fingerprint IS DISTINCT FROM fingerprint_value
|
|
193
|
+
OR existing_receipt.organization_name IS DISTINCT FROM organization_name_value
|
|
194
|
+
THEN
|
|
195
|
+
RAISE EXCEPTION 'self-service organization setup was already completed'
|
|
196
|
+
USING ERRCODE = '23505';
|
|
197
|
+
END IF;
|
|
198
|
+
RETURN existing_receipt.result;
|
|
199
|
+
END IF;
|
|
200
|
+
|
|
201
|
+
SELECT count(*)::integer INTO existing_membership_count
|
|
202
|
+
FROM organization_memberships membership
|
|
203
|
+
WHERE membership.subject_id = actor_subject;
|
|
204
|
+
IF existing_membership_count <> 0
|
|
205
|
+
OR has_pending_organization_invitation_for_subject(actor_subject)
|
|
206
|
+
THEN
|
|
207
|
+
RAISE EXCEPTION 'organization setup is no longer available'
|
|
208
|
+
USING ERRCODE = '55000';
|
|
209
|
+
END IF;
|
|
210
|
+
|
|
211
|
+
-- A pre-0348 image could leave this exact Better Auth user a legacy
|
|
212
|
+
-- `better-auth:user` fallback organization whose organization membership was
|
|
213
|
+
-- never backfilled: migration 0290 only anchored subjects that already held
|
|
214
|
+
-- workspace access. Refusing that shape dead-ends the human permanently -
|
|
215
|
+
-- managed-access convergence no longer self-heals it, and this is a one-way
|
|
216
|
+
-- cutover - so adopt the orphaned account instead of creating a second one.
|
|
217
|
+
-- Adoption keeps the human's existing account identity (and any legacy
|
|
218
|
+
-- workspace grants they already hold inside it) and needs no migration-time
|
|
219
|
+
-- backfill over a FORCE-RLS table. It is only safe while that account carries
|
|
220
|
+
-- NO organization membership at all: one that already has memberships
|
|
221
|
+
-- describes a state this lifecycle must not reinterpret, and granting owner
|
|
222
|
+
-- there would be a privilege event rather than a repair.
|
|
223
|
+
SELECT * INTO adopted_account FROM managed_accounts account
|
|
224
|
+
WHERE account.external_source = 'better-auth:user'
|
|
225
|
+
AND account.external_id = auth_user_id_value;
|
|
226
|
+
IF FOUND THEN
|
|
227
|
+
IF EXISTS (
|
|
228
|
+
SELECT 1 FROM organization_memberships membership
|
|
229
|
+
WHERE membership.account_id = adopted_account.id
|
|
230
|
+
) THEN
|
|
231
|
+
RAISE EXCEPTION 'organization setup is no longer available'
|
|
232
|
+
USING ERRCODE = '55000';
|
|
233
|
+
END IF;
|
|
234
|
+
account_id_value := adopted_account.id;
|
|
235
|
+
adopt_existing_account := true;
|
|
236
|
+
END IF;
|
|
237
|
+
|
|
238
|
+
-- Join the canonical organization -> workspace lock order before acquiring
|
|
239
|
+
-- any workspace state. Never hold managed_accounts FOR UPDATE across this
|
|
240
|
+
-- prefix: an adopted account can still have ordinary workspace writers, and
|
|
241
|
+
-- migration 0299 fences exactly that deadlock, so the adoption rename is
|
|
242
|
+
-- deferred until after the workspace prefix below.
|
|
243
|
+
PERFORM pg_catalog.pg_advisory_xact_lock(
|
|
244
|
+
pg_catalog.hashtextextended('organization-membership:' || account_id_value::text, 0)
|
|
245
|
+
);
|
|
246
|
+
IF NOT adopt_existing_account THEN
|
|
247
|
+
INSERT INTO managed_accounts (id, name, external_source, external_id)
|
|
248
|
+
VALUES (
|
|
249
|
+
account_id_value, organization_name_value,
|
|
250
|
+
'better-auth:user', auth_user_id_value
|
|
251
|
+
) RETURNING * INTO account_row;
|
|
252
|
+
END IF;
|
|
253
|
+
PERFORM pg_catalog.set_config('opengeni.account_id', account_id_value::text, true);
|
|
254
|
+
PERFORM pg_catalog.set_config('opengeni.workspace_id', workspace_id_value::text, true);
|
|
255
|
+
|
|
256
|
+
INSERT INTO workspaces (
|
|
257
|
+
id, account_id, name, slug, external_source, external_id
|
|
258
|
+
) VALUES (
|
|
259
|
+
workspace_id_value, account_id_value, 'Personal workspace', NULL,
|
|
260
|
+
'opengeni:organization-membership', account_id_value::text || ':' || actor_subject
|
|
261
|
+
);
|
|
262
|
+
INSERT INTO workspace_inference_controls (workspace_id, account_id)
|
|
263
|
+
VALUES (workspace_id_value, account_id_value);
|
|
264
|
+
INSERT INTO organization_memberships (
|
|
265
|
+
account_id, subject_id, role, status, personal_workspace_id
|
|
266
|
+
) VALUES (
|
|
267
|
+
account_id_value, actor_subject, 'owner', 'active', workspace_id_value
|
|
268
|
+
) RETURNING id INTO membership_id_value;
|
|
269
|
+
|
|
270
|
+
IF adopt_existing_account THEN
|
|
271
|
+
-- Only now, with every workspace row already acquired, is an organization
|
|
272
|
+
-- row write safe. The one-shot signup name is this human's first and only
|
|
273
|
+
-- chance to name the organization - they never had access to the orphaned
|
|
274
|
+
-- account - so this is initial provisioning, not the managed-access
|
|
275
|
+
-- convergence rename that the access refresh path must never perform.
|
|
276
|
+
UPDATE managed_accounts SET
|
|
277
|
+
name = organization_name_value,
|
|
278
|
+
updated_at = pg_catalog.clock_timestamp()
|
|
279
|
+
WHERE id = account_id_value
|
|
280
|
+
RETURNING * INTO account_row;
|
|
281
|
+
END IF;
|
|
282
|
+
|
|
283
|
+
public_result := pg_catalog.jsonb_build_object(
|
|
284
|
+
'status', 'complete',
|
|
285
|
+
'organizationId', account_id_value,
|
|
286
|
+
'personalWorkspaceId', workspace_id_value,
|
|
287
|
+
'organization', pg_catalog.jsonb_build_object(
|
|
288
|
+
'id', account_row.id,
|
|
289
|
+
'name', account_row.name,
|
|
290
|
+
'createdAt', account_row.created_at,
|
|
291
|
+
'updatedAt', account_row.updated_at
|
|
292
|
+
),
|
|
293
|
+
'workspaceId', workspace_id_value
|
|
294
|
+
);
|
|
295
|
+
INSERT INTO self_service_organization_setup_receipts (
|
|
296
|
+
auth_user_id, operation_id, request_fingerprint, account_id,
|
|
297
|
+
personal_workspace_id, organization_membership_id,
|
|
298
|
+
organization_name, result
|
|
299
|
+
) VALUES (
|
|
300
|
+
auth_user_id_value, operation_id_value, fingerprint_value, account_id_value,
|
|
301
|
+
workspace_id_value, membership_id_value, organization_name_value, public_result
|
|
302
|
+
);
|
|
303
|
+
RETURN public_result;
|
|
304
|
+
END
|
|
305
|
+
$body$;
|
|
306
|
+
|
|
307
|
+
-- Preserve the API/SDK signature introduced by 0331 while forwarding every
|
|
308
|
+
-- rolling caller through the corrected one-time setup lifecycle above. The
|
|
309
|
+
-- legacy subject label is deliberately non-authoritative.
|
|
310
|
+
CREATE OR REPLACE FUNCTION create_managed_organization(
|
|
311
|
+
p_subject_id text,
|
|
312
|
+
p_subject_label text,
|
|
313
|
+
p_name text,
|
|
314
|
+
p_operation_id uuid
|
|
315
|
+
) RETURNS jsonb
|
|
316
|
+
LANGUAGE plpgsql
|
|
317
|
+
SECURITY DEFINER
|
|
318
|
+
SET search_path FROM CURRENT
|
|
319
|
+
AS $body$
|
|
320
|
+
DECLARE
|
|
321
|
+
auth_user_id_value text := substring(p_subject_id from length('user:') + 1);
|
|
322
|
+
requested_name text := nullif(pg_catalog.btrim(p_name), '');
|
|
323
|
+
fingerprint_value text;
|
|
324
|
+
BEGIN
|
|
325
|
+
IF p_subject_id IS NULL
|
|
326
|
+
OR p_subject_id NOT LIKE 'user:%'
|
|
327
|
+
OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
|
|
328
|
+
OR requested_name IS NULL
|
|
329
|
+
OR pg_catalog.octet_length(pg_catalog.convert_to(requested_name, 'UTF8')) NOT BETWEEN 1 AND 120
|
|
330
|
+
OR p_operation_id IS NULL
|
|
331
|
+
THEN
|
|
332
|
+
RAISE EXCEPTION 'managed organization creation input is invalid'
|
|
333
|
+
USING ERRCODE = '22023';
|
|
334
|
+
END IF;
|
|
335
|
+
|
|
336
|
+
fingerprint_value := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
|
|
337
|
+
'opengeni:self-service-organization:v1:' || p_subject_id || ':' ||
|
|
338
|
+
pg_catalog.octet_length(pg_catalog.convert_to(requested_name, 'UTF8'))::text || ':' ||
|
|
339
|
+
requested_name,
|
|
340
|
+
'UTF8'
|
|
341
|
+
)), 'hex');
|
|
342
|
+
|
|
343
|
+
RETURN complete_self_service_organization_setup(pg_catalog.jsonb_build_object(
|
|
344
|
+
'authUserId', auth_user_id_value,
|
|
345
|
+
'actorSubjectId', p_subject_id,
|
|
346
|
+
'organizationName', requested_name,
|
|
347
|
+
'operationId', p_operation_id,
|
|
348
|
+
'requestFingerprint', fingerprint_value
|
|
349
|
+
));
|
|
350
|
+
END
|
|
351
|
+
$body$;
|
|
352
|
+
|
|
353
|
+
CREATE TABLE organization_user_setup_intents (
|
|
354
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
355
|
+
account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
|
|
356
|
+
invitation_id uuid NOT NULL,
|
|
357
|
+
token_digest text NOT NULL,
|
|
358
|
+
expires_at timestamptz NOT NULL,
|
|
359
|
+
status text NOT NULL DEFAULT 'pending',
|
|
360
|
+
completion_operation_id uuid,
|
|
361
|
+
completion_request_fingerprint text,
|
|
362
|
+
completed_auth_user_id text REFERENCES auth_users(id) ON DELETE RESTRICT,
|
|
363
|
+
completion_result jsonb,
|
|
364
|
+
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
365
|
+
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
366
|
+
completed_at timestamptz,
|
|
367
|
+
CONSTRAINT organization_user_setup_intents_invitation_fk
|
|
368
|
+
FOREIGN KEY (invitation_id, account_id)
|
|
369
|
+
REFERENCES organization_membership_invitations(id, account_id) ON DELETE RESTRICT,
|
|
370
|
+
CONSTRAINT organization_user_setup_intents_invitation_uq UNIQUE (account_id, invitation_id),
|
|
371
|
+
CONSTRAINT organization_user_setup_intents_token_uq UNIQUE (token_digest),
|
|
372
|
+
CONSTRAINT organization_user_setup_intents_token_check CHECK (
|
|
373
|
+
token_digest ~ '^[0-9a-f]{64}$'
|
|
374
|
+
),
|
|
375
|
+
CONSTRAINT organization_user_setup_intents_status_check CHECK (
|
|
376
|
+
status IN ('pending', 'completed')
|
|
377
|
+
),
|
|
378
|
+
CONSTRAINT organization_user_setup_intents_completion_check CHECK (
|
|
379
|
+
(status = 'pending'
|
|
380
|
+
AND completion_operation_id IS NULL
|
|
381
|
+
AND completion_request_fingerprint IS NULL
|
|
382
|
+
AND completed_auth_user_id IS NULL
|
|
383
|
+
AND completion_result IS NULL
|
|
384
|
+
AND completed_at IS NULL)
|
|
385
|
+
OR
|
|
386
|
+
(status = 'completed'
|
|
387
|
+
AND completion_operation_id IS NOT NULL
|
|
388
|
+
AND completion_request_fingerprint IS NOT NULL
|
|
389
|
+
AND completion_request_fingerprint ~ '^[0-9a-f]{64}$'
|
|
390
|
+
AND completed_auth_user_id IS NOT NULL
|
|
391
|
+
AND completion_result IS NOT NULL
|
|
392
|
+
AND completed_at IS NOT NULL)
|
|
393
|
+
)
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
ALTER TABLE organization_user_setup_intents ENABLE ROW LEVEL SECURITY;
|
|
397
|
+
ALTER TABLE organization_user_setup_intents FORCE ROW LEVEL SECURITY;
|
|
398
|
+
CREATE POLICY organization_tenancy_lifecycle ON organization_user_setup_intents
|
|
399
|
+
USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
|
|
400
|
+
= 'organization_membership_lifecycle')
|
|
401
|
+
WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
|
|
402
|
+
= 'organization_membership_lifecycle');
|
|
403
|
+
|
|
404
|
+
CREATE FUNCTION ensure_organization_user_setup_intent(p_command jsonb)
|
|
405
|
+
RETURNS jsonb
|
|
406
|
+
LANGUAGE plpgsql
|
|
407
|
+
SECURITY DEFINER
|
|
408
|
+
SET search_path FROM CURRENT
|
|
409
|
+
AS $body$
|
|
410
|
+
DECLARE
|
|
411
|
+
account_id_value uuid := nullif(p_command ->> 'organizationId', '')::uuid;
|
|
412
|
+
actor_subject text := p_command ->> 'actorSubjectId';
|
|
413
|
+
invitation_id_value uuid := nullif(p_command ->> 'invitationId', '')::uuid;
|
|
414
|
+
invitation organization_membership_invitations%ROWTYPE;
|
|
415
|
+
actor organization_memberships%ROWTYPE;
|
|
416
|
+
setup organization_user_setup_intents%ROWTYPE;
|
|
417
|
+
digest_value text := p_command ->> 'tokenDigest';
|
|
418
|
+
expires_at_value timestamptz := nullif(p_command ->> 'expiresAt', '')::timestamptz;
|
|
419
|
+
BEGIN
|
|
420
|
+
IF account_id_value IS NULL
|
|
421
|
+
OR account_id_value IS DISTINCT FROM opengeni_private.current_account_id()
|
|
422
|
+
OR actor_subject IS NULL
|
|
423
|
+
OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
|
|
424
|
+
OR invitation_id_value IS NULL
|
|
425
|
+
OR digest_value !~ '^[0-9a-f]{64}$'
|
|
426
|
+
OR expires_at_value IS NULL
|
|
427
|
+
THEN
|
|
428
|
+
RAISE EXCEPTION 'organization user setup authority is invalid' USING ERRCODE = '42501';
|
|
429
|
+
END IF;
|
|
430
|
+
|
|
431
|
+
PERFORM pg_catalog.set_config(
|
|
432
|
+
'opengeni.organization_tenancy_lifecycle',
|
|
433
|
+
'organization_membership_lifecycle', true
|
|
434
|
+
);
|
|
435
|
+
PERFORM pg_catalog.pg_advisory_xact_lock(
|
|
436
|
+
pg_catalog.hashtextextended('organization-membership:' || account_id_value::text, 0)
|
|
437
|
+
);
|
|
438
|
+
PERFORM 1 FROM managed_accounts account
|
|
439
|
+
WHERE account.id = account_id_value FOR KEY SHARE;
|
|
440
|
+
IF NOT FOUND THEN
|
|
441
|
+
RAISE EXCEPTION 'organization not found' USING ERRCODE = 'P0002';
|
|
442
|
+
END IF;
|
|
443
|
+
|
|
444
|
+
SELECT * INTO actor FROM organization_memberships membership
|
|
445
|
+
WHERE membership.account_id = account_id_value
|
|
446
|
+
AND membership.subject_id = actor_subject
|
|
447
|
+
FOR UPDATE;
|
|
448
|
+
IF NOT FOUND OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
|
|
449
|
+
RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
|
|
450
|
+
END IF;
|
|
451
|
+
|
|
452
|
+
SELECT * INTO invitation FROM organization_membership_invitations candidate
|
|
453
|
+
WHERE candidate.account_id = account_id_value
|
|
454
|
+
AND candidate.id = invitation_id_value
|
|
455
|
+
FOR UPDATE;
|
|
456
|
+
IF NOT FOUND
|
|
457
|
+
OR invitation.created_by_membership_id IS DISTINCT FROM actor.id
|
|
458
|
+
OR invitation.status <> 'pending'
|
|
459
|
+
OR invitation.expires_at <= pg_catalog.clock_timestamp()
|
|
460
|
+
OR expires_at_value IS DISTINCT FROM invitation.expires_at
|
|
461
|
+
THEN
|
|
462
|
+
RAISE EXCEPTION 'organization invitation is unavailable' USING ERRCODE = 'P0002';
|
|
463
|
+
END IF;
|
|
464
|
+
|
|
465
|
+
SELECT * INTO setup FROM organization_user_setup_intents candidate
|
|
466
|
+
WHERE candidate.account_id = account_id_value
|
|
467
|
+
AND candidate.invitation_id = invitation_id_value
|
|
468
|
+
FOR UPDATE;
|
|
469
|
+
IF FOUND THEN
|
|
470
|
+
IF setup.token_digest IS DISTINCT FROM digest_value
|
|
471
|
+
OR setup.expires_at IS DISTINCT FROM expires_at_value
|
|
472
|
+
THEN
|
|
473
|
+
RAISE EXCEPTION 'organization user setup intent changed under retry'
|
|
474
|
+
USING ERRCODE = '23505';
|
|
475
|
+
END IF;
|
|
476
|
+
RETURN pg_catalog.jsonb_build_object('status', setup.status);
|
|
477
|
+
END IF;
|
|
478
|
+
|
|
479
|
+
INSERT INTO organization_user_setup_intents (
|
|
480
|
+
account_id, invitation_id, token_digest, expires_at
|
|
481
|
+
) VALUES (
|
|
482
|
+
account_id_value, invitation_id_value, digest_value, expires_at_value
|
|
483
|
+
) RETURNING * INTO setup;
|
|
484
|
+
RETURN pg_catalog.jsonb_build_object('status', setup.status);
|
|
485
|
+
END
|
|
486
|
+
$body$;
|
|
487
|
+
|
|
488
|
+
CREATE FUNCTION preflight_organization_user_setup(p_token_digest text)
|
|
489
|
+
RETURNS text
|
|
490
|
+
LANGUAGE plpgsql
|
|
491
|
+
SECURITY DEFINER
|
|
492
|
+
SET search_path FROM CURRENT
|
|
493
|
+
AS $body$
|
|
494
|
+
DECLARE
|
|
495
|
+
setup organization_user_setup_intents%ROWTYPE;
|
|
496
|
+
invitation organization_membership_invitations%ROWTYPE;
|
|
497
|
+
normalized_email text;
|
|
498
|
+
BEGIN
|
|
499
|
+
-- This is deliberately a cheap, non-consuming eligibility read. The final
|
|
500
|
+
-- lifecycle repeats every check under the canonical email/organization
|
|
501
|
+
-- locks and remains the only authority that creates or consumes anything.
|
|
502
|
+
IF p_token_digest IS NULL OR p_token_digest !~ '^[0-9a-f]{64}$' THEN
|
|
503
|
+
RETURN 'unavailable';
|
|
504
|
+
END IF;
|
|
505
|
+
PERFORM pg_catalog.set_config(
|
|
506
|
+
'opengeni.organization_tenancy_lifecycle',
|
|
507
|
+
'organization_membership_lifecycle', true
|
|
508
|
+
);
|
|
509
|
+
SELECT * INTO setup FROM organization_user_setup_intents candidate
|
|
510
|
+
WHERE candidate.token_digest = p_token_digest;
|
|
511
|
+
IF NOT FOUND THEN
|
|
512
|
+
RETURN 'unavailable';
|
|
513
|
+
END IF;
|
|
514
|
+
IF setup.status = 'completed' THEN
|
|
515
|
+
RETURN 'completed';
|
|
516
|
+
END IF;
|
|
517
|
+
SELECT * INTO invitation FROM organization_membership_invitations candidate
|
|
518
|
+
WHERE candidate.account_id = setup.account_id
|
|
519
|
+
AND candidate.id = setup.invitation_id;
|
|
520
|
+
IF NOT FOUND
|
|
521
|
+
OR invitation.status <> 'pending'
|
|
522
|
+
OR invitation.expires_at <= pg_catalog.clock_timestamp()
|
|
523
|
+
OR setup.expires_at <= pg_catalog.clock_timestamp()
|
|
524
|
+
OR invitation.expires_at IS DISTINCT FROM setup.expires_at
|
|
525
|
+
THEN
|
|
526
|
+
RETURN 'unavailable';
|
|
527
|
+
END IF;
|
|
528
|
+
normalized_email := lower(btrim(invitation.target_email));
|
|
529
|
+
IF EXISTS (
|
|
530
|
+
SELECT 1 FROM auth_users auth_user
|
|
531
|
+
WHERE lower(btrim(auth_user.email)) = normalized_email
|
|
532
|
+
) THEN
|
|
533
|
+
RETURN 'unavailable';
|
|
534
|
+
END IF;
|
|
535
|
+
RETURN 'pending';
|
|
536
|
+
END
|
|
537
|
+
$body$;
|
|
538
|
+
|
|
539
|
+
CREATE FUNCTION complete_organization_user_setup(p_command jsonb)
|
|
540
|
+
RETURNS jsonb
|
|
541
|
+
LANGUAGE plpgsql
|
|
542
|
+
SECURITY DEFINER
|
|
543
|
+
SET search_path FROM CURRENT
|
|
544
|
+
AS $body$
|
|
545
|
+
DECLARE
|
|
546
|
+
digest_value text := p_command ->> 'tokenDigest';
|
|
547
|
+
operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
|
|
548
|
+
fingerprint_value text := p_command ->> 'requestFingerprint';
|
|
549
|
+
auth_user_id_value text := p_command ->> 'authUserId';
|
|
550
|
+
display_name_value text := nullif(btrim(p_command ->> 'name'), '');
|
|
551
|
+
password_hash_value text := p_command ->> 'passwordHash';
|
|
552
|
+
setup organization_user_setup_intents%ROWTYPE;
|
|
553
|
+
invitation organization_membership_invitations%ROWTYPE;
|
|
554
|
+
normalized_email text;
|
|
555
|
+
subject_id_value text;
|
|
556
|
+
locked_account_id_value uuid;
|
|
557
|
+
acceptance_result jsonb;
|
|
558
|
+
public_result jsonb := pg_catalog.jsonb_build_object('status', 'complete');
|
|
559
|
+
BEGIN
|
|
560
|
+
IF digest_value !~ '^[0-9a-f]{64}$'
|
|
561
|
+
OR operation_id_value IS NULL
|
|
562
|
+
OR fingerprint_value !~ '^[0-9a-f]{64}$'
|
|
563
|
+
OR auth_user_id_value IS NULL
|
|
564
|
+
OR octet_length(convert_to(auth_user_id_value, 'UTF8')) NOT BETWEEN 1 AND 1019
|
|
565
|
+
OR display_name_value IS NULL
|
|
566
|
+
OR octet_length(convert_to(display_name_value, 'UTF8')) NOT BETWEEN 1 AND 120
|
|
567
|
+
OR password_hash_value IS NULL
|
|
568
|
+
OR octet_length(convert_to(password_hash_value, 'UTF8')) NOT BETWEEN 32 AND 4096
|
|
569
|
+
THEN
|
|
570
|
+
RAISE EXCEPTION 'organization user setup input is invalid' USING ERRCODE = '22023';
|
|
571
|
+
END IF;
|
|
572
|
+
|
|
573
|
+
PERFORM pg_catalog.set_config(
|
|
574
|
+
'opengeni.organization_tenancy_lifecycle',
|
|
575
|
+
'organization_membership_lifecycle', true
|
|
576
|
+
);
|
|
577
|
+
SELECT * INTO setup FROM organization_user_setup_intents candidate
|
|
578
|
+
WHERE candidate.token_digest = digest_value;
|
|
579
|
+
IF NOT FOUND THEN
|
|
580
|
+
RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
|
|
581
|
+
END IF;
|
|
582
|
+
|
|
583
|
+
SELECT * INTO invitation FROM organization_membership_invitations candidate
|
|
584
|
+
WHERE candidate.account_id = setup.account_id
|
|
585
|
+
AND candidate.id = setup.invitation_id;
|
|
586
|
+
IF NOT FOUND THEN
|
|
587
|
+
RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
|
|
588
|
+
END IF;
|
|
589
|
+
normalized_email := lower(btrim(invitation.target_email));
|
|
590
|
+
|
|
591
|
+
-- Match invitation creation/binding lock order before taking any tenant row.
|
|
592
|
+
PERFORM pg_catalog.pg_advisory_xact_lock(
|
|
593
|
+
pg_catalog.hashtextextended('organization-invitation-email:' || normalized_email, 0)
|
|
594
|
+
);
|
|
595
|
+
-- Freeze the complete matching invitation set behind the email fence, then
|
|
596
|
+
-- acquire every relevant organization lock in UUID order before touching the
|
|
597
|
+
-- selected organization. This preserves the binder's canonical multi-org
|
|
598
|
+
-- order even when two different recipients have invitations in the same two
|
|
599
|
+
-- organizations but choose opposite setup links concurrently.
|
|
600
|
+
FOR locked_account_id_value IN
|
|
601
|
+
SELECT relevant.account_id
|
|
602
|
+
FROM (
|
|
603
|
+
SELECT setup.account_id
|
|
604
|
+
UNION
|
|
605
|
+
SELECT candidate.account_id
|
|
606
|
+
FROM organization_membership_invitations candidate
|
|
607
|
+
WHERE candidate.target_subject_id IS NULL
|
|
608
|
+
AND candidate.target_email = normalized_email
|
|
609
|
+
AND candidate.status = 'pending'
|
|
610
|
+
AND candidate.expires_at > pg_catalog.clock_timestamp()
|
|
611
|
+
) relevant
|
|
612
|
+
ORDER BY relevant.account_id
|
|
613
|
+
LOOP
|
|
614
|
+
PERFORM pg_catalog.pg_advisory_xact_lock(
|
|
615
|
+
pg_catalog.hashtextextended(
|
|
616
|
+
'organization-membership:' || locked_account_id_value::text, 0
|
|
617
|
+
)
|
|
618
|
+
);
|
|
619
|
+
PERFORM 1 FROM managed_accounts account
|
|
620
|
+
WHERE account.id = locked_account_id_value FOR KEY SHARE;
|
|
621
|
+
IF NOT FOUND THEN
|
|
622
|
+
RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
|
|
623
|
+
END IF;
|
|
624
|
+
END LOOP;
|
|
625
|
+
|
|
626
|
+
SELECT * INTO setup FROM organization_user_setup_intents candidate
|
|
627
|
+
WHERE candidate.token_digest = digest_value
|
|
628
|
+
FOR UPDATE;
|
|
629
|
+
IF setup.status = 'completed' THEN
|
|
630
|
+
IF setup.completion_operation_id IS DISTINCT FROM operation_id_value
|
|
631
|
+
OR setup.completion_request_fingerprint IS DISTINCT FROM fingerprint_value
|
|
632
|
+
THEN
|
|
633
|
+
RAISE EXCEPTION 'organization user setup operation was reused'
|
|
634
|
+
USING ERRCODE = '23505';
|
|
635
|
+
END IF;
|
|
636
|
+
RETURN setup.completion_result;
|
|
637
|
+
END IF;
|
|
638
|
+
|
|
639
|
+
SELECT * INTO invitation FROM organization_membership_invitations candidate
|
|
640
|
+
WHERE candidate.account_id = setup.account_id
|
|
641
|
+
AND candidate.id = setup.invitation_id
|
|
642
|
+
FOR UPDATE;
|
|
643
|
+
IF NOT FOUND
|
|
644
|
+
OR invitation.status <> 'pending'
|
|
645
|
+
OR invitation.expires_at <= pg_catalog.clock_timestamp()
|
|
646
|
+
OR setup.expires_at <= pg_catalog.clock_timestamp()
|
|
647
|
+
OR invitation.expires_at IS DISTINCT FROM setup.expires_at
|
|
648
|
+
THEN
|
|
649
|
+
RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
|
|
650
|
+
END IF;
|
|
651
|
+
|
|
652
|
+
-- Existing users retain the ordinary verified-email invitation path. A setup
|
|
653
|
+
-- bearer never changes an existing credential or takes over an identity.
|
|
654
|
+
IF EXISTS (
|
|
655
|
+
SELECT 1 FROM auth_users auth_user
|
|
656
|
+
WHERE lower(btrim(auth_user.email)) = normalized_email
|
|
657
|
+
) THEN
|
|
658
|
+
RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
|
|
659
|
+
END IF;
|
|
660
|
+
|
|
661
|
+
INSERT INTO auth_users (id, name, email, email_verified)
|
|
662
|
+
VALUES (auth_user_id_value, display_name_value, normalized_email, true);
|
|
663
|
+
INSERT INTO auth_identities (
|
|
664
|
+
id, user_id, account_id, provider_id, password
|
|
665
|
+
) VALUES (
|
|
666
|
+
gen_random_uuid()::text, auth_user_id_value, auth_user_id_value,
|
|
667
|
+
'credential', password_hash_value
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
subject_id_value := 'user:' || auth_user_id_value;
|
|
671
|
+
PERFORM pg_catalog.set_config('opengeni.subject_id', subject_id_value, true);
|
|
672
|
+
PERFORM pg_catalog.set_config('opengeni.account_id', setup.account_id::text, true);
|
|
673
|
+
PERFORM pg_catalog.set_config('opengeni.workspace_id', '', true);
|
|
674
|
+
PERFORM bind_pending_organization_invitations_for_verified_email(
|
|
675
|
+
subject_id_value, normalized_email
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
SELECT * INTO invitation FROM organization_membership_invitations candidate
|
|
679
|
+
WHERE candidate.account_id = setup.account_id
|
|
680
|
+
AND candidate.id = setup.invitation_id
|
|
681
|
+
FOR UPDATE;
|
|
682
|
+
IF invitation.target_subject_id IS DISTINCT FROM subject_id_value
|
|
683
|
+
OR invitation.status <> 'pending'
|
|
684
|
+
THEN
|
|
685
|
+
RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
|
|
686
|
+
END IF;
|
|
687
|
+
|
|
688
|
+
acceptance_result := accept_organization_invitation_v2(
|
|
689
|
+
pg_catalog.jsonb_build_object(
|
|
690
|
+
'action', 'accept',
|
|
691
|
+
'organizationId', setup.account_id,
|
|
692
|
+
'actorSubjectId', subject_id_value,
|
|
693
|
+
'operationId', operation_id_value,
|
|
694
|
+
'invitationId', setup.invitation_id,
|
|
695
|
+
'expectedRevision', invitation.revision
|
|
696
|
+
)
|
|
697
|
+
);
|
|
698
|
+
IF acceptance_result -> 'membership' ->> 'subjectId' IS DISTINCT FROM subject_id_value THEN
|
|
699
|
+
RAISE EXCEPTION 'organization user setup acceptance did not converge'
|
|
700
|
+
USING ERRCODE = '55000';
|
|
701
|
+
END IF;
|
|
702
|
+
|
|
703
|
+
UPDATE organization_user_setup_intents SET
|
|
704
|
+
status = 'completed',
|
|
705
|
+
completion_operation_id = operation_id_value,
|
|
706
|
+
completion_request_fingerprint = fingerprint_value,
|
|
707
|
+
completed_auth_user_id = auth_user_id_value,
|
|
708
|
+
completion_result = public_result,
|
|
709
|
+
completed_at = pg_catalog.clock_timestamp(),
|
|
710
|
+
updated_at = pg_catalog.clock_timestamp()
|
|
711
|
+
WHERE id = setup.id;
|
|
712
|
+
RETURN public_result;
|
|
713
|
+
END
|
|
714
|
+
$body$;
|
|
715
|
+
|
|
716
|
+
-- The incoming-invitation chooser must expose a human-readable organization
|
|
717
|
+
-- identity without turning the row-projection helper into an arbitrary account
|
|
718
|
+
-- name oracle. Enrich only the exact current subject's authorized page.
|
|
719
|
+
CREATE OR REPLACE FUNCTION list_self_organization_invitations(
|
|
720
|
+
p_subject_id text,
|
|
721
|
+
p_cursor_id uuid,
|
|
722
|
+
p_limit integer
|
|
723
|
+
)
|
|
724
|
+
RETURNS jsonb
|
|
725
|
+
LANGUAGE plpgsql
|
|
726
|
+
SECURITY DEFINER
|
|
727
|
+
SET search_path FROM CURRENT
|
|
728
|
+
AS $body$
|
|
729
|
+
DECLARE
|
|
730
|
+
cursor_created_at timestamptz;
|
|
731
|
+
result jsonb;
|
|
732
|
+
BEGIN
|
|
733
|
+
IF p_subject_id IS NULL
|
|
734
|
+
OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
|
|
735
|
+
OR p_subject_id NOT LIKE 'user:%'
|
|
736
|
+
OR p_limit IS NULL OR p_limit < 1 OR p_limit > 100
|
|
737
|
+
THEN
|
|
738
|
+
RAISE EXCEPTION 'managed human subject authority required' USING ERRCODE = '42501';
|
|
739
|
+
END IF;
|
|
740
|
+
PERFORM pg_catalog.set_config(
|
|
741
|
+
'opengeni.organization_tenancy_lifecycle',
|
|
742
|
+
'organization_membership_lifecycle', true
|
|
743
|
+
);
|
|
744
|
+
IF p_cursor_id IS NOT NULL THEN
|
|
745
|
+
SELECT invitation.created_at INTO cursor_created_at
|
|
746
|
+
FROM organization_membership_invitations invitation
|
|
747
|
+
WHERE invitation.target_subject_id = p_subject_id
|
|
748
|
+
AND invitation.id = p_cursor_id;
|
|
749
|
+
IF NOT FOUND THEN
|
|
750
|
+
RAISE EXCEPTION 'organization invitation cursor not found' USING ERRCODE = 'P0002';
|
|
751
|
+
END IF;
|
|
752
|
+
END IF;
|
|
753
|
+
SELECT coalesce(
|
|
754
|
+
pg_catalog.jsonb_agg(
|
|
755
|
+
opengeni_private.organization_invitation_row_json(candidate.invitation)
|
|
756
|
+
|| pg_catalog.jsonb_build_object('organizationName', candidate.organization_name)
|
|
757
|
+
ORDER BY candidate.created_at DESC, candidate.id DESC
|
|
758
|
+
), '[]'::jsonb
|
|
759
|
+
) INTO result
|
|
760
|
+
FROM (
|
|
761
|
+
SELECT invitation AS invitation, account.name AS organization_name,
|
|
762
|
+
invitation.created_at, invitation.id
|
|
763
|
+
FROM organization_membership_invitations invitation
|
|
764
|
+
JOIN managed_accounts account ON account.id = invitation.account_id
|
|
765
|
+
WHERE invitation.target_subject_id = p_subject_id
|
|
766
|
+
AND (
|
|
767
|
+
p_cursor_id IS NULL
|
|
768
|
+
OR (invitation.created_at, invitation.id) < (cursor_created_at, p_cursor_id)
|
|
769
|
+
)
|
|
770
|
+
ORDER BY invitation.created_at DESC, invitation.id DESC
|
|
771
|
+
LIMIT p_limit + 1
|
|
772
|
+
) candidate;
|
|
773
|
+
RETURN result;
|
|
774
|
+
END
|
|
775
|
+
$body$;
|
|
776
|
+
|
|
777
|
+
DO $search_paths$
|
|
778
|
+
DECLARE data_schema text := pg_catalog.current_schema();
|
|
779
|
+
BEGIN
|
|
780
|
+
EXECUTE format(
|
|
781
|
+
'ALTER FUNCTION %I.complete_self_service_organization_setup(jsonb) SET search_path = pg_catalog, %I, pg_temp',
|
|
782
|
+
data_schema, data_schema
|
|
783
|
+
);
|
|
784
|
+
EXECUTE format(
|
|
785
|
+
'ALTER FUNCTION %I.ensure_organization_user_setup_intent(jsonb) SET search_path = pg_catalog, %I, pg_temp',
|
|
786
|
+
data_schema, data_schema
|
|
787
|
+
);
|
|
788
|
+
EXECUTE format(
|
|
789
|
+
'ALTER FUNCTION %I.preflight_organization_user_setup(text) SET search_path = pg_catalog, %I, pg_temp',
|
|
790
|
+
data_schema, data_schema
|
|
791
|
+
);
|
|
792
|
+
EXECUTE format(
|
|
793
|
+
'ALTER FUNCTION %I.complete_organization_user_setup(jsonb) SET search_path = pg_catalog, %I, pg_temp',
|
|
794
|
+
data_schema, data_schema
|
|
795
|
+
);
|
|
796
|
+
EXECUTE format(
|
|
797
|
+
'ALTER FUNCTION %I.create_managed_organization(text,text,text,uuid) SET search_path = pg_catalog, %I, pg_temp',
|
|
798
|
+
data_schema, data_schema
|
|
799
|
+
);
|
|
800
|
+
EXECUTE format(
|
|
801
|
+
'ALTER FUNCTION %I.list_self_organization_invitations(text,uuid,integer) SET search_path = pg_catalog, %I, pg_temp',
|
|
802
|
+
data_schema, data_schema
|
|
803
|
+
);
|
|
804
|
+
END
|
|
805
|
+
$search_paths$;
|
|
806
|
+
|
|
807
|
+
REVOKE ALL ON TABLE self_service_organization_setup_receipts FROM PUBLIC;
|
|
808
|
+
REVOKE ALL ON TABLE organization_user_setup_intents FROM PUBLIC;
|
|
809
|
+
REVOKE ALL ON FUNCTION complete_self_service_organization_setup(jsonb) FROM PUBLIC;
|
|
810
|
+
REVOKE ALL ON FUNCTION ensure_organization_user_setup_intent(jsonb) FROM PUBLIC;
|
|
811
|
+
REVOKE ALL ON FUNCTION preflight_organization_user_setup(text) FROM PUBLIC;
|
|
812
|
+
REVOKE ALL ON FUNCTION complete_organization_user_setup(jsonb) FROM PUBLIC;
|
|
813
|
+
|
|
814
|
+
DO $runtime_grants$
|
|
815
|
+
BEGIN
|
|
816
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
817
|
+
REVOKE ALL ON TABLE self_service_organization_setup_receipts FROM opengeni_app;
|
|
818
|
+
REVOKE ALL ON TABLE organization_user_setup_intents FROM opengeni_app;
|
|
819
|
+
GRANT EXECUTE ON FUNCTION complete_self_service_organization_setup(jsonb) TO opengeni_app;
|
|
820
|
+
GRANT EXECUTE ON FUNCTION ensure_organization_user_setup_intent(jsonb) TO opengeni_app;
|
|
821
|
+
GRANT EXECUTE ON FUNCTION preflight_organization_user_setup(text) TO opengeni_app;
|
|
822
|
+
GRANT EXECUTE ON FUNCTION complete_organization_user_setup(jsonb) TO opengeni_app;
|
|
823
|
+
END IF;
|
|
824
|
+
END
|
|
825
|
+
$runtime_grants$;
|
|
826
|
+
|
|
827
|
+
COMMENT ON TABLE self_service_organization_setup_receipts IS
|
|
828
|
+
'One completed organization setup per verified Better Auth user; stores idempotency evidence and the canonical personal-workspace result.';
|
|
829
|
+
COMMENT ON FUNCTION complete_self_service_organization_setup(jsonb) IS
|
|
830
|
+
'Atomically creates one active organization owner membership and only its canonical personal workspace after ordinary sign-in.';
|
|
831
|
+
COMMENT ON FUNCTION create_managed_organization(text,text,text,uuid) IS
|
|
832
|
+
'Compatibility entry point for the one-time Personal-only self-service organization setup lifecycle.';
|
|
833
|
+
COMMENT ON TABLE organization_user_setup_intents IS
|
|
834
|
+
'Invitation-bound hashed one-time account setup authority; no plaintext bearer or password is stored.';
|
|
835
|
+
COMMENT ON FUNCTION preflight_organization_user_setup(text) IS
|
|
836
|
+
'Performs a cheap non-consuming setup-bearer eligibility read before password hashing; final completion revalidates atomically.';
|
|
837
|
+
COMMENT ON FUNCTION complete_organization_user_setup(jsonb) IS
|
|
838
|
+
'Consumes one email-delivered setup bearer and atomically creates the verified credential and invitation membership.';
|