1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
//! PostgreSQL-backed [`AccountStore`] — durable user / identity persistence.
//!
//! This is the production backend for [`AccountStore`](super::AccountStore); the
//! [`InMemoryAccountStore`](super::InMemoryAccountStore) loses all linkage on
//! restart. It is a drop-in replacement (same trait, same `"user_<uuid>"`
//! identifier format, so it joins the existing `_system.sessions.user_id`), so
//! `multi_provider` / `phone_otp` need no change beyond which `Arc<dyn AccountStore>`
//! they are handed.
//!
//! # Schema
//!
//! - `core.tb_user` — one row per stable account (`user_id`, optional verified email).
//! - `core.tb_auth_identity` — one row per linked `(provider, provider_id)`, FK to a user.
//!
//! Both carry a `tenant_id` and RLS deny-by-default (mirroring the change-log RLS in
//! observers migration `12`). RLS is `ENABLE`, not `FORCE`: this store runs as the
//! table owner and bypasses the policies — exactly like the executor/poller for the
//! change-log — while any other (non-`BYPASSRLS`) role reads zero rows unless it sets
//! the `fraiseql.tenant_id` GUC.
//!
//! # Account spaces (#1088)
//!
//! `tenant_id` partitions accounts: `NULL` is the platform, a UUID is that tenant. Every key
//! is unique *within* a space — email, SCIM `userName`, `(provider, provider_id)` — so the
//! same address in two tenants is two accounts, and a merge can never cross a space. Each
//! key is one unique index whose `tenant_id` column is `NULLS NOT DISTINCT`, so the platform
//! is a space like any tenant rather than a set of rows that never collide. The key columns
//! lead and `tenant_id` comes last: lookups match the key with `=` and the space with
//! `IS NOT DISTINCT FROM`, which an index cannot use as a leading column. [`SCHEMA_SQL`]
//! drops the global keys an earlier release created, so `init` migrates an existing
//! database in place without moving any row: existing accounts are platform accounts, as
//! before.
use async_trait;
use ;
use Uuid;
use ;
use crate::;
/// Idempotent DDL for the user / identity store. Exposed so a migration runner can
/// apply it explicitly; [`PostgresAccountStore::init`] runs the same statements.
pub const SCHEMA_SQL: &str = r"
CREATE SCHEMA IF NOT EXISTS core;
CREATE TABLE IF NOT EXISTS core.tb_user (
pk_user BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID NOT NULL DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL UNIQUE,
email TEXT,
tenant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Keys are unique per account space (#1088): see the module docs. The global index an
-- earlier release created is replaced in place.
DROP INDEX IF EXISTS core.uq_user_email;
DROP INDEX IF EXISTS core.uq_user_email_platform;
DROP INDEX IF EXISTS core.uq_user_email_tenant;
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_email_per_space
ON core.tb_user (email, tenant_id) NULLS NOT DISTINCT WHERE email IS NOT NULL;
-- SCIM 2.0 provisioning (#946). ADD COLUMN IF NOT EXISTS so a database that predates
-- provisioning upgrades in place.
--
-- `active` is the load-bearing one: it is what an IdP flips when it offboards someone, and
-- it must block *every* credential on the account, not merely SAML. It defaults TRUE so
-- every pre-existing row stays exactly as active as it was.
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS user_name TEXT;
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS external_id TEXT;
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS given_name TEXT;
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS family_name TEXT;
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS display_name TEXT;
-- Monotonic per-row counter behind the SCIM `meta.version` / `ETag`, so a provisioning
-- client's If-Match can detect a lost update. A timestamp would not: two writes inside one
-- clock tick would share a version.
ALTER TABLE core.tb_user ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1;
DROP INDEX IF EXISTS core.uq_user_user_name;
DROP INDEX IF EXISTS core.uq_user_user_name_platform;
DROP INDEX IF EXISTS core.uq_user_user_name_tenant;
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_user_name_per_space
ON core.tb_user (user_name, tenant_id) NULLS NOT DISTINCT WHERE user_name IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_external_id
ON core.tb_user (external_id) WHERE external_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS core.tb_auth_identity (
pk_auth_identity BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID NOT NULL DEFAULT gen_random_uuid(),
fk_user BIGINT NOT NULL REFERENCES core.tb_user (pk_user) ON DELETE CASCADE,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
tenant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE core.tb_auth_identity
DROP CONSTRAINT IF EXISTS tb_auth_identity_provider_provider_id_key;
DROP INDEX IF EXISTS core.uq_auth_identity_platform;
DROP INDEX IF EXISTS core.uq_auth_identity_tenant;
CREATE UNIQUE INDEX IF NOT EXISTS uq_auth_identity_per_space
ON core.tb_auth_identity (provider, provider_id, tenant_id) NULLS NOT DISTINCT;
CREATE INDEX IF NOT EXISTS idx_auth_identity_user ON core.tb_auth_identity (fk_user);
CREATE INDEX IF NOT EXISTS idx_auth_identity_user_id ON core.tb_auth_identity (user_id);
-- RLS deny-by-default (mirrors observers migration 12). ENABLE not FORCE so the
-- owner (this store) and BYPASSRLS roles operate freely; a non-owner role reads a
-- row only once it has set fraiseql.tenant_id to that row's tenant (fail-closed).
ALTER TABLE core.tb_user ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.tb_auth_identity ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_user_tenant_read ON core.tb_user;
CREATE POLICY p_user_tenant_read ON core.tb_user
FOR SELECT USING (tenant_id = NULLIF(current_setting('fraiseql.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS p_user_insert ON core.tb_user;
CREATE POLICY p_user_insert ON core.tb_user FOR INSERT WITH CHECK (true);
DROP POLICY IF EXISTS p_auth_identity_tenant_read ON core.tb_auth_identity;
CREATE POLICY p_auth_identity_tenant_read ON core.tb_auth_identity
FOR SELECT USING (tenant_id = NULLIF(current_setting('fraiseql.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS p_auth_identity_insert ON core.tb_auth_identity;
CREATE POLICY p_auth_identity_insert ON core.tb_auth_identity FOR INSERT WITH CHECK (true);
-- Least-privilege baseline: never world-readable. RLS is defence-in-depth on top.
REVOKE ALL ON core.tb_user FROM PUBLIC;
REVOKE ALL ON core.tb_auth_identity FROM PUBLIC;
";
/// PostgreSQL-backed account store.
///
/// Persists user accounts and their linked provider identities, so account linking
/// survives a process restart. See the module-level documentation for the schema and
/// RLS posture.
/// Generate a fresh stable user identifier, matching the in-memory store's format so
/// the two backends are interchangeable and the value joins `_system.sessions.user_id`.
// Reason: AccountStore is defined with #[async_trait]; the impl must match its
// transformed signatures. async_trait: dyn-dispatch required; remove when RTN + Send
// is stable (RFC 3425).
/// Insert a new user row in `tenant`'s account space and return its `pk_user`.
async