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.
- package/CHANGELOG.md +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- 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
|
+
`;
|