slotlock 0.9.0 → 0.10.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. package/workspaces.js +433 -0
package/ddl-scim.js ADDED
@@ -0,0 +1,740 @@
1
+ // Teams phase 5.6–5.7: a team's directory (Okta, Entra) provisioning its people over SCIM 2.0
2
+ // (scim.ts parses and normalises the requests; these functions keep the records and apply what they
3
+ // mean to the team's memberships). Reached only through the SECURITY DEFINER functions below.
4
+ //
5
+ // The rules the database holds:
6
+ // - A token is the team's (never its creator's), stored as its SHA-256 only, and works for that team
7
+ // alone: a token presented at another team's address is refused.
8
+ // - A directory user is a standing invitation from the directory: it binds to the person who signs in
9
+ // holding its address verified, and only at a domain the team has verified.
10
+ // - Deactivating a user ends that person's membership of this team at their next request, and nothing
11
+ // else of theirs; reactivating restores it, for sessions begun after (sessions_valid_after).
12
+ // - The directory never makes an Owner, never changes one, and never deactivates or deletes one.
13
+ // - Groups named slotlock-role-<role>, or mapped by an Owner, give roles: the highest a person's
14
+ // groups give (admin, developer, member, viewer; billing only alone), else Viewer.
15
+ import { SLOTLOCK_PERSON_PATTERN } from './ddl-identity.js';
16
+ const PERSON = SLOTLOCK_PERSON_PATTERN;
17
+ export const SLOTLOCK_SCIM_TABLES = Object.freeze(['scim_tokens', 'scim_users', 'scim_groups', 'scim_group_members']);
18
+ export const SLOTLOCK_SCIM_FUNCTIONS = Object.freeze([
19
+ 'slotlock.enable_scim(text, text, text)',
20
+ 'slotlock.disable_scim(text, text)',
21
+ 'slotlock.create_scim_token(text, text, bytea, text)',
22
+ 'slotlock.revoke_scim_token(text, text, uuid)',
23
+ 'slotlock.scim_state(text)',
24
+ 'slotlock.scim_authenticate(bytea, text)',
25
+ 'slotlock.scim_put_user(text, uuid, uuid, text, text, boolean, text, text, text, text)',
26
+ 'slotlock.scim_users_list(text, text, text, integer, integer, uuid)',
27
+ 'slotlock.scim_delete_user(text, uuid)',
28
+ 'slotlock.scim_put_group(text, uuid, uuid, text, text, uuid[])',
29
+ 'slotlock.scim_groups_list(text, text, text, integer, integer, uuid)',
30
+ 'slotlock.scim_delete_group(text, uuid)',
31
+ 'slotlock.map_scim_group_role(text, text, uuid, text)',
32
+ 'slotlock.scim_bind(text, text[], integer)',
33
+ ]);
34
+ /** Active tokens a team may hold at once (two, to rotate). */
35
+ export const SLOTLOCK_SCIM_TOKEN_LIMIT = 2;
36
+ const fn = SLOTLOCK_SCIM_FUNCTIONS;
37
+ // The role a SCIM user's groups give, by precedence; Viewer when none does.
38
+ const ROLE_OF_USER = (tenant, user) => `coalesce((
39
+ SELECT grouped.role FROM slotlock.scim_group_members membership
40
+ JOIN slotlock.scim_groups grouped ON grouped.id = membership.group_id AND grouped.deleted_at IS NULL
41
+ WHERE membership.user_id = ${user} AND grouped.tenant_ref = ${tenant} AND grouped.role IS NOT NULL
42
+ ORDER BY array_position(ARRAY['admin', 'developer', 'member', 'viewer', 'billing'], grouped.role)
43
+ LIMIT 1), 'viewer')`;
44
+ export const SLOTLOCK_SCIM_DDL = `
45
+ -- A team's SCIM tokens: the SHA-256 of each, and its first characters to name it.
46
+ CREATE TABLE IF NOT EXISTS slotlock.scim_tokens (
47
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
48
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
49
+ token_sha256 bytea NOT NULL,
50
+ prefix text NOT NULL,
51
+ created_by text,
52
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
53
+ last_used_at timestamptz,
54
+ revoked_at timestamptz,
55
+ CONSTRAINT slotlock_scim_tokens_digest_key UNIQUE (token_sha256),
56
+ CONSTRAINT slotlock_scim_tokens_valid CHECK (
57
+ octet_length(token_sha256) = 32 AND prefix ~ '^slk_scim_[A-Za-z0-9]{4}$'
58
+ AND (created_by IS NULL OR created_by ~ '${PERSON}')
59
+ )
60
+ );
61
+ ALTER TABLE slotlock.scim_tokens ENABLE ROW LEVEL SECURITY;
62
+ REVOKE ALL ON slotlock.scim_tokens FROM PUBLIC;
63
+
64
+ -- The directory's users of a team: their SCIM id is Slotlock's, per team, never a person's id.
65
+ CREATE TABLE IF NOT EXISTS slotlock.scim_users (
66
+ id uuid PRIMARY KEY,
67
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
68
+ user_name text NOT NULL,
69
+ user_name_key text NOT NULL,
70
+ external_id text,
71
+ active boolean NOT NULL DEFAULT true,
72
+ given_name text,
73
+ family_name text,
74
+ display_name text,
75
+ work_email text,
76
+ person_ref text,
77
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
78
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
79
+ deleted_at timestamptz,
80
+ CONSTRAINT slotlock_scim_users_valid CHECK (
81
+ char_length(user_name) BETWEEN 1 AND 254 AND user_name_key = lower(user_name)
82
+ AND (external_id IS NULL OR char_length(external_id) BETWEEN 1 AND 255)
83
+ AND (given_name IS NULL OR char_length(given_name) BETWEEN 1 AND 100)
84
+ AND (family_name IS NULL OR char_length(family_name) BETWEEN 1 AND 100)
85
+ AND (display_name IS NULL OR char_length(display_name) BETWEEN 1 AND 200)
86
+ AND (work_email IS NULL OR (char_length(work_email) BETWEEN 3 AND 254 AND work_email = lower(work_email)))
87
+ AND (person_ref IS NULL OR person_ref ~ '${PERSON}')
88
+ )
89
+ );
90
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_scim_users_name_key
91
+ ON slotlock.scim_users (tenant_ref, user_name_key) WHERE deleted_at IS NULL;
92
+ CREATE INDEX IF NOT EXISTS slotlock_scim_users_email_idx ON slotlock.scim_users (work_email) WHERE deleted_at IS NULL;
93
+ CREATE INDEX IF NOT EXISTS slotlock_scim_users_person_idx ON slotlock.scim_users (person_ref);
94
+ ALTER TABLE slotlock.scim_users ENABLE ROW LEVEL SECURITY;
95
+ REVOKE ALL ON slotlock.scim_users FROM PUBLIC;
96
+
97
+ -- The directory's groups of a team, and the role each gives (never Owner).
98
+ CREATE TABLE IF NOT EXISTS slotlock.scim_groups (
99
+ id uuid PRIMARY KEY,
100
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
101
+ display_name text NOT NULL,
102
+ display_name_key text NOT NULL,
103
+ external_id text,
104
+ role text,
105
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
106
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
107
+ deleted_at timestamptz,
108
+ CONSTRAINT slotlock_scim_groups_valid CHECK (
109
+ char_length(display_name) BETWEEN 1 AND 200 AND display_name_key = lower(display_name)
110
+ AND (external_id IS NULL OR char_length(external_id) BETWEEN 1 AND 255)
111
+ AND (role IS NULL OR role IN ('admin', 'developer', 'member', 'viewer', 'billing'))
112
+ )
113
+ );
114
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_scim_groups_name_key
115
+ ON slotlock.scim_groups (tenant_ref, display_name_key) WHERE deleted_at IS NULL;
116
+ ALTER TABLE slotlock.scim_groups ENABLE ROW LEVEL SECURITY;
117
+ REVOKE ALL ON slotlock.scim_groups FROM PUBLIC;
118
+
119
+ CREATE TABLE IF NOT EXISTS slotlock.scim_group_members (
120
+ group_id uuid NOT NULL REFERENCES slotlock.scim_groups (id) ON DELETE CASCADE,
121
+ user_id uuid NOT NULL REFERENCES slotlock.scim_users (id) ON DELETE CASCADE,
122
+ CONSTRAINT slotlock_scim_group_members_pkey PRIMARY KEY (group_id, user_id)
123
+ );
124
+ CREATE INDEX IF NOT EXISTS slotlock_scim_group_members_user_idx ON slotlock.scim_group_members (user_id);
125
+ ALTER TABLE slotlock.scim_group_members ENABLE ROW LEVEL SECURITY;
126
+ REVOKE ALL ON slotlock.scim_group_members FROM PUBLIC;
127
+
128
+ -- Whether a team's directory provisions it, and what a deactivated person's keys become.
129
+ ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS scim_enabled boolean NOT NULL DEFAULT false;
130
+ ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS scim_key_policy text
131
+ CONSTRAINT slotlock_workspaces_scim_key_policy_valid CHECK (scim_key_policy IS NULL OR scim_key_policy IN ('keep', 'revoke'));
132
+ -- A membership the directory gave, or restored: its sessions from before that moment do not count.
133
+ ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS sessions_valid_after timestamptz;
134
+ -- Who ended a membership: the team (an Owner or Admin), the person themselves, or the team's
135
+ -- directory. The directory restores only what it ended (ddl.ts remove_member sets the others).
136
+ ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS deactivated_by text
137
+ CONSTRAINT slotlock_workspace_memberships_deactivated_by_valid CHECK (deactivated_by IN ('team', 'self', 'scim'));
138
+
139
+ -- Turn the team's directory on, by an Owner, choosing what a deactivated person's keys become. The
140
+ -- directory decides who is in from then on: domains no longer let people join at once, and single
141
+ -- sign-on no longer adds people.
142
+ CREATE OR REPLACE FUNCTION slotlock.enable_scim(requested_tenant text, requested_actor text, requested_key_policy text)
143
+ RETURNS text
144
+ LANGUAGE plpgsql
145
+ VOLATILE
146
+ SECURITY DEFINER
147
+ SET search_path = pg_catalog, slotlock, pg_temp
148
+ AS $slotlock_enable_scim$
149
+ DECLARE
150
+ actor_role text;
151
+ BEGIN
152
+ IF requested_key_policy IS NULL OR requested_key_policy NOT IN ('keep', 'revoke') THEN
153
+ RETURN 'key_policy';
154
+ END IF;
155
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
156
+ IF NOT FOUND THEN
157
+ RETURN 'not_found';
158
+ END IF;
159
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
160
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
161
+ IF actor_role IS DISTINCT FROM 'owner' THEN
162
+ RETURN 'not_allowed';
163
+ END IF;
164
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_domains claim WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified') THEN
165
+ RETURN 'no_verified_domain';
166
+ END IF;
167
+ UPDATE slotlock.workspaces ws SET scim_enabled = true, scim_key_policy = requested_key_policy WHERE ws.tenant_ref = requested_tenant;
168
+ UPDATE slotlock.workspace_domains claim SET join_policy = 'request'
169
+ WHERE claim.tenant_ref = requested_tenant AND claim.join_policy = 'auto';
170
+ UPDATE slotlock.sso_connections linked SET jit_enabled = false WHERE linked.tenant_ref = requested_tenant;
171
+ RETURN NULL;
172
+ END
173
+ $slotlock_enable_scim$;
174
+ REVOKE ALL ON FUNCTION ${fn[0]} FROM PUBLIC;
175
+
176
+ -- Turn it off, by an Owner: its tokens stop. Nobody is deactivated by it.
177
+ CREATE OR REPLACE FUNCTION slotlock.disable_scim(requested_tenant text, requested_actor text)
178
+ RETURNS text
179
+ LANGUAGE plpgsql
180
+ VOLATILE
181
+ SECURITY DEFINER
182
+ SET search_path = pg_catalog, slotlock, pg_temp
183
+ AS $slotlock_disable_scim$
184
+ DECLARE
185
+ actor_role text;
186
+ BEGIN
187
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
188
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
189
+ IF actor_role IS DISTINCT FROM 'owner' THEN
190
+ RETURN 'not_allowed';
191
+ END IF;
192
+ UPDATE slotlock.workspaces ws SET scim_enabled = false WHERE ws.tenant_ref = requested_tenant;
193
+ UPDATE slotlock.scim_tokens token SET revoked_at = statement_timestamp()
194
+ WHERE token.tenant_ref = requested_tenant AND token.revoked_at IS NULL;
195
+ RETURN NULL;
196
+ END
197
+ $slotlock_disable_scim$;
198
+ REVOKE ALL ON FUNCTION ${fn[1]} FROM PUBLIC;
199
+
200
+ -- A new token, by an Owner, while the directory is on: two at most at once, to rotate.
201
+ CREATE OR REPLACE FUNCTION slotlock.create_scim_token(
202
+ requested_tenant text, requested_actor text, requested_digest bytea, requested_prefix text)
203
+ RETURNS text
204
+ LANGUAGE plpgsql
205
+ VOLATILE
206
+ SECURITY DEFINER
207
+ SET search_path = pg_catalog, slotlock, pg_temp
208
+ AS $slotlock_create_scim_token$
209
+ DECLARE
210
+ actor_role text;
211
+ BEGIN
212
+ PERFORM 1 FROM slotlock.workspaces ws
213
+ WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' AND ws.scim_enabled FOR UPDATE;
214
+ IF NOT FOUND THEN
215
+ RETURN 'not_enabled';
216
+ END IF;
217
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
218
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
219
+ IF actor_role IS DISTINCT FROM 'owner' THEN
220
+ RETURN 'not_allowed';
221
+ END IF;
222
+ IF (SELECT count(*) FROM slotlock.scim_tokens token WHERE token.tenant_ref = requested_tenant AND token.revoked_at IS NULL)
223
+ >= ${SLOTLOCK_SCIM_TOKEN_LIMIT} THEN
224
+ RETURN 'token_limit';
225
+ END IF;
226
+ INSERT INTO slotlock.scim_tokens (tenant_ref, token_sha256, prefix, created_by)
227
+ VALUES (requested_tenant, requested_digest, requested_prefix, requested_actor);
228
+ RETURN NULL;
229
+ END
230
+ $slotlock_create_scim_token$;
231
+ REVOKE ALL ON FUNCTION ${fn[2]} FROM PUBLIC;
232
+
233
+ CREATE OR REPLACE FUNCTION slotlock.revoke_scim_token(requested_tenant text, requested_actor text, requested_id uuid)
234
+ RETURNS text
235
+ LANGUAGE plpgsql
236
+ VOLATILE
237
+ SECURITY DEFINER
238
+ SET search_path = pg_catalog, slotlock, pg_temp
239
+ AS $slotlock_revoke_scim_token$
240
+ DECLARE
241
+ actor_role text;
242
+ BEGIN
243
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
244
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
245
+ IF actor_role IS DISTINCT FROM 'owner' THEN
246
+ RETURN 'not_allowed';
247
+ END IF;
248
+ UPDATE slotlock.scim_tokens token SET revoked_at = statement_timestamp()
249
+ WHERE token.id = requested_id AND token.tenant_ref = requested_tenant AND token.revoked_at IS NULL;
250
+ IF NOT FOUND THEN
251
+ RETURN 'not_found';
252
+ END IF;
253
+ RETURN NULL;
254
+ END
255
+ $slotlock_revoke_scim_token$;
256
+ REVOKE ALL ON FUNCTION ${fn[3]} FROM PUBLIC;
257
+
258
+ -- The team's directory as its Identity page shows it: on or off, its key policy, and its tokens.
259
+ CREATE OR REPLACE FUNCTION slotlock.scim_state(requested_tenant text)
260
+ RETURNS TABLE (enabled boolean, key_policy text, token_id uuid, prefix text, created_at timestamptz, last_used_at timestamptz)
261
+ LANGUAGE sql
262
+ STABLE
263
+ SECURITY DEFINER
264
+ SET search_path = pg_catalog, slotlock, pg_temp
265
+ AS $slotlock_scim_state$
266
+ SELECT ws.scim_enabled, ws.scim_key_policy, token.id, token.prefix, token.created_at, token.last_used_at
267
+ FROM slotlock.workspaces ws
268
+ LEFT JOIN slotlock.scim_tokens token ON token.tenant_ref = ws.tenant_ref AND token.revoked_at IS NULL
269
+ WHERE ws.tenant_ref = requested_tenant
270
+ ORDER BY token.created_at;
271
+ $slotlock_scim_state$;
272
+ REVOKE ALL ON FUNCTION ${fn[4]} FROM PUBLIC;
273
+
274
+ -- A request's token: its team, which must be the team the address names (a token at another team's
275
+ -- address is wrong_workspace), while the directory is on.
276
+ CREATE OR REPLACE FUNCTION slotlock.scim_authenticate(requested_digest bytea, requested_team text)
277
+ RETURNS TABLE (team_ref text, token_prefix text, key_policy text, refusal text)
278
+ LANGUAGE plpgsql
279
+ VOLATILE
280
+ SECURITY DEFINER
281
+ SET search_path = pg_catalog, slotlock, pg_temp
282
+ AS $slotlock_scim_authenticate$
283
+ DECLARE
284
+ token slotlock.scim_tokens%ROWTYPE;
285
+ policy text;
286
+ BEGIN
287
+ SELECT * INTO token FROM slotlock.scim_tokens found WHERE found.token_sha256 = requested_digest AND found.revoked_at IS NULL;
288
+ IF NOT FOUND THEN
289
+ RETURN QUERY SELECT NULL::text, NULL::text, NULL::text, 'bad_token'::text;
290
+ RETURN;
291
+ END IF;
292
+ IF token.tenant_ref IS DISTINCT FROM requested_team THEN
293
+ RETURN QUERY SELECT token.tenant_ref, token.prefix, NULL::text, 'wrong_workspace'::text;
294
+ RETURN;
295
+ END IF;
296
+ SELECT ws.scim_key_policy INTO policy FROM slotlock.workspaces ws
297
+ WHERE ws.tenant_ref = token.tenant_ref AND ws.status = 'active' AND ws.scim_enabled;
298
+ IF NOT FOUND THEN
299
+ RETURN QUERY SELECT NULL::text, NULL::text, NULL::text, 'bad_token'::text;
300
+ RETURN;
301
+ END IF;
302
+ UPDATE slotlock.scim_tokens found SET last_used_at = statement_timestamp()
303
+ WHERE found.id = token.id AND (found.last_used_at IS NULL OR found.last_used_at < statement_timestamp() - interval '1 minute');
304
+ RETURN QUERY SELECT token.tenant_ref, token.prefix, policy, NULL::text;
305
+ END
306
+ $slotlock_scim_authenticate$;
307
+ REVOKE ALL ON FUNCTION ${fn[5]} FROM PUBLIC;
308
+
309
+ -- Create (requested_id null: requested_new_id) or replace a directory user, as scim.ts has already
310
+ -- worked out its attributes (a PUT, or a PATCH applied). Its address must be at a domain the team
311
+ -- has verified. Its active flag ends or restores the bound person's membership of this team; an
312
+ -- Owner's never. Returns the user's id, what happened to the membership, and the person.
313
+ CREATE OR REPLACE FUNCTION slotlock.scim_put_user(
314
+ requested_tenant text, requested_id uuid, requested_new_id uuid, requested_user_name text, requested_external_id text,
315
+ requested_active boolean, requested_given_name text, requested_family_name text, requested_display_name text,
316
+ requested_work_email text)
317
+ RETURNS TABLE (user_id uuid, refusal text, membership text, person text)
318
+ LANGUAGE plpgsql
319
+ VOLATILE
320
+ SECURITY DEFINER
321
+ SET search_path = pg_catalog, slotlock, pg_temp
322
+ AS $slotlock_scim_put_user$
323
+ DECLARE
324
+ existing slotlock.scim_users%ROWTYPE;
325
+ address text := lower(coalesce(requested_work_email, CASE WHEN requested_user_name LIKE '%@%' THEN requested_user_name END));
326
+ saved uuid;
327
+ bound text;
328
+ member_role text;
329
+ effect text := 'none';
330
+ BEGIN
331
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' AND ws.scim_enabled FOR UPDATE;
332
+ IF NOT FOUND THEN
333
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text, NULL::text, NULL::text;
334
+ RETURN;
335
+ END IF;
336
+ IF address IS NULL OR NOT EXISTS (
337
+ SELECT 1 FROM slotlock.workspace_domains claim
338
+ WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified'
339
+ AND claim.domain_ascii = lower(substring(address FROM '@([^@]+)$'))
340
+ ) THEN
341
+ RETURN QUERY SELECT NULL::uuid, 'domain_not_verified'::text, NULL::text, NULL::text;
342
+ RETURN;
343
+ END IF;
344
+ IF requested_id IS NULL THEN
345
+ IF EXISTS (SELECT 1 FROM slotlock.scim_users other WHERE other.tenant_ref = requested_tenant
346
+ AND other.user_name_key = lower(requested_user_name) AND other.deleted_at IS NULL) THEN
347
+ RETURN QUERY SELECT NULL::uuid, 'uniqueness'::text, NULL::text, NULL::text;
348
+ RETURN;
349
+ END IF;
350
+ -- A member now who joined by this address (an invitation, a domain) is this user already: the
351
+ -- directory may end their membership, never change the role they were given by hand. Someone
352
+ -- the team removed is nobody's user (ddl.ts remove_member).
353
+ SELECT member.person_ref, member.role INTO bound, member_role FROM slotlock.workspace_memberships member
354
+ WHERE member.tenant_ref = requested_tenant AND member.joined_email = address AND member.status = 'active'
355
+ ORDER BY member.created_at LIMIT 1;
356
+ IF member_role = 'owner' AND NOT requested_active THEN
357
+ RETURN QUERY SELECT NULL::uuid, 'owner_protected'::text, NULL::text, bound;
358
+ RETURN;
359
+ END IF;
360
+ INSERT INTO slotlock.scim_users (id, tenant_ref, user_name, user_name_key, external_id, active, given_name, family_name,
361
+ display_name, work_email, person_ref)
362
+ VALUES (requested_new_id, requested_tenant, requested_user_name, lower(requested_user_name), requested_external_id,
363
+ requested_active, requested_given_name, requested_family_name, requested_display_name,
364
+ lower(requested_work_email), bound)
365
+ RETURNING id INTO saved;
366
+ existing.active := true;
367
+ existing.person_ref := bound;
368
+ ELSE
369
+ SELECT * INTO existing FROM slotlock.scim_users found
370
+ WHERE found.id = requested_id AND found.tenant_ref = requested_tenant AND found.deleted_at IS NULL FOR UPDATE;
371
+ IF NOT FOUND THEN
372
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text, NULL::text, NULL::text;
373
+ RETURN;
374
+ END IF;
375
+ IF lower(requested_user_name) <> existing.user_name_key AND EXISTS (
376
+ SELECT 1 FROM slotlock.scim_users other WHERE other.tenant_ref = requested_tenant AND other.id <> existing.id
377
+ AND other.user_name_key = lower(requested_user_name) AND other.deleted_at IS NULL
378
+ ) THEN
379
+ RETURN QUERY SELECT NULL::uuid, 'uniqueness'::text, NULL::text, NULL::text;
380
+ RETURN;
381
+ END IF;
382
+ saved := existing.id;
383
+ bound := existing.person_ref;
384
+ END IF;
385
+ IF bound IS NOT NULL THEN
386
+ SELECT member.role INTO member_role FROM slotlock.workspace_memberships member
387
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = bound;
388
+ IF member_role = 'owner' AND NOT requested_active THEN
389
+ RETURN QUERY SELECT saved, 'owner_protected'::text, NULL::text, bound;
390
+ RETURN;
391
+ END IF;
392
+ END IF;
393
+ IF requested_id IS NOT NULL THEN
394
+ UPDATE slotlock.scim_users found
395
+ SET user_name = requested_user_name, user_name_key = lower(requested_user_name), external_id = requested_external_id,
396
+ active = requested_active, given_name = requested_given_name, family_name = requested_family_name,
397
+ display_name = requested_display_name, work_email = lower(requested_work_email), updated_at = statement_timestamp()
398
+ WHERE found.id = saved;
399
+ END IF;
400
+ IF bound IS NOT NULL AND member_role IS DISTINCT FROM 'owner' THEN
401
+ IF NOT requested_active AND existing.active THEN
402
+ UPDATE slotlock.workspace_memberships member
403
+ SET status = 'inactive', deactivated_at = statement_timestamp(), deactivated_by = 'scim', updated_at = statement_timestamp()
404
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = bound AND member.status = 'active';
405
+ effect := 'deactivated';
406
+ ELSIF requested_active THEN
407
+ -- Back in the directory: only a membership the directory itself ended returns, and not for an
408
+ -- address the team removed; for sessions begun from now on.
409
+ UPDATE slotlock.workspace_memberships member
410
+ SET status = 'active', deactivated_at = NULL, deactivated_by = NULL, sessions_valid_after = statement_timestamp(),
411
+ role = CASE WHEN member.role_managed_by = 'manual' THEN member.role ELSE ${ROLE_OF_USER('requested_tenant', 'saved')} END,
412
+ updated_at = statement_timestamp()
413
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = bound AND member.status = 'inactive'
414
+ AND member.deactivated_by = 'scim'
415
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_join_blocks blocked
416
+ WHERE blocked.tenant_ref = requested_tenant
417
+ AND blocked.email_sha256 = sha256(convert_to(address, 'UTF8')));
418
+ IF FOUND THEN
419
+ effect := 'activated';
420
+ END IF;
421
+ END IF;
422
+ END IF;
423
+ RETURN QUERY SELECT saved, NULL::text, effect, bound;
424
+ END
425
+ $slotlock_scim_put_user$;
426
+ REVOKE ALL ON FUNCTION ${fn[6]} FROM PUBLIC;
427
+
428
+ -- A team's directory users, filtered by one attribute (username, externalid, id, email: exact, the
429
+ -- user name case-insensitively), in a stable order, a page at a time, with the total.
430
+ CREATE OR REPLACE FUNCTION slotlock.scim_users_list(
431
+ requested_tenant text, requested_attribute text, requested_value text, requested_start integer, requested_count integer,
432
+ requested_id uuid)
433
+ RETURNS TABLE (user_id uuid, user_name text, external_id text, active boolean, given_name text, family_name text,
434
+ display_name text, work_email text, created_at timestamptz, updated_at timestamptz, total integer)
435
+ LANGUAGE sql
436
+ STABLE
437
+ SECURITY DEFINER
438
+ SET search_path = pg_catalog, slotlock, pg_temp
439
+ AS $slotlock_scim_users_list$
440
+ WITH matched AS (
441
+ SELECT found.* FROM slotlock.scim_users found
442
+ WHERE found.tenant_ref = requested_tenant AND found.deleted_at IS NULL
443
+ AND (requested_id IS NULL OR found.id = requested_id)
444
+ AND (requested_attribute IS NULL
445
+ OR (requested_attribute = 'username' AND found.user_name_key = lower(requested_value))
446
+ OR (requested_attribute = 'externalid' AND found.external_id = requested_value)
447
+ OR (requested_attribute = 'id' AND found.id::text = lower(requested_value))
448
+ OR (requested_attribute = 'email' AND found.work_email = lower(requested_value)))
449
+ )
450
+ SELECT matched.id, matched.user_name, matched.external_id, matched.active, matched.given_name, matched.family_name,
451
+ matched.display_name, matched.work_email, matched.created_at, matched.updated_at,
452
+ (SELECT count(*)::integer FROM matched)
453
+ FROM matched
454
+ ORDER BY matched.created_at, matched.id
455
+ OFFSET greatest(coalesce(requested_start, 1), 1) - 1
456
+ LIMIT least(greatest(coalesce(requested_count, 100), 0), 100);
457
+ $slotlock_scim_users_list$;
458
+ REVOKE ALL ON FUNCTION ${fn[7]} FROM PUBLIC;
459
+
460
+ -- Delete a directory user (Entra, 30 days after a soft delete): the membership ends, as on
461
+ -- deactivation; an Owner's is refused. A user made again with the same name gets a new id.
462
+ CREATE OR REPLACE FUNCTION slotlock.scim_delete_user(requested_tenant text, requested_id uuid)
463
+ RETURNS TABLE (refusal text, person text)
464
+ LANGUAGE plpgsql
465
+ VOLATILE
466
+ SECURITY DEFINER
467
+ SET search_path = pg_catalog, slotlock, pg_temp
468
+ AS $slotlock_scim_delete_user$
469
+ DECLARE
470
+ existing slotlock.scim_users%ROWTYPE;
471
+ BEGIN
472
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
473
+ SELECT * INTO existing FROM slotlock.scim_users found
474
+ WHERE found.id = requested_id AND found.tenant_ref = requested_tenant AND found.deleted_at IS NULL FOR UPDATE;
475
+ IF NOT FOUND THEN
476
+ RETURN QUERY SELECT 'not_found'::text, NULL::text;
477
+ RETURN;
478
+ END IF;
479
+ IF existing.person_ref IS NOT NULL AND EXISTS (
480
+ SELECT 1 FROM slotlock.workspace_memberships member
481
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = existing.person_ref AND member.role = 'owner'
482
+ ) THEN
483
+ RETURN QUERY SELECT 'owner_protected'::text, existing.person_ref;
484
+ RETURN;
485
+ END IF;
486
+ -- Deleted in the directory: nothing of the user is kept (their membership ends, by the directory).
487
+ DELETE FROM slotlock.scim_group_members membership WHERE membership.user_id = existing.id;
488
+ DELETE FROM slotlock.scim_users found WHERE found.id = existing.id;
489
+ IF existing.person_ref IS NOT NULL THEN
490
+ UPDATE slotlock.workspace_memberships member
491
+ SET status = 'inactive', deactivated_at = statement_timestamp(), deactivated_by = 'scim', updated_at = statement_timestamp()
492
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = existing.person_ref AND member.status = 'active';
493
+ END IF;
494
+ RETURN QUERY SELECT NULL::text, existing.person_ref;
495
+ END
496
+ $slotlock_scim_delete_user$;
497
+ REVOKE ALL ON FUNCTION ${fn[8]} FROM PUBLIC;
498
+
499
+ -- Create (requested_id null) or replace a group with its members, as scim.ts worked them out; a name
500
+ -- slotlock-role-<role> gives that role (never Owner). Each member's role is recomputed after: the
501
+ -- highest their groups give, for members the directory manages, never an Owner.
502
+ CREATE OR REPLACE FUNCTION slotlock.scim_put_group(
503
+ requested_tenant text, requested_id uuid, requested_new_id uuid, requested_display_name text, requested_external_id text,
504
+ requested_members uuid[])
505
+ RETURNS TABLE (group_id uuid, refusal text)
506
+ LANGUAGE plpgsql
507
+ VOLATILE
508
+ SECURITY DEFINER
509
+ SET search_path = pg_catalog, slotlock, pg_temp
510
+ AS $slotlock_scim_put_group$
511
+ DECLARE
512
+ saved uuid;
513
+ reserved text := substring(lower(requested_display_name) FROM '^slotlock-role-(admin|developer|member|viewer|billing)$');
514
+ affected uuid[];
515
+ BEGIN
516
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' AND ws.scim_enabled FOR UPDATE;
517
+ IF NOT FOUND THEN
518
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text;
519
+ RETURN;
520
+ END IF;
521
+ IF EXISTS (SELECT 1 FROM slotlock.scim_groups other
522
+ WHERE other.tenant_ref = requested_tenant AND other.display_name_key = lower(requested_display_name)
523
+ AND other.deleted_at IS NULL AND other.id IS DISTINCT FROM requested_id) THEN
524
+ RETURN QUERY SELECT NULL::uuid, 'uniqueness'::text;
525
+ RETURN;
526
+ END IF;
527
+ IF EXISTS (SELECT 1 FROM unnest(coalesce(requested_members, ARRAY[]::uuid[])) given
528
+ WHERE NOT EXISTS (SELECT 1 FROM slotlock.scim_users found
529
+ WHERE found.id = given AND found.tenant_ref = requested_tenant AND found.deleted_at IS NULL)) THEN
530
+ RETURN QUERY SELECT NULL::uuid, 'invalid_member'::text;
531
+ RETURN;
532
+ END IF;
533
+ IF requested_id IS NULL THEN
534
+ INSERT INTO slotlock.scim_groups (id, tenant_ref, display_name, display_name_key, external_id, role)
535
+ VALUES (requested_new_id, requested_tenant, requested_display_name, lower(requested_display_name), requested_external_id, reserved)
536
+ RETURNING id INTO saved;
537
+ ELSE
538
+ UPDATE slotlock.scim_groups found
539
+ SET display_name = requested_display_name, display_name_key = lower(requested_display_name),
540
+ external_id = requested_external_id,
541
+ role = CASE WHEN reserved IS NOT NULL THEN reserved
542
+ WHEN found.display_name_key ~ '^slotlock-role-' THEN NULL ELSE found.role END,
543
+ updated_at = statement_timestamp()
544
+ WHERE found.id = requested_id AND found.tenant_ref = requested_tenant AND found.deleted_at IS NULL
545
+ RETURNING found.id INTO saved;
546
+ IF saved IS NULL THEN
547
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text;
548
+ RETURN;
549
+ END IF;
550
+ END IF;
551
+ SELECT array_agg(DISTINCT member_id) INTO affected FROM (
552
+ SELECT membership.user_id AS member_id FROM slotlock.scim_group_members membership WHERE membership.group_id = saved
553
+ UNION SELECT unnest(coalesce(requested_members, ARRAY[]::uuid[]))) changed;
554
+ DELETE FROM slotlock.scim_group_members membership WHERE membership.group_id = saved;
555
+ INSERT INTO slotlock.scim_group_members (group_id, user_id)
556
+ SELECT saved, given FROM unnest(coalesce(requested_members, ARRAY[]::uuid[])) given ON CONFLICT DO NOTHING;
557
+ -- Every affected member the directory manages: the role their groups give now.
558
+ UPDATE slotlock.workspace_memberships member
559
+ SET role = ${ROLE_OF_USER('requested_tenant', 'directory.id')}, updated_at = statement_timestamp()
560
+ FROM slotlock.scim_users directory
561
+ WHERE directory.id = ANY (coalesce(affected, ARRAY[]::uuid[])) AND directory.person_ref = member.person_ref
562
+ AND member.tenant_ref = requested_tenant AND member.role_managed_by = 'scim' AND member.role <> 'owner';
563
+ RETURN QUERY SELECT saved, NULL::text;
564
+ END
565
+ $slotlock_scim_put_group$;
566
+ REVOKE ALL ON FUNCTION ${fn[9]} FROM PUBLIC;
567
+
568
+ CREATE OR REPLACE FUNCTION slotlock.scim_groups_list(
569
+ requested_tenant text, requested_attribute text, requested_value text, requested_start integer, requested_count integer,
570
+ requested_id uuid)
571
+ RETURNS TABLE (group_id uuid, display_name text, external_id text, members uuid[], created_at timestamptz,
572
+ updated_at timestamptz, total integer, mapped_role text)
573
+ LANGUAGE sql
574
+ STABLE
575
+ SECURITY DEFINER
576
+ SET search_path = pg_catalog, slotlock, pg_temp
577
+ AS $slotlock_scim_groups_list$
578
+ WITH matched AS (
579
+ SELECT found.* FROM slotlock.scim_groups found
580
+ WHERE found.tenant_ref = requested_tenant AND found.deleted_at IS NULL
581
+ AND (requested_id IS NULL OR found.id = requested_id)
582
+ AND (requested_attribute IS NULL
583
+ OR (requested_attribute = 'displayname' AND found.display_name_key = lower(requested_value))
584
+ OR (requested_attribute = 'externalid' AND found.external_id = requested_value)
585
+ OR (requested_attribute = 'id' AND found.id::text = lower(requested_value)))
586
+ )
587
+ SELECT matched.id, matched.display_name, matched.external_id,
588
+ coalesce((SELECT array_agg(membership.user_id ORDER BY membership.user_id) FROM slotlock.scim_group_members membership
589
+ WHERE membership.group_id = matched.id), ARRAY[]::uuid[]),
590
+ matched.created_at, matched.updated_at, (SELECT count(*)::integer FROM matched), matched.role
591
+ FROM matched
592
+ ORDER BY matched.created_at, matched.id
593
+ OFFSET greatest(coalesce(requested_start, 1), 1) - 1
594
+ LIMIT least(greatest(coalesce(requested_count, 100), 0), 100);
595
+ $slotlock_scim_groups_list$;
596
+ REVOKE ALL ON FUNCTION ${fn[10]} FROM PUBLIC;
597
+
598
+ -- Delete a group: its members' roles fall back to what their other groups give; nobody is deactivated.
599
+ CREATE OR REPLACE FUNCTION slotlock.scim_delete_group(requested_tenant text, requested_id uuid)
600
+ RETURNS text
601
+ LANGUAGE plpgsql
602
+ VOLATILE
603
+ SECURITY DEFINER
604
+ SET search_path = pg_catalog, slotlock, pg_temp
605
+ AS $slotlock_scim_delete_group$
606
+ DECLARE
607
+ affected uuid[];
608
+ BEGIN
609
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
610
+ SELECT array_agg(membership.user_id) INTO affected FROM slotlock.scim_group_members membership WHERE membership.group_id = requested_id;
611
+ UPDATE slotlock.scim_groups found SET deleted_at = statement_timestamp()
612
+ WHERE found.id = requested_id AND found.tenant_ref = requested_tenant AND found.deleted_at IS NULL;
613
+ IF NOT FOUND THEN
614
+ RETURN 'not_found';
615
+ END IF;
616
+ DELETE FROM slotlock.scim_group_members membership WHERE membership.group_id = requested_id;
617
+ UPDATE slotlock.workspace_memberships member
618
+ SET role = ${ROLE_OF_USER('requested_tenant', 'directory.id')}, updated_at = statement_timestamp()
619
+ FROM slotlock.scim_users directory
620
+ WHERE directory.id = ANY (coalesce(affected, ARRAY[]::uuid[])) AND directory.person_ref = member.person_ref
621
+ AND member.tenant_ref = requested_tenant AND member.role_managed_by = 'scim' AND member.role <> 'owner';
622
+ RETURN NULL;
623
+ END
624
+ $slotlock_scim_delete_group$;
625
+ REVOKE ALL ON FUNCTION ${fn[11]} FROM PUBLIC;
626
+
627
+ -- Map any pushed group to a role (never Owner), by an Owner; null unmaps it.
628
+ CREATE OR REPLACE FUNCTION slotlock.map_scim_group_role(requested_tenant text, requested_actor text, requested_id uuid, requested_role text)
629
+ RETURNS text
630
+ LANGUAGE plpgsql
631
+ VOLATILE
632
+ SECURITY DEFINER
633
+ SET search_path = pg_catalog, slotlock, pg_temp
634
+ AS $slotlock_map_scim_group_role$
635
+ DECLARE
636
+ actor_role text;
637
+ BEGIN
638
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
639
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
640
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
641
+ IF actor_role IS DISTINCT FROM 'owner' THEN
642
+ RETURN 'not_allowed';
643
+ END IF;
644
+ IF requested_role = 'owner' THEN
645
+ RETURN 'owner_not_mappable';
646
+ END IF;
647
+ IF requested_role IS NOT NULL AND requested_role NOT IN ('admin', 'developer', 'member', 'viewer', 'billing') THEN
648
+ RETURN 'role_not_allowed';
649
+ END IF;
650
+ UPDATE slotlock.scim_groups found SET role = requested_role, updated_at = statement_timestamp()
651
+ WHERE found.id = requested_id AND found.tenant_ref = requested_tenant AND found.deleted_at IS NULL;
652
+ IF NOT FOUND THEN
653
+ RETURN 'not_found';
654
+ END IF;
655
+ UPDATE slotlock.workspace_memberships member
656
+ SET role = ${ROLE_OF_USER('requested_tenant', 'directory.id')}, updated_at = statement_timestamp()
657
+ FROM slotlock.scim_users directory
658
+ JOIN slotlock.scim_group_members membership ON membership.user_id = directory.id AND membership.group_id = requested_id
659
+ WHERE directory.person_ref = member.person_ref
660
+ AND member.tenant_ref = requested_tenant AND member.role_managed_by = 'scim' AND member.role <> 'owner';
661
+ RETURN NULL;
662
+ END
663
+ $slotlock_map_scim_group_role$;
664
+ REVOKE ALL ON FUNCTION ${fn[12]} FROM PUBLIC;
665
+
666
+ -- A sign-in holding addresses the domain itself vouches for (the caller passes only those: a Google
667
+ -- Workspace account of the domain, or an address the team's own provider sent): each active directory
668
+ -- user of a team whose directory is on, not bound yet, at one of those addresses, binds to the person,
669
+ -- who becomes a member (the role their groups give), unless the team removed them or that address.
670
+ -- Returns the teams joined.
671
+ CREATE OR REPLACE FUNCTION slotlock.scim_bind(requested_person text, requested_emails text[], requested_free_cap integer)
672
+ RETURNS TABLE (team_ref text)
673
+ LANGUAGE plpgsql
674
+ VOLATILE
675
+ SECURITY DEFINER
676
+ SET search_path = pg_catalog, slotlock, pg_temp
677
+ AS $slotlock_scim_bind$
678
+ DECLARE
679
+ directory slotlock.scim_users%ROWTYPE;
680
+ BEGIN
681
+ IF requested_person IS NULL OR requested_person !~ '${PERSON}' THEN
682
+ RETURN;
683
+ END IF;
684
+ FOR directory IN
685
+ SELECT found.* FROM slotlock.scim_users found
686
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = found.tenant_ref AND ws.status = 'active' AND ws.scim_enabled
687
+ WHERE found.deleted_at IS NULL AND found.active AND found.person_ref IS NULL
688
+ AND coalesce(found.work_email, found.user_name_key) IN (SELECT lower(btrim(given)) FROM unnest(coalesce(requested_emails, ARRAY[]::text[])) given)
689
+ AND NOT EXISTS (SELECT 1 FROM slotlock.scim_users taken
690
+ WHERE taken.tenant_ref = found.tenant_ref AND taken.person_ref = requested_person AND taken.deleted_at IS NULL)
691
+ -- At a domain the team still holds verified (a released or lapsed one may be someone else's).
692
+ AND EXISTS (SELECT 1 FROM slotlock.workspace_domains claim
693
+ WHERE claim.tenant_ref = found.tenant_ref AND claim.status = 'verified'
694
+ AND claim.domain_ascii = lower(substring(coalesce(found.work_email, found.user_name_key) FROM '@([^@]+)$')))
695
+ -- Someone the team removed, or who left, or whose address it removed or declined, is not
696
+ -- brought back; one the directory itself ended is.
697
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships removed
698
+ WHERE removed.tenant_ref = found.tenant_ref AND removed.person_ref = requested_person
699
+ AND removed.status = 'inactive' AND removed.deactivated_by IS DISTINCT FROM 'scim')
700
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_join_blocks blocked
701
+ WHERE blocked.tenant_ref = found.tenant_ref
702
+ AND blocked.email_sha256 = sha256(convert_to(coalesce(found.work_email, found.user_name_key), 'UTF8')))
703
+ ORDER BY found.created_at
704
+ LIMIT 20
705
+ LOOP
706
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = directory.tenant_ref FOR UPDATE;
707
+ -- A team on the free plan holds its member cap (the caller passes it where plans apply).
708
+ IF requested_free_cap IS NOT NULL
709
+ AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans billed WHERE billed.tenant_ref = directory.tenant_ref
710
+ AND billed.plan <> 'free' AND billed.status <> 'canceled')
711
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships mine
712
+ WHERE mine.tenant_ref = directory.tenant_ref AND mine.person_ref = requested_person AND mine.status = 'active')
713
+ AND (SELECT count(*) FROM slotlock.workspace_memberships member
714
+ WHERE member.tenant_ref = directory.tenant_ref AND member.status = 'active')
715
+ + (SELECT count(*) FROM slotlock.workspace_invitations pending
716
+ WHERE pending.tenant_ref = directory.tenant_ref AND pending.accepted_at IS NULL
717
+ AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp()) >= requested_free_cap THEN
718
+ CONTINUE;
719
+ END IF;
720
+ UPDATE slotlock.scim_users found SET person_ref = requested_person WHERE found.id = directory.id AND found.person_ref IS NULL;
721
+ IF FOUND THEN
722
+ INSERT INTO slotlock.workspace_memberships AS member
723
+ (tenant_ref, person_ref, role, source, role_managed_by, joined_email)
724
+ VALUES (directory.tenant_ref, requested_person, ${ROLE_OF_USER('directory.tenant_ref', 'directory.id')}, 'scim', 'scim',
725
+ coalesce(directory.work_email, directory.user_name_key))
726
+ ON CONFLICT ON CONSTRAINT slotlock_workspace_memberships_pkey DO UPDATE
727
+ SET status = 'active', deactivated_at = NULL, deactivated_by = NULL,
728
+ sessions_valid_after = CASE WHEN member.status = 'inactive' THEN statement_timestamp() ELSE member.sessions_valid_after END,
729
+ -- The directory decides the role from now on, unless an Owner set it by hand, or it is an Owner's.
730
+ role_managed_by = CASE WHEN member.role_managed_by = 'manual' OR member.role = 'owner' THEN member.role_managed_by ELSE 'scim' END,
731
+ role = CASE WHEN member.role_managed_by = 'manual' OR member.role = 'owner' THEN member.role ELSE excluded.role END,
732
+ updated_at = statement_timestamp();
733
+ team_ref := directory.tenant_ref;
734
+ RETURN NEXT;
735
+ END IF;
736
+ END LOOP;
737
+ END
738
+ $slotlock_scim_bind$;
739
+ REVOKE ALL ON FUNCTION ${fn[13]} FROM PUBLIC;
740
+ `;