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-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
|
+
`;
|