slotlock 0.8.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 (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
package/ddl-sso.js ADDED
@@ -0,0 +1,1033 @@
1
+ // Teams phase 5.3–5.5: single sign-on through a team's own identity provider (OpenID Connect; the
2
+ // tokens are checked in oidc.ts, before any of this is called). Reached only through the SECURITY
3
+ // DEFINER functions below, as the rest of the dashboard's records are.
4
+ //
5
+ // The rules the database holds, whatever the caller does:
6
+ // - An identity is the provider's subject (issuer and `sub`; Entra's tenant and object id too),
7
+ // never an email. A sign-in never attaches to an existing person by email.
8
+ // - An email from a team's provider is trusted only at a domain that same team has verified.
9
+ // - A person signed in through a team's provider alone acts in that team alone: their session is
10
+ // scoped to it (dashboard_sessions.sso_connection_id), so the team's provider, which its Owner
11
+ // controls, never reaches the person's other workspaces.
12
+ // - A team that requires single sign-on gives no role to a session without a recent proof from its
13
+ // provider, or an Owner's bypass; dashboard_standing says so, so a caller that forgets fails closed.
14
+ // - The provider never makes an Owner, and never changes one.
15
+ import { SLOTLOCK_PERSON_PATTERN } from './ddl-identity.js';
16
+ const PERSON = SLOTLOCK_PERSON_PATTERN;
17
+ /** The tables of single sign-on: their owner's only. */
18
+ export const SLOTLOCK_SSO_TABLES = Object.freeze([
19
+ 'sso_connections',
20
+ 'sso_identities',
21
+ 'sso_role_mappings',
22
+ 'dashboard_session_sso',
23
+ 'sso_bypasses',
24
+ ]);
25
+ /** The SECURITY DEFINER functions through which the serving role reaches them. */
26
+ export const SLOTLOCK_SSO_FUNCTIONS = Object.freeze([
27
+ 'slotlock.save_sso_connection(text, text, uuid, text, text, text, text, text, text, text, text, text[], boolean, text)',
28
+ 'slotlock.sso_connection_of(text)',
29
+ 'slotlock.sso_connection_by_id(uuid)',
30
+ 'slotlock.sso_connection_route(text, text)',
31
+ 'slotlock.record_sso_test(text, text, uuid, integer, jsonb)',
32
+ 'slotlock.set_sso_connection_status(text, text, uuid, text)',
33
+ 'slotlock.delete_sso_connection(text, text, uuid)',
34
+ 'slotlock.set_sso_provisioning(text, text, boolean, text, text)',
35
+ 'slotlock.set_sso_role_mapping(text, text, text, text)',
36
+ 'slotlock.sso_role_mappings_of(text)',
37
+ 'slotlock.sso_sign_in(uuid, text, text, text, text, text, boolean, text[], boolean, boolean, text, integer)',
38
+ 'slotlock.record_sso_proof(bytea, text, uuid, boolean, integer)',
39
+ 'slotlock.set_require_sso(text, text, bytea, boolean, integer)',
40
+ 'slotlock.sso_owner_bypass(text, text, bytea)',
41
+ 'slotlock.prune_sso()',
42
+ ]);
43
+ /** How long a proof from a team's provider lasts by default, and the range an Owner may set. */
44
+ export const SLOTLOCK_SSO_MAX_AGE_HOURS = Object.freeze({ default: 24, min: 1, max: 168 });
45
+ /** How fresh a proof must be to turn "require single sign-on" on. */
46
+ export const SLOTLOCK_SSO_ENABLE_PROOF_MINUTES = 10;
47
+ /** How long an Owner's bypass lasts. */
48
+ export const SLOTLOCK_SSO_BYPASS_MINUTES = 60;
49
+ const fn = SLOTLOCK_SSO_FUNCTIONS;
50
+ const NOT_SSO = `!~ '^sso:'`;
51
+ export const SLOTLOCK_SSO_DDL = `
52
+ -- A team's connection to its identity provider: one per team. The client secret is sealed by the
53
+ -- application (AES-256-GCM, oidc.ts) with the team and connection as associated data; only its last
54
+ -- four characters are kept in the clear. The endpoints are the provider's discovery document's when
55
+ -- the Owner saved it; a change to the issuer, client or secret returns it to draft until tested.
56
+ CREATE TABLE IF NOT EXISTS slotlock.sso_connections (
57
+ id uuid PRIMARY KEY,
58
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
59
+ kind text NOT NULL,
60
+ status text NOT NULL DEFAULT 'draft',
61
+ issuer text NOT NULL,
62
+ client_id text NOT NULL,
63
+ secret_sealed text,
64
+ secret_last4 text,
65
+ authorization_endpoint text NOT NULL,
66
+ token_endpoint text NOT NULL,
67
+ jwks_uri text NOT NULL,
68
+ algorithms text[] NOT NULL,
69
+ iss_parameter boolean NOT NULL DEFAULT false,
70
+ entra_tenant text,
71
+ groups_claim text NOT NULL DEFAULT 'groups',
72
+ jit_enabled boolean NOT NULL DEFAULT true,
73
+ default_role text NOT NULL DEFAULT 'viewer',
74
+ config_version integer NOT NULL DEFAULT 1,
75
+ tested_version integer,
76
+ last_tested_at timestamptz,
77
+ last_test_claims jsonb,
78
+ tested_by text,
79
+ created_by text,
80
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
81
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
82
+ CONSTRAINT slotlock_sso_connections_tenant_key UNIQUE (tenant_ref),
83
+ CONSTRAINT slotlock_sso_connections_valid CHECK (
84
+ kind IN ('okta', 'entra', 'google_workspace', 'oidc')
85
+ AND status IN ('draft', 'tested', 'active', 'disabled')
86
+ AND char_length(issuer) <= 500 AND issuer ~ '^https://[^[:space:][:cntrl:]]+$'
87
+ AND char_length(client_id) BETWEEN 1 AND 255 AND client_id !~ '[[:space:][:cntrl:]]'
88
+ AND (secret_sealed IS NULL OR char_length(secret_sealed) BETWEEN 1 AND 2048)
89
+ AND (secret_last4 IS NULL OR char_length(secret_last4) BETWEEN 1 AND 4)
90
+ AND authorization_endpoint ~ '^https://' AND token_endpoint ~ '^https://' AND jwks_uri ~ '^https://'
91
+ AND cardinality(algorithms) BETWEEN 1 AND 3 AND algorithms <@ ARRAY['RS256', 'PS256', 'ES256']
92
+ AND (entra_tenant IS NULL OR entra_tenant ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
93
+ AND (kind <> 'entra' OR entra_tenant IS NOT NULL)
94
+ AND groups_claim ~ '^[A-Za-z0-9_:./-]{1,100}$'
95
+ AND default_role IN ('viewer', 'member', 'developer')
96
+ AND config_version >= 1
97
+ AND (created_by IS NULL OR created_by ~ '${PERSON}')
98
+ AND (tested_by IS NULL OR tested_by ~ '${PERSON}')
99
+ AND (last_test_claims IS NULL OR (jsonb_typeof(last_test_claims) = 'object' AND pg_column_size(last_test_claims) <= 8192))
100
+ )
101
+ );
102
+ ALTER TABLE slotlock.sso_connections ENABLE ROW LEVEL SECURITY;
103
+ REVOKE ALL ON slotlock.sso_connections FROM PUBLIC;
104
+
105
+ -- Who the provider says signed in: its subject, under the connection, and the person it is.
106
+ CREATE TABLE IF NOT EXISTS slotlock.sso_identities (
107
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
108
+ connection_id uuid NOT NULL REFERENCES slotlock.sso_connections (id) ON DELETE CASCADE,
109
+ issuer text NOT NULL,
110
+ subject text NOT NULL,
111
+ entra_tid text,
112
+ entra_oid text,
113
+ person_ref text NOT NULL,
114
+ email_last text,
115
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
116
+ last_sso_at timestamptz NOT NULL DEFAULT statement_timestamp(),
117
+ -- When the person linked another account in its place: kept, so its address stays theirs (a
118
+ -- removal blocks it) and it signs in as nobody.
119
+ replaced_at timestamptz,
120
+ CONSTRAINT slotlock_sso_identities_subject_key UNIQUE (connection_id, issuer, subject),
121
+ CONSTRAINT slotlock_sso_identities_valid CHECK (
122
+ char_length(subject) BETWEEN 1 AND 255
123
+ AND person_ref ~ '${PERSON}'
124
+ AND (email_last IS NULL OR char_length(email_last) BETWEEN 3 AND 254)
125
+ AND (entra_oid IS NULL) = (entra_tid IS NULL)
126
+ )
127
+ );
128
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_sso_identities_entra_key
129
+ ON slotlock.sso_identities (connection_id, entra_tid, entra_oid) WHERE entra_oid IS NOT NULL;
130
+ CREATE INDEX IF NOT EXISTS slotlock_sso_identities_person_idx ON slotlock.sso_identities (person_ref);
131
+ -- One account at a team's provider a person: linking another replaces it.
132
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_sso_identities_person_key ON slotlock.sso_identities (connection_id, person_ref)
133
+ WHERE replaced_at IS NULL;
134
+ ALTER TABLE slotlock.sso_identities ENABLE ROW LEVEL SECURITY;
135
+ REVOKE ALL ON slotlock.sso_identities FROM PUBLIC;
136
+
137
+ -- What a provider group (or app role) gives: any role but Owner.
138
+ CREATE TABLE IF NOT EXISTS slotlock.sso_role_mappings (
139
+ connection_id uuid NOT NULL REFERENCES slotlock.sso_connections (id) ON DELETE CASCADE,
140
+ idp_value text NOT NULL,
141
+ role text NOT NULL,
142
+ CONSTRAINT slotlock_sso_role_mappings_pkey PRIMARY KEY (connection_id, idp_value),
143
+ CONSTRAINT slotlock_sso_role_mappings_valid CHECK (
144
+ char_length(idp_value) BETWEEN 1 AND 255 AND idp_value !~ '[[:cntrl:]]'
145
+ AND role IN ('admin', 'developer', 'member', 'viewer', 'billing')
146
+ )
147
+ );
148
+ ALTER TABLE slotlock.sso_role_mappings ENABLE ROW LEVEL SECURITY;
149
+ REVOKE ALL ON slotlock.sso_role_mappings FROM PUBLIC;
150
+
151
+ -- A session's scope (the connection it was signed in through, when signed in through one alone),
152
+ -- and its proofs: when a team's provider last vouched for the person in this session.
153
+ ALTER TABLE slotlock.dashboard_sessions ADD COLUMN IF NOT EXISTS sso_connection_id uuid
154
+ REFERENCES slotlock.sso_connections (id) ON DELETE CASCADE;
155
+ CREATE TABLE IF NOT EXISTS slotlock.dashboard_session_sso (
156
+ sid_sha256 bytea NOT NULL REFERENCES slotlock.dashboard_sessions (sid_sha256) ON DELETE CASCADE,
157
+ connection_id uuid NOT NULL REFERENCES slotlock.sso_connections (id) ON DELETE CASCADE,
158
+ person_ref text NOT NULL,
159
+ authenticated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
160
+ CONSTRAINT slotlock_dashboard_session_sso_pkey PRIMARY KEY (sid_sha256, connection_id),
161
+ CONSTRAINT slotlock_dashboard_session_sso_valid CHECK (person_ref ~ '${PERSON}')
162
+ );
163
+ ALTER TABLE slotlock.dashboard_session_sso ENABLE ROW LEVEL SECURITY;
164
+ REVOKE ALL ON slotlock.dashboard_session_sso FROM PUBLIC;
165
+
166
+ -- An Owner's way in while the provider is down: one session, one team, an hour.
167
+ CREATE TABLE IF NOT EXISTS slotlock.sso_bypasses (
168
+ sid_sha256 bytea NOT NULL REFERENCES slotlock.dashboard_sessions (sid_sha256) ON DELETE CASCADE,
169
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
170
+ person_ref text NOT NULL,
171
+ granted_at timestamptz NOT NULL DEFAULT statement_timestamp(),
172
+ expires_at timestamptz NOT NULL,
173
+ CONSTRAINT slotlock_sso_bypasses_pkey PRIMARY KEY (sid_sha256, tenant_ref),
174
+ CONSTRAINT slotlock_sso_bypasses_valid CHECK (person_ref ~ '${PERSON}' AND expires_at > granted_at)
175
+ );
176
+ ALTER TABLE slotlock.sso_bypasses ENABLE ROW LEVEL SECURITY;
177
+ REVOKE ALL ON slotlock.sso_bypasses FROM PUBLIC;
178
+
179
+ -- Whether a team requires single sign-on, and how long a proof lasts.
180
+ ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS require_sso boolean NOT NULL DEFAULT false;
181
+ ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS sso_max_age_hours integer NOT NULL DEFAULT ${SLOTLOCK_SSO_MAX_AGE_HOURS.default}
182
+ CONSTRAINT slotlock_workspaces_sso_max_age_valid
183
+ CHECK (sso_max_age_hours BETWEEN ${SLOTLOCK_SSO_MAX_AGE_HOURS.min} AND ${SLOTLOCK_SSO_MAX_AGE_HOURS.max});
184
+
185
+ -- Who decides a member's role: Slotlock (manual, the default; a role changed by hand stays so), the
186
+ -- team's provider at each sign-in (sso: a member it provisioned), or its directory's groups (scim).
187
+ -- The role column is read by dashboard_standing below, and sessions_valid_after (ddl-scim.ts) too.
188
+ ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS role_managed_by text NOT NULL DEFAULT 'manual'
189
+ CONSTRAINT slotlock_workspace_memberships_managed_valid CHECK (role_managed_by IN ('manual', 'sso', 'scim'));
190
+
191
+ -- A person's standing in a workspace now, for this session: their role (Owner of their own; else an
192
+ -- active membership's, or none) and their sessions' epoch. None, with why, when the session is scoped
193
+ -- to another team's provider or to one turned off (sso_scope), or the team requires single sign-on and the session holds
194
+ -- no proof younger than the team allows and no Owner's bypass (sso_required: may_bypass says whether
195
+ -- this person may use one). A person signed in through a provider (sso:) has no workspace of their
196
+ -- own. A test environment is its live tenant's.
197
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_standing(requested_tenant text, requested_person text, requested_sid bytea)
198
+ RETURNS TABLE (role text, session_epoch integer, refusal text, may_bypass boolean)
199
+ LANGUAGE plpgsql
200
+ STABLE
201
+ SECURITY DEFINER
202
+ SET search_path = pg_catalog, slotlock, pg_temp
203
+ AS $slotlock_dashboard_standing$
204
+ DECLARE
205
+ live text := pg_catalog.regexp_replace(requested_tenant, '~test$', '');
206
+ found_role text;
207
+ epoch integer;
208
+ scoped uuid;
209
+ scoped_tenant text;
210
+ scoped_status text;
211
+ started timestamptz;
212
+ valid_after timestamptz;
213
+ enforced boolean;
214
+ max_age integer;
215
+ connection uuid;
216
+ BEGIN
217
+ SELECT person.session_epoch INTO epoch FROM slotlock.dashboard_people person WHERE person.person_ref = requested_person;
218
+ IF live = requested_person AND requested_person ${NOT_SSO} THEN
219
+ found_role := 'owner';
220
+ ELSE
221
+ SELECT member.role, member.sessions_valid_after INTO found_role, valid_after FROM slotlock.workspace_memberships member
222
+ WHERE member.tenant_ref = live AND member.person_ref = requested_person AND member.status = 'active';
223
+ END IF;
224
+ IF requested_sid IS NOT NULL THEN
225
+ SELECT kept.sso_connection_id, kept.started_at INTO scoped, started FROM slotlock.dashboard_sessions kept
226
+ WHERE kept.sid_sha256 = requested_sid AND kept.person_ref = requested_person;
227
+ -- A person a team's provider made signs in only through it: a session of theirs it did not
228
+ -- scope (its proof refused while it was turned off, say) is nobody's.
229
+ IF scoped IS NULL AND requested_person ~ '^sso:' THEN
230
+ RETURN QUERY SELECT NULL::text, epoch, 'sso_scope'::text, false;
231
+ RETURN;
232
+ END IF;
233
+ IF scoped IS NOT NULL THEN
234
+ SELECT linked.tenant_ref, linked.status INTO scoped_tenant, scoped_status
235
+ FROM slotlock.sso_connections linked WHERE linked.id = scoped;
236
+ -- Another team's provider, or this team's turned off since: nothing.
237
+ IF scoped_tenant IS DISTINCT FROM live OR scoped_status IS DISTINCT FROM 'active' THEN
238
+ RETURN QUERY SELECT NULL::text, epoch, 'sso_scope'::text, false;
239
+ RETURN;
240
+ END IF;
241
+ END IF;
242
+ END IF;
243
+ -- A membership the directory restored counts only for sessions begun after (ddl-scim.ts).
244
+ IF found_role IS NOT NULL AND valid_after IS NOT NULL AND (started IS NULL OR started < valid_after) THEN
245
+ RETURN QUERY SELECT NULL::text, epoch, NULL::text, false;
246
+ RETURN;
247
+ END IF;
248
+ IF found_role IS NOT NULL THEN
249
+ SELECT ws.require_sso, ws.sso_max_age_hours INTO enforced, max_age FROM slotlock.workspaces ws WHERE ws.tenant_ref = live;
250
+ IF coalesce(enforced, false) THEN
251
+ SELECT linked.id INTO connection FROM slotlock.sso_connections linked
252
+ WHERE linked.tenant_ref = live AND linked.status = 'active';
253
+ IF requested_sid IS NULL OR NOT (
254
+ EXISTS (SELECT 1 FROM slotlock.dashboard_session_sso proof
255
+ WHERE proof.sid_sha256 = requested_sid AND proof.connection_id = connection
256
+ AND proof.person_ref = requested_person
257
+ AND proof.authenticated_at > statement_timestamp() - make_interval(hours => max_age))
258
+ OR EXISTS (SELECT 1 FROM slotlock.sso_bypasses bypass
259
+ WHERE bypass.sid_sha256 = requested_sid AND bypass.tenant_ref = live
260
+ AND bypass.person_ref = requested_person AND bypass.expires_at > statement_timestamp())
261
+ ) THEN
262
+ RETURN QUERY SELECT NULL::text, epoch, 'sso_required'::text, (found_role = 'owner' AND requested_person ${NOT_SSO});
263
+ RETURN;
264
+ END IF;
265
+ END IF;
266
+ END IF;
267
+ RETURN QUERY SELECT found_role, epoch, NULL::text, false;
268
+ END
269
+ $slotlock_dashboard_standing$;
270
+ REVOKE ALL ON FUNCTION slotlock.dashboard_standing(text, text, bytea) FROM PUBLIC;
271
+
272
+ -- Save a team's connection, by its Owner, while the team has a verified domain. A change to the
273
+ -- issuer, client or secret (a new sealed secret) makes it a new configuration: draft until tested,
274
+ -- "require single sign-on" off (which enforcement_off reports for the audit log), and the sessions
275
+ -- and proofs the old one vouched for ended.
276
+ CREATE OR REPLACE FUNCTION slotlock.save_sso_connection(
277
+ requested_tenant text, requested_actor text, requested_id uuid, requested_kind text, requested_issuer text,
278
+ requested_client_id text, requested_secret_sealed text, requested_secret_last4 text,
279
+ requested_authorization_endpoint text, requested_token_endpoint text, requested_jwks_uri text,
280
+ requested_algorithms text[], requested_iss_parameter boolean, requested_entra_tenant text)
281
+ RETURNS TABLE (connection_id uuid, refusal text, enforcement_off boolean)
282
+ LANGUAGE plpgsql
283
+ VOLATILE
284
+ SECURITY DEFINER
285
+ SET search_path = pg_catalog, slotlock, pg_temp
286
+ AS $slotlock_save_sso_connection$
287
+ DECLARE
288
+ actor_role text;
289
+ existing slotlock.sso_connections%ROWTYPE;
290
+ material boolean;
291
+ was_enforced boolean;
292
+ BEGIN
293
+ SELECT ws.require_sso INTO was_enforced FROM slotlock.workspaces ws
294
+ WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
295
+ IF NOT FOUND THEN
296
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text, false;
297
+ RETURN;
298
+ END IF;
299
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
300
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
301
+ IF actor_role IS DISTINCT FROM 'owner' THEN
302
+ RETURN QUERY SELECT NULL::uuid, 'not_allowed'::text, false;
303
+ RETURN;
304
+ END IF;
305
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_domains claim
306
+ WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified') THEN
307
+ RETURN QUERY SELECT NULL::uuid, 'no_verified_domain'::text, false;
308
+ RETURN;
309
+ END IF;
310
+ SELECT * INTO existing FROM slotlock.sso_connections linked WHERE linked.tenant_ref = requested_tenant FOR UPDATE;
311
+ IF NOT FOUND THEN
312
+ IF requested_kind <> 'google_workspace' AND requested_secret_sealed IS NULL THEN
313
+ RETURN QUERY SELECT NULL::uuid, 'secret_required'::text, false;
314
+ RETURN;
315
+ END IF;
316
+ INSERT INTO slotlock.sso_connections
317
+ (id, tenant_ref, kind, issuer, client_id, secret_sealed, secret_last4, authorization_endpoint, token_endpoint,
318
+ jwks_uri, algorithms, iss_parameter, entra_tenant, created_by)
319
+ VALUES (requested_id, requested_tenant, requested_kind, requested_issuer, requested_client_id, requested_secret_sealed,
320
+ requested_secret_last4, requested_authorization_endpoint, requested_token_endpoint, requested_jwks_uri,
321
+ requested_algorithms, coalesce(requested_iss_parameter, false), requested_entra_tenant, requested_actor);
322
+ RETURN QUERY SELECT requested_id, NULL::text, false;
323
+ RETURN;
324
+ END IF;
325
+ IF existing.id <> requested_id THEN
326
+ RETURN QUERY SELECT existing.id, 'conflict'::text, false;
327
+ RETURN;
328
+ END IF;
329
+ material := existing.kind <> requested_kind OR existing.issuer <> requested_issuer
330
+ OR existing.client_id <> requested_client_id OR requested_secret_sealed IS NOT NULL
331
+ OR existing.entra_tenant IS DISTINCT FROM requested_entra_tenant;
332
+ UPDATE slotlock.sso_connections linked
333
+ SET kind = requested_kind, issuer = requested_issuer, client_id = requested_client_id,
334
+ secret_sealed = coalesce(requested_secret_sealed, linked.secret_sealed),
335
+ secret_last4 = CASE WHEN requested_secret_sealed IS NULL THEN linked.secret_last4 ELSE requested_secret_last4 END,
336
+ authorization_endpoint = requested_authorization_endpoint, token_endpoint = requested_token_endpoint,
337
+ jwks_uri = requested_jwks_uri, algorithms = requested_algorithms,
338
+ iss_parameter = coalesce(requested_iss_parameter, false), entra_tenant = requested_entra_tenant,
339
+ config_version = linked.config_version + CASE WHEN material THEN 1 ELSE 0 END,
340
+ status = CASE WHEN material THEN 'draft' ELSE linked.status END,
341
+ updated_at = statement_timestamp()
342
+ WHERE linked.id = existing.id;
343
+ IF material AND was_enforced THEN
344
+ UPDATE slotlock.workspaces ws SET require_sso = false WHERE ws.tenant_ref = requested_tenant;
345
+ END IF;
346
+ IF material THEN
347
+ UPDATE slotlock.dashboard_sessions kept SET ended_at = statement_timestamp()
348
+ WHERE kept.sso_connection_id = existing.id AND kept.ended_at IS NULL;
349
+ DELETE FROM slotlock.dashboard_session_sso proof WHERE proof.connection_id = existing.id;
350
+ END IF;
351
+ RETURN QUERY SELECT existing.id, NULL::text, coalesce(material AND was_enforced, false);
352
+ END
353
+ $slotlock_save_sso_connection$;
354
+ REVOKE ALL ON FUNCTION ${fn[0]} FROM PUBLIC;
355
+
356
+ -- A team's connection, as its Identity page and sign-in read it (the sealed secret included: the
357
+ -- application opens it to sign in), with whether the team requires single sign-on.
358
+ CREATE OR REPLACE FUNCTION slotlock.sso_connection_of(requested_tenant text)
359
+ RETURNS TABLE (connection_id uuid, kind text, status text, issuer text, client_id text, secret_sealed text,
360
+ secret_last4 text, authorization_endpoint text, token_endpoint text, jwks_uri text,
361
+ algorithms text[], iss_parameter boolean, entra_tenant text, groups_claim text,
362
+ jit_enabled boolean, default_role text, config_version integer, tested_version integer,
363
+ last_tested_at timestamptz, last_test_claims jsonb, require_sso boolean, sso_max_age_hours integer)
364
+ LANGUAGE sql
365
+ STABLE
366
+ SECURITY DEFINER
367
+ SET search_path = pg_catalog, slotlock, pg_temp
368
+ AS $slotlock_sso_connection_of$
369
+ SELECT linked.id, linked.kind, linked.status, linked.issuer, linked.client_id, linked.secret_sealed,
370
+ linked.secret_last4, linked.authorization_endpoint, linked.token_endpoint, linked.jwks_uri,
371
+ linked.algorithms, linked.iss_parameter, linked.entra_tenant, linked.groups_claim, linked.jit_enabled,
372
+ linked.default_role, linked.config_version, linked.tested_version, linked.last_tested_at,
373
+ linked.last_test_claims, ws.require_sso, ws.sso_max_age_hours
374
+ FROM slotlock.sso_connections linked
375
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = linked.tenant_ref
376
+ WHERE linked.tenant_ref = requested_tenant;
377
+ $slotlock_sso_connection_of$;
378
+ REVOKE ALL ON FUNCTION ${fn[1]} FROM PUBLIC;
379
+
380
+ -- A connection by its id, for its callback: with its team, while the team is active.
381
+ CREATE OR REPLACE FUNCTION slotlock.sso_connection_by_id(requested_id uuid)
382
+ RETURNS TABLE (team_ref text, handle text, name text, kind text, status text, issuer text, client_id text,
383
+ secret_sealed text, token_endpoint text, jwks_uri text, algorithms text[], iss_parameter boolean,
384
+ entra_tenant text, groups_claim text, config_version integer)
385
+ LANGUAGE sql
386
+ STABLE
387
+ SECURITY DEFINER
388
+ SET search_path = pg_catalog, slotlock, pg_temp
389
+ AS $slotlock_sso_connection_by_id$
390
+ SELECT linked.tenant_ref, ws.handle, ws.name, linked.kind, linked.status, linked.issuer, linked.client_id,
391
+ linked.secret_sealed, linked.token_endpoint, linked.jwks_uri, linked.algorithms, linked.iss_parameter,
392
+ linked.entra_tenant, linked.groups_claim, linked.config_version
393
+ FROM slotlock.sso_connections linked
394
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = linked.tenant_ref AND ws.status = 'active'
395
+ WHERE linked.id = requested_id;
396
+ $slotlock_sso_connection_by_id$;
397
+ REVOKE ALL ON FUNCTION ${fn[2]} FROM PUBLIC;
398
+
399
+ -- Where single sign-on starts: a team's active connection, by the team's handle or by a domain the
400
+ -- team holds verified.
401
+ CREATE OR REPLACE FUNCTION slotlock.sso_connection_route(requested_handle text, requested_domain text)
402
+ RETURNS TABLE (connection_id uuid, team_ref text, handle text, name text, authorization_endpoint text,
403
+ client_id text, kind text, entra_tenant text)
404
+ LANGUAGE sql
405
+ STABLE
406
+ SECURITY DEFINER
407
+ SET search_path = pg_catalog, slotlock, pg_temp
408
+ AS $slotlock_sso_connection_route$
409
+ SELECT linked.id, ws.tenant_ref, ws.handle, ws.name, linked.authorization_endpoint, linked.client_id, linked.kind,
410
+ linked.entra_tenant
411
+ FROM slotlock.sso_connections linked
412
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = linked.tenant_ref AND ws.status = 'active'
413
+ WHERE linked.status = 'active'
414
+ AND ((requested_handle IS NOT NULL AND ws.handle = lower(requested_handle))
415
+ OR (requested_domain IS NOT NULL AND EXISTS (
416
+ SELECT 1 FROM slotlock.workspace_domains claim
417
+ WHERE claim.tenant_ref = ws.tenant_ref AND claim.status = 'verified'
418
+ AND claim.domain_ascii = lower(requested_domain))))
419
+ LIMIT 1;
420
+ $slotlock_sso_connection_route$;
421
+ REVOKE ALL ON FUNCTION ${fn[3]} FROM PUBLIC;
422
+
423
+ -- A test sign-in's result, by an Owner, for the configuration it tested: what the provider sent,
424
+ -- with no token. A configuration changed since is not marked tested.
425
+ CREATE OR REPLACE FUNCTION slotlock.record_sso_test(
426
+ requested_tenant text, requested_actor text, requested_id uuid, requested_version integer, requested_claims jsonb)
427
+ RETURNS text
428
+ LANGUAGE plpgsql
429
+ VOLATILE
430
+ SECURITY DEFINER
431
+ SET search_path = pg_catalog, slotlock, pg_temp
432
+ AS $slotlock_record_sso_test$
433
+ DECLARE
434
+ actor_role text;
435
+ BEGIN
436
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
437
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
438
+ IF actor_role IS DISTINCT FROM 'owner' THEN
439
+ RETURN 'not_allowed';
440
+ END IF;
441
+ UPDATE slotlock.sso_connections linked
442
+ SET tested_version = requested_version, last_tested_at = statement_timestamp(), last_test_claims = requested_claims,
443
+ tested_by = requested_actor,
444
+ status = CASE WHEN linked.status = 'draft' THEN 'tested' ELSE linked.status END
445
+ WHERE linked.id = requested_id AND linked.tenant_ref = requested_tenant AND linked.config_version = requested_version;
446
+ IF NOT FOUND THEN
447
+ RETURN 'stale_config';
448
+ END IF;
449
+ RETURN NULL;
450
+ END
451
+ $slotlock_record_sso_test$;
452
+ REVOKE ALL ON FUNCTION ${fn[4]} FROM PUBLIC;
453
+
454
+ -- Turn a connection on (only once its configuration has been tested) or off (which stops requiring
455
+ -- single sign-on, and ends the sessions and proofs it vouched for), by an Owner.
456
+ CREATE OR REPLACE FUNCTION slotlock.set_sso_connection_status(
457
+ requested_tenant text, requested_actor text, requested_id uuid, requested_status text)
458
+ RETURNS text
459
+ LANGUAGE plpgsql
460
+ VOLATILE
461
+ SECURITY DEFINER
462
+ SET search_path = pg_catalog, slotlock, pg_temp
463
+ AS $slotlock_set_sso_connection_status$
464
+ DECLARE
465
+ actor_role text;
466
+ linked slotlock.sso_connections%ROWTYPE;
467
+ BEGIN
468
+ IF requested_status IS NULL OR requested_status NOT IN ('active', 'disabled') THEN
469
+ RAISE EXCEPTION 'Slotlock SSO: a connection is turned active or disabled' USING ERRCODE = '22023';
470
+ END IF;
471
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
472
+ IF NOT FOUND THEN
473
+ RETURN 'not_found';
474
+ END IF;
475
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
476
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
477
+ IF actor_role IS DISTINCT FROM 'owner' THEN
478
+ RETURN 'not_allowed';
479
+ END IF;
480
+ SELECT * INTO linked FROM slotlock.sso_connections found
481
+ WHERE found.id = requested_id AND found.tenant_ref = requested_tenant FOR UPDATE;
482
+ IF NOT FOUND THEN
483
+ RETURN 'not_found';
484
+ END IF;
485
+ IF requested_status = 'active' AND linked.tested_version IS DISTINCT FROM linked.config_version THEN
486
+ RETURN 'untested';
487
+ END IF;
488
+ IF requested_status = 'disabled' AND NOT EXISTS (
489
+ SELECT 1 FROM slotlock.workspace_memberships member
490
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active' AND member.role = 'owner'
491
+ AND member.person_ref ${NOT_SSO}
492
+ ) THEN
493
+ RETURN 'no_breakglass_owner';
494
+ END IF;
495
+ UPDATE slotlock.sso_connections found SET status = requested_status, updated_at = statement_timestamp()
496
+ WHERE found.id = requested_id;
497
+ IF requested_status = 'disabled' THEN
498
+ UPDATE slotlock.workspaces ws SET require_sso = false WHERE ws.tenant_ref = requested_tenant;
499
+ UPDATE slotlock.dashboard_sessions kept SET ended_at = statement_timestamp()
500
+ WHERE kept.sso_connection_id = requested_id AND kept.ended_at IS NULL;
501
+ DELETE FROM slotlock.dashboard_session_sso proof WHERE proof.connection_id = requested_id;
502
+ END IF;
503
+ RETURN NULL;
504
+ END
505
+ $slotlock_set_sso_connection_status$;
506
+ REVOKE ALL ON FUNCTION ${fn[5]} FROM PUBLIC;
507
+
508
+ -- Delete a team's connection, by an Owner: its identities and proofs go, sessions signed in through
509
+ -- it alone end, single sign-on is no longer required, and the people who signed in only through it
510
+ -- (sso:), left with no way in, are erased. Refused while every Owner signs in through it.
511
+ CREATE OR REPLACE FUNCTION slotlock.delete_sso_connection(requested_tenant text, requested_actor text, requested_id uuid)
512
+ RETURNS text
513
+ LANGUAGE plpgsql
514
+ VOLATILE
515
+ SECURITY DEFINER
516
+ SET search_path = pg_catalog, slotlock, pg_temp
517
+ AS $slotlock_delete_sso_connection$
518
+ DECLARE
519
+ actor_role text;
520
+ departing text[];
521
+ person text;
522
+ BEGIN
523
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
524
+ IF NOT FOUND THEN
525
+ RETURN 'not_found';
526
+ END IF;
527
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
528
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
529
+ IF actor_role IS DISTINCT FROM 'owner' THEN
530
+ RETURN 'not_allowed';
531
+ END IF;
532
+ IF NOT EXISTS (
533
+ SELECT 1 FROM slotlock.workspace_memberships member
534
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active' AND member.role = 'owner'
535
+ AND member.person_ref ${NOT_SSO}
536
+ ) THEN
537
+ RETURN 'no_breakglass_owner';
538
+ END IF;
539
+ SELECT coalesce(array_agg(identity.person_ref), ARRAY[]::text[]) INTO departing
540
+ FROM slotlock.sso_identities identity
541
+ JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id
542
+ WHERE linked.id = requested_id AND linked.tenant_ref = requested_tenant AND identity.person_ref ~ '^sso:';
543
+ DELETE FROM slotlock.sso_connections linked WHERE linked.id = requested_id AND linked.tenant_ref = requested_tenant;
544
+ IF NOT FOUND THEN
545
+ RETURN 'not_found';
546
+ END IF;
547
+ UPDATE slotlock.workspaces ws SET require_sso = false WHERE ws.tenant_ref = requested_tenant;
548
+ FOREACH person IN ARRAY departing LOOP
549
+ PERFORM 1 FROM slotlock.erase_tenant(person);
550
+ END LOOP;
551
+ RETURN NULL;
552
+ END
553
+ $slotlock_delete_sso_connection$;
554
+ REVOKE ALL ON FUNCTION ${fn[6]} FROM PUBLIC;
555
+
556
+ -- Who a first sign-in through the provider makes a member, and as what, by an Owner: on by default,
557
+ -- as a Viewer (Member or Developer at most), and only for an email trusted at a verified domain.
558
+ CREATE OR REPLACE FUNCTION slotlock.set_sso_provisioning(
559
+ requested_tenant text, requested_actor text, requested_jit boolean, requested_default_role text, requested_groups_claim text)
560
+ RETURNS text
561
+ LANGUAGE plpgsql
562
+ VOLATILE
563
+ SECURITY DEFINER
564
+ SET search_path = pg_catalog, slotlock, pg_temp
565
+ AS $slotlock_set_sso_provisioning$
566
+ DECLARE
567
+ actor_role text;
568
+ BEGIN
569
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
570
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
571
+ IF actor_role IS DISTINCT FROM 'owner' THEN
572
+ RETURN 'not_allowed';
573
+ END IF;
574
+ IF requested_default_role IS NULL OR requested_default_role NOT IN ('viewer', 'member', 'developer') THEN
575
+ RETURN 'role_not_allowed';
576
+ END IF;
577
+ UPDATE slotlock.sso_connections linked
578
+ SET jit_enabled = coalesce(requested_jit, false), default_role = requested_default_role,
579
+ groups_claim = coalesce(nullif(btrim(requested_groups_claim), ''), 'groups'), updated_at = statement_timestamp()
580
+ WHERE linked.tenant_ref = requested_tenant;
581
+ IF NOT FOUND THEN
582
+ RETURN 'not_found';
583
+ END IF;
584
+ RETURN NULL;
585
+ END
586
+ $slotlock_set_sso_provisioning$;
587
+ REVOKE ALL ON FUNCTION ${fn[7]} FROM PUBLIC;
588
+
589
+ -- Map a provider group (or app role) to a role, never Owner; a null role removes the mapping.
590
+ CREATE OR REPLACE FUNCTION slotlock.set_sso_role_mapping(
591
+ requested_tenant text, requested_actor text, requested_value text, requested_role text)
592
+ RETURNS text
593
+ LANGUAGE plpgsql
594
+ VOLATILE
595
+ SECURITY DEFINER
596
+ SET search_path = pg_catalog, slotlock, pg_temp
597
+ AS $slotlock_set_sso_role_mapping$
598
+ DECLARE
599
+ actor_role text;
600
+ linked uuid;
601
+ BEGIN
602
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
603
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
604
+ IF actor_role IS DISTINCT FROM 'owner' THEN
605
+ RETURN 'not_allowed';
606
+ END IF;
607
+ IF requested_role = 'owner' THEN
608
+ RETURN 'owner_not_mappable';
609
+ END IF;
610
+ SELECT found.id INTO linked FROM slotlock.sso_connections found WHERE found.tenant_ref = requested_tenant;
611
+ IF linked IS NULL THEN
612
+ RETURN 'not_found';
613
+ END IF;
614
+ IF requested_value IS NULL OR char_length(btrim(requested_value)) NOT BETWEEN 1 AND 255 THEN
615
+ RETURN 'invalid_value';
616
+ END IF;
617
+ IF requested_role IS NULL THEN
618
+ DELETE FROM slotlock.sso_role_mappings mapped WHERE mapped.connection_id = linked AND mapped.idp_value = btrim(requested_value);
619
+ RETURN NULL;
620
+ END IF;
621
+ IF requested_role NOT IN ('admin', 'developer', 'member', 'viewer', 'billing') THEN
622
+ RETURN 'role_not_allowed';
623
+ END IF;
624
+ IF (SELECT count(*) FROM slotlock.sso_role_mappings mapped WHERE mapped.connection_id = linked) >= 50 THEN
625
+ RETURN 'mapping_limit';
626
+ END IF;
627
+ INSERT INTO slotlock.sso_role_mappings (connection_id, idp_value, role) VALUES (linked, btrim(requested_value), requested_role)
628
+ ON CONFLICT ON CONSTRAINT slotlock_sso_role_mappings_pkey DO UPDATE SET role = excluded.role;
629
+ RETURN NULL;
630
+ END
631
+ $slotlock_set_sso_role_mapping$;
632
+ REVOKE ALL ON FUNCTION ${fn[8]} FROM PUBLIC;
633
+
634
+ CREATE OR REPLACE FUNCTION slotlock.sso_role_mappings_of(requested_tenant text)
635
+ RETURNS TABLE (idp_value text, role text)
636
+ LANGUAGE sql
637
+ STABLE
638
+ SECURITY DEFINER
639
+ SET search_path = pg_catalog, slotlock, pg_temp
640
+ AS $slotlock_sso_role_mappings_of$
641
+ SELECT mapped.idp_value, mapped.role FROM slotlock.sso_role_mappings mapped
642
+ JOIN slotlock.sso_connections linked ON linked.id = mapped.connection_id
643
+ WHERE linked.tenant_ref = requested_tenant
644
+ ORDER BY mapped.idp_value;
645
+ $slotlock_sso_role_mappings_of$;
646
+ REVOKE ALL ON FUNCTION ${fn[9]} FROM PUBLIC;
647
+
648
+ -- A sign-in through a team's provider, its token already checked. requested_session_person is the
649
+ -- person already signed in to Slotlock who started it (to prove themselves to the team, or to link
650
+ -- the identity to themselves), or null for a sign-in through the provider alone.
651
+ -- - The identity is found by the provider's subject (and Entra's tenant and object id), never email.
652
+ -- - Linking: an identity seen for the first time while a team member is signed in becomes theirs,
653
+ -- only with an email trusted at the team's verified domain (identity_untrusted otherwise: no
654
+ -- personal account keeps a member in), replacing the one they linked before; one already another
655
+ -- person's is refused (identity_taken).
656
+ -- - Alone: an identity seen for the first time is a new person (sso:<uuid>), with no workspace of
657
+ -- their own, kept only once they are let in: a pending invitation to their trusted email (never an
658
+ -- Owner's: the provider makes no Owner) makes them a member, or the connection provisions them (on
659
+ -- by default) as its default role or the role their groups map to. An email is trusted only at a
660
+ -- domain this team has verified, and only when the caller says the provider vouched for it
661
+ -- (requested_email_ok: Entra's xms_edov, Google's hd, no email_verified false).
662
+ -- - Someone the team removed, or whose address it removed, is not provisioned again.
663
+ -- - A member the provider provisioned has their role recomputed from their groups each time.
664
+ CREATE OR REPLACE FUNCTION slotlock.sso_sign_in(
665
+ requested_connection uuid, requested_issuer text, requested_subject text, requested_tid text, requested_oid text,
666
+ requested_email text, requested_email_ok boolean, requested_groups text[], requested_overage boolean,
667
+ requested_guest boolean, requested_session_person text, requested_member_cap integer)
668
+ RETURNS TABLE (person text, outcome text, refusal text, member_role text)
669
+ LANGUAGE plpgsql
670
+ VOLATILE
671
+ SECURITY DEFINER
672
+ SET search_path = pg_catalog, slotlock, pg_temp
673
+ AS $slotlock_sso_sign_in$
674
+ DECLARE
675
+ linked slotlock.sso_connections%ROWTYPE;
676
+ known slotlock.sso_identities%ROWTYPE;
677
+ email_key text := lower(btrim(requested_email));
678
+ trusted boolean := false;
679
+ mapped text;
680
+ current_membership slotlock.workspace_memberships%ROWTYPE;
681
+ chosen text;
682
+ invitation uuid;
683
+ accepted_ref text;
684
+ accepted_refusal text;
685
+ occupied integer;
686
+ newcomer text;
687
+ BEGIN
688
+ SELECT * INTO linked FROM slotlock.sso_connections found WHERE found.id = requested_connection;
689
+ IF NOT FOUND OR linked.status <> 'active' THEN
690
+ RETURN QUERY SELECT NULL::text, NULL::text, 'connection_inactive'::text, NULL::text;
691
+ RETURN;
692
+ END IF;
693
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = linked.tenant_ref AND ws.status = 'active' FOR UPDATE;
694
+ IF NOT FOUND THEN
695
+ RETURN QUERY SELECT NULL::text, NULL::text, 'connection_inactive'::text, NULL::text;
696
+ RETURN;
697
+ END IF;
698
+ IF requested_issuer IS DISTINCT FROM linked.issuer OR requested_subject IS NULL
699
+ OR char_length(requested_subject) NOT BETWEEN 1 AND 255 THEN
700
+ RAISE EXCEPTION 'Slotlock SSO: the identity is not this connection''s' USING ERRCODE = '22023';
701
+ END IF;
702
+ IF coalesce(requested_guest, false) THEN
703
+ RETURN QUERY SELECT NULL::text, NULL::text, 'guest_refused'::text, NULL::text;
704
+ RETURN;
705
+ END IF;
706
+ IF email_key IS NOT NULL AND email_key !~ '^[^@[:space:][:cntrl:]<>]+@[^@[:space:][:cntrl:]<>]+$' THEN
707
+ email_key := NULL;
708
+ END IF;
709
+ trusted := coalesce(requested_email_ok, false) AND email_key IS NOT NULL AND EXISTS (
710
+ SELECT 1 FROM slotlock.workspace_domains claim
711
+ WHERE claim.tenant_ref = linked.tenant_ref AND claim.status = 'verified'
712
+ AND claim.domain_ascii = lower(substring(email_key FROM '@([^@]+)$')));
713
+ -- The highest role a group maps to; none when the provider sent too many groups to list.
714
+ IF NOT coalesce(requested_overage, false) THEN
715
+ SELECT mapping.role INTO mapped FROM slotlock.sso_role_mappings mapping
716
+ WHERE mapping.connection_id = linked.id AND mapping.idp_value = ANY (coalesce(requested_groups, ARRAY[]::text[]))
717
+ ORDER BY array_position(ARRAY['admin', 'developer', 'member', 'viewer', 'billing'], mapping.role)
718
+ LIMIT 1;
719
+ END IF;
720
+ SELECT * INTO known FROM slotlock.sso_identities found
721
+ WHERE found.connection_id = linked.id
722
+ AND ((found.issuer = requested_issuer AND found.subject = requested_subject)
723
+ OR (requested_oid IS NOT NULL AND found.entra_tid = requested_tid AND found.entra_oid = requested_oid))
724
+ ORDER BY (found.subject = requested_subject) DESC
725
+ LIMIT 1;
726
+
727
+ IF requested_session_person IS NOT NULL THEN
728
+ -- Proving oneself to the team, or linking: the signed-in person must be a member of it.
729
+ IF requested_session_person !~ '${PERSON}' OR NOT EXISTS (
730
+ SELECT 1 FROM slotlock.workspace_memberships member
731
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = requested_session_person AND member.status = 'active'
732
+ ) THEN
733
+ RETURN QUERY SELECT NULL::text, NULL::text, 'not_member'::text, NULL::text;
734
+ RETURN;
735
+ END IF;
736
+ IF known.id IS NOT NULL AND known.person_ref <> requested_session_person THEN
737
+ RETURN QUERY SELECT NULL::text, NULL::text, 'identity_taken'::text, NULL::text;
738
+ RETURN;
739
+ END IF;
740
+ IF known.replaced_at IS NOT NULL THEN
741
+ -- An account of theirs they replaced, linked again in place of the current one.
742
+ UPDATE slotlock.sso_identities current_one SET replaced_at = statement_timestamp()
743
+ WHERE current_one.connection_id = linked.id AND current_one.person_ref = requested_session_person
744
+ AND current_one.replaced_at IS NULL;
745
+ UPDATE slotlock.sso_identities found SET replaced_at = NULL WHERE found.id = known.id;
746
+ END IF;
747
+ IF known.id IS NULL THEN
748
+ IF NOT trusted THEN
749
+ RETURN QUERY SELECT NULL::text, NULL::text, 'identity_untrusted'::text, NULL::text;
750
+ RETURN;
751
+ END IF;
752
+ UPDATE slotlock.sso_identities replaced SET replaced_at = statement_timestamp()
753
+ WHERE replaced.connection_id = linked.id AND replaced.person_ref = requested_session_person
754
+ AND replaced.replaced_at IS NULL;
755
+ INSERT INTO slotlock.sso_identities (connection_id, issuer, subject, entra_tid, entra_oid, person_ref, email_last)
756
+ VALUES (linked.id, requested_issuer, requested_subject, requested_tid, requested_oid, requested_session_person, email_key);
757
+ RETURN QUERY SELECT requested_session_person, 'linked'::text, NULL::text,
758
+ (SELECT member.role FROM slotlock.workspace_memberships member
759
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = requested_session_person);
760
+ RETURN;
761
+ END IF;
762
+ UPDATE slotlock.sso_identities found SET last_sso_at = statement_timestamp(), subject = requested_subject,
763
+ email_last = CASE WHEN trusted THEN email_key ELSE found.email_last END
764
+ WHERE found.id = known.id;
765
+ RETURN QUERY SELECT requested_session_person, 'proof'::text, NULL::text,
766
+ (SELECT member.role FROM slotlock.workspace_memberships member
767
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = requested_session_person);
768
+ RETURN;
769
+ END IF;
770
+
771
+ -- Through the provider alone. An account its person replaced signs in as nobody.
772
+ IF known.replaced_at IS NOT NULL THEN
773
+ RETURN QUERY SELECT NULL::text, NULL::text, 'not_member'::text, NULL::text;
774
+ RETURN;
775
+ END IF;
776
+ IF known.id IS NULL THEN
777
+ newcomer := 'sso:' || gen_random_uuid()::text;
778
+ ELSE
779
+ newcomer := known.person_ref;
780
+ UPDATE slotlock.sso_identities found SET last_sso_at = statement_timestamp(), subject = requested_subject,
781
+ email_last = CASE WHEN trusted THEN email_key ELSE found.email_last END
782
+ WHERE found.id = known.id;
783
+ END IF;
784
+ SELECT * INTO current_membership FROM slotlock.workspace_memberships member
785
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = newcomer;
786
+ IF current_membership.status = 'active' THEN
787
+ IF current_membership.role_managed_by = 'sso' AND current_membership.role <> 'owner' THEN
788
+ chosen := coalesce(mapped, linked.default_role);
789
+ IF chosen <> current_membership.role THEN
790
+ UPDATE slotlock.workspace_memberships member SET role = chosen, updated_at = statement_timestamp()
791
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = newcomer;
792
+ RETURN QUERY SELECT newcomer, 'role_changed'::text, NULL::text, chosen;
793
+ RETURN;
794
+ END IF;
795
+ END IF;
796
+ RETURN QUERY SELECT newcomer, 'signed_in'::text, NULL::text, current_membership.role;
797
+ RETURN;
798
+ END IF;
799
+ IF current_membership.person_ref IS NOT NULL AND current_membership.deactivated_by IS DISTINCT FROM 'scim' THEN
800
+ -- Removed from the team, or left it: the provider does not bring them back. One the team's
801
+ -- directory ended may return through the directory (below), or an invitation.
802
+ RETURN QUERY SELECT CASE WHEN known.id IS NULL THEN NULL ELSE newcomer END, NULL::text, 'not_member'::text, NULL::text;
803
+ RETURN;
804
+ END IF;
805
+ IF trusted THEN
806
+ SELECT pending.id INTO invitation FROM slotlock.workspace_invitations pending
807
+ WHERE pending.tenant_ref = linked.tenant_ref AND pending.email = email_key
808
+ AND pending.accepted_at IS NULL AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp()
809
+ AND pending.role <> 'owner'
810
+ ORDER BY pending.sent_at DESC LIMIT 1;
811
+ IF invitation IS NOT NULL THEN
812
+ SELECT accepted.tenant_ref, accepted.refusal INTO accepted_ref, accepted_refusal
813
+ FROM slotlock.accept_invitation(NULL::bytea, invitation, newcomer, ARRAY[email_key], requested_member_cap) accepted;
814
+ IF accepted_ref IS NOT NULL THEN
815
+ IF known.id IS NULL THEN
816
+ INSERT INTO slotlock.sso_identities (connection_id, issuer, subject, entra_tid, entra_oid, person_ref, email_last)
817
+ VALUES (linked.id, requested_issuer, requested_subject, requested_tid, requested_oid, newcomer, email_key);
818
+ END IF;
819
+ RETURN QUERY SELECT newcomer, 'joined'::text, NULL::text,
820
+ (SELECT member.role FROM slotlock.workspace_memberships member
821
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = newcomer);
822
+ RETURN;
823
+ END IF;
824
+ IF accepted_refusal = 'member_cap' THEN
825
+ RETURN QUERY SELECT CASE WHEN known.id IS NULL THEN NULL ELSE newcomer END, NULL::text, 'member_cap'::text, NULL::text;
826
+ RETURN;
827
+ END IF;
828
+ END IF;
829
+ END IF;
830
+ -- The team's directory (SCIM, ddl-scim.ts) lists the address: it decides, whatever provisioning
831
+ -- says. Its active user there, not yet anyone's, is this person; deactivated, or another's, nobody.
832
+ IF trusted AND EXISTS (SELECT 1 FROM slotlock.scim_users directory
833
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = directory.tenant_ref AND ws.scim_enabled
834
+ WHERE directory.tenant_ref = linked.tenant_ref AND directory.deleted_at IS NULL
835
+ AND coalesce(directory.work_email, directory.user_name_key) = email_key) THEN
836
+ PERFORM 1 FROM slotlock.scim_bind(newcomer, ARRAY[email_key], requested_member_cap);
837
+ SELECT * INTO current_membership FROM slotlock.workspace_memberships member
838
+ WHERE member.tenant_ref = linked.tenant_ref AND member.person_ref = newcomer AND member.status = 'active';
839
+ IF current_membership.person_ref IS NULL THEN
840
+ RETURN QUERY SELECT CASE WHEN known.id IS NULL THEN NULL ELSE newcomer END, NULL::text, 'not_member'::text, NULL::text;
841
+ RETURN;
842
+ END IF;
843
+ IF known.id IS NULL THEN
844
+ INSERT INTO slotlock.sso_identities (connection_id, issuer, subject, entra_tid, entra_oid, person_ref, email_last)
845
+ VALUES (linked.id, requested_issuer, requested_subject, requested_tid, requested_oid, newcomer, email_key);
846
+ END IF;
847
+ RETURN QUERY SELECT newcomer, 'joined'::text, NULL::text, current_membership.role;
848
+ RETURN;
849
+ END IF;
850
+ IF current_membership.person_ref IS NOT NULL THEN
851
+ -- Ended by the directory, which lists them no more: not by provisioning either.
852
+ RETURN QUERY SELECT newcomer, NULL::text, 'not_member'::text, NULL::text;
853
+ RETURN;
854
+ END IF;
855
+ IF NOT linked.jit_enabled OR NOT trusted OR EXISTS (
856
+ SELECT 1 FROM slotlock.workspace_join_blocks blocked
857
+ WHERE blocked.tenant_ref = linked.tenant_ref AND blocked.email_sha256 = sha256(convert_to(email_key, 'UTF8'))
858
+ ) OR EXISTS (
859
+ SELECT 1 FROM slotlock.workspace_memberships member
860
+ WHERE member.tenant_ref = linked.tenant_ref AND member.joined_email = email_key AND member.status = 'inactive'
861
+ ) THEN
862
+ RETURN QUERY SELECT CASE WHEN known.id IS NULL THEN NULL ELSE newcomer END, NULL::text, 'not_member'::text, NULL::text;
863
+ RETURN;
864
+ END IF;
865
+ IF requested_member_cap IS NOT NULL THEN
866
+ SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
867
+ WHERE member.tenant_ref = linked.tenant_ref AND member.status = 'active')
868
+ + (SELECT count(*) FROM slotlock.workspace_invitations pending
869
+ WHERE pending.tenant_ref = linked.tenant_ref AND pending.accepted_at IS NULL
870
+ AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp())
871
+ INTO occupied;
872
+ IF occupied >= requested_member_cap THEN
873
+ RETURN QUERY SELECT CASE WHEN known.id IS NULL THEN NULL ELSE newcomer END, NULL::text, 'member_cap'::text, NULL::text;
874
+ RETURN;
875
+ END IF;
876
+ END IF;
877
+ chosen := coalesce(mapped, linked.default_role);
878
+ INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source, role_managed_by, joined_email)
879
+ VALUES (linked.tenant_ref, newcomer, chosen, 'sso', 'sso', email_key);
880
+ IF known.id IS NULL THEN
881
+ INSERT INTO slotlock.sso_identities (connection_id, issuer, subject, entra_tid, entra_oid, person_ref, email_last)
882
+ VALUES (linked.id, requested_issuer, requested_subject, requested_tid, requested_oid, newcomer, email_key);
883
+ END IF;
884
+ RETURN QUERY SELECT newcomer, 'joined'::text, NULL::text, chosen;
885
+ END
886
+ $slotlock_sso_sign_in$;
887
+ REVOKE ALL ON FUNCTION ${fn[10]} FROM PUBLIC;
888
+
889
+ -- A session's proof from a connection's provider, now: for the person whose session it is. A session
890
+ -- begun through the provider alone (scoped) is scoped to the connection's team for its life.
891
+ CREATE OR REPLACE FUNCTION slotlock.record_sso_proof(
892
+ requested_sid bytea, requested_person text, requested_connection uuid, requested_scoped boolean, requested_version integer)
893
+ RETURNS boolean
894
+ LANGUAGE plpgsql
895
+ VOLATILE
896
+ SECURITY DEFINER
897
+ SET search_path = pg_catalog, slotlock, pg_temp
898
+ AS $slotlock_record_sso_proof$
899
+ BEGIN
900
+ -- Only from a connection on now, configured as it was when it vouched (requested_version, where the
901
+ -- caller knows it): locked, so turning it off or changing it waits for this, or this sees it.
902
+ IF NOT EXISTS (SELECT 1 FROM slotlock.sso_connections linked
903
+ WHERE linked.id = requested_connection AND linked.status = 'active'
904
+ AND (requested_version IS NULL OR linked.config_version = requested_version)
905
+ FOR SHARE) THEN
906
+ RETURN false;
907
+ END IF;
908
+ -- A session scoped to one team's provider proves nothing to another's.
909
+ IF NOT EXISTS (SELECT 1 FROM slotlock.dashboard_sessions kept
910
+ WHERE kept.sid_sha256 = requested_sid AND kept.person_ref = requested_person
911
+ AND kept.ended_at IS NULL AND kept.expires_at > statement_timestamp()
912
+ AND (kept.sso_connection_id IS NULL OR kept.sso_connection_id = requested_connection)) THEN
913
+ RETURN false;
914
+ END IF;
915
+ IF requested_scoped THEN
916
+ UPDATE slotlock.dashboard_sessions kept SET sso_connection_id = requested_connection
917
+ WHERE kept.sid_sha256 = requested_sid AND kept.sso_connection_id IS NULL
918
+ AND kept.started_at > statement_timestamp() - interval '1 minute';
919
+ IF NOT FOUND THEN
920
+ RETURN false;
921
+ END IF;
922
+ END IF;
923
+ INSERT INTO slotlock.dashboard_session_sso (sid_sha256, connection_id, person_ref)
924
+ VALUES (requested_sid, requested_connection, requested_person)
925
+ ON CONFLICT ON CONSTRAINT slotlock_dashboard_session_sso_pkey DO UPDATE SET authenticated_at = statement_timestamp();
926
+ RETURN true;
927
+ END
928
+ $slotlock_record_sso_proof$;
929
+ REVOKE ALL ON FUNCTION ${fn[11]} FROM PUBLIC;
930
+
931
+ -- Require single sign-on, or stop, by an Owner: turning it on needs the team's connection active,
932
+ -- a proof from it in this session in the last ${SLOTLOCK_SSO_ENABLE_PROOF_MINUTES} minutes, and an Owner who signs in with
933
+ -- GitHub or Google, for when the provider is down.
934
+ CREATE OR REPLACE FUNCTION slotlock.set_require_sso(
935
+ requested_tenant text, requested_actor text, requested_sid bytea, requested_on boolean, requested_max_age integer)
936
+ RETURNS text
937
+ LANGUAGE plpgsql
938
+ VOLATILE
939
+ SECURITY DEFINER
940
+ SET search_path = pg_catalog, slotlock, pg_temp
941
+ AS $slotlock_set_require_sso$
942
+ DECLARE
943
+ actor_role text;
944
+ linked uuid;
945
+ BEGIN
946
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
947
+ IF NOT FOUND THEN
948
+ RETURN 'not_found';
949
+ END IF;
950
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
951
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
952
+ IF actor_role IS DISTINCT FROM 'owner' THEN
953
+ RETURN 'not_allowed';
954
+ END IF;
955
+ IF requested_max_age IS NULL OR requested_max_age NOT BETWEEN ${SLOTLOCK_SSO_MAX_AGE_HOURS.min} AND ${SLOTLOCK_SSO_MAX_AGE_HOURS.max} THEN
956
+ RETURN 'invalid_max_age';
957
+ END IF;
958
+ IF requested_on THEN
959
+ SELECT found.id INTO linked FROM slotlock.sso_connections found
960
+ WHERE found.tenant_ref = requested_tenant AND found.status = 'active';
961
+ IF linked IS NULL THEN
962
+ RETURN 'no_active_connection';
963
+ END IF;
964
+ IF NOT EXISTS (SELECT 1 FROM slotlock.dashboard_session_sso proof
965
+ WHERE proof.sid_sha256 = requested_sid AND proof.connection_id = linked
966
+ AND proof.person_ref = requested_actor
967
+ AND proof.authenticated_at > statement_timestamp() - interval '${SLOTLOCK_SSO_ENABLE_PROOF_MINUTES} minutes') THEN
968
+ RETURN 'stale_sso_proof';
969
+ END IF;
970
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
971
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active' AND member.role = 'owner'
972
+ AND member.person_ref ${NOT_SSO}) THEN
973
+ RETURN 'no_breakglass_owner';
974
+ END IF;
975
+ END IF;
976
+ UPDATE slotlock.workspaces ws SET require_sso = coalesce(requested_on, false), sso_max_age_hours = requested_max_age
977
+ WHERE ws.tenant_ref = requested_tenant;
978
+ RETURN NULL;
979
+ END
980
+ $slotlock_set_require_sso$;
981
+ REVOKE ALL ON FUNCTION ${fn[12]} FROM PUBLIC;
982
+
983
+ -- An Owner's way in while the provider is down: an Owner who signs in with GitHub or Google, in this
984
+ -- session, for ${SLOTLOCK_SSO_BYPASS_MINUTES} minutes. The caller records it in the audit log and tells the other Owners.
985
+ CREATE OR REPLACE FUNCTION slotlock.sso_owner_bypass(requested_tenant text, requested_person text, requested_sid bytea)
986
+ RETURNS text
987
+ LANGUAGE plpgsql
988
+ VOLATILE
989
+ SECURITY DEFINER
990
+ SET search_path = pg_catalog, slotlock, pg_temp
991
+ AS $slotlock_sso_owner_bypass$
992
+ BEGIN
993
+ IF requested_person IS NULL OR requested_person !~ '${PERSON}' OR requested_person ~ '^sso:' OR NOT EXISTS (
994
+ SELECT 1 FROM slotlock.workspace_memberships member
995
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person
996
+ AND member.status = 'active' AND member.role = 'owner'
997
+ ) THEN
998
+ RETURN 'not_allowed';
999
+ END IF;
1000
+ -- A live session the Owner began themselves: never one their team's provider began alone.
1001
+ IF NOT EXISTS (SELECT 1 FROM slotlock.dashboard_sessions kept
1002
+ WHERE kept.sid_sha256 = requested_sid AND kept.person_ref = requested_person
1003
+ AND kept.ended_at IS NULL AND kept.expires_at > statement_timestamp()
1004
+ AND kept.sso_connection_id IS NULL) THEN
1005
+ RETURN 'not_allowed';
1006
+ END IF;
1007
+ INSERT INTO slotlock.sso_bypasses (sid_sha256, tenant_ref, person_ref, expires_at)
1008
+ VALUES (requested_sid, requested_tenant, requested_person, statement_timestamp() + interval '${SLOTLOCK_SSO_BYPASS_MINUTES} minutes')
1009
+ ON CONFLICT ON CONSTRAINT slotlock_sso_bypasses_pkey DO UPDATE
1010
+ SET granted_at = statement_timestamp(), expires_at = excluded.expires_at;
1011
+ RETURN NULL;
1012
+ END
1013
+ $slotlock_sso_owner_bypass$;
1014
+ REVOKE ALL ON FUNCTION ${fn[13]} FROM PUBLIC;
1015
+
1016
+ -- Bypasses that ended, deleted. How many went.
1017
+ CREATE OR REPLACE FUNCTION slotlock.prune_sso()
1018
+ RETURNS integer
1019
+ LANGUAGE plpgsql
1020
+ VOLATILE
1021
+ SECURITY DEFINER
1022
+ SET search_path = pg_catalog, slotlock, pg_temp
1023
+ AS $slotlock_prune_sso$
1024
+ DECLARE
1025
+ pruned integer;
1026
+ BEGIN
1027
+ DELETE FROM slotlock.sso_bypasses bypass WHERE bypass.expires_at < statement_timestamp();
1028
+ GET DIAGNOSTICS pruned = ROW_COUNT;
1029
+ RETURN pruned;
1030
+ END
1031
+ $slotlock_prune_sso$;
1032
+ REVOKE ALL ON FUNCTION ${fn[14]} FROM PUBLIC;
1033
+ `;