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.
- package/CHANGELOG.md +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- 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 +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- 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 +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- 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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { SlotlockScimGroupAttributes, SlotlockScimUserAttributes } from './scim.js';
|
|
2
|
+
import type { SlotlockSql } from './store.js';
|
|
3
|
+
/** A SCIM token: `slk_scim_` and 256 random bits in hex. */
|
|
4
|
+
export declare const SLOTLOCK_SCIM_TOKEN: RegExp;
|
|
5
|
+
export type SlotlockScimRefusal = 'not_found' | 'not_allowed' | 'not_enabled' | 'no_verified_domain' | 'key_policy' | 'token_limit' | 'bad_token' | 'wrong_workspace' | 'domain_not_verified' | 'uniqueness' | 'owner_protected' | 'invalid_member' | 'owner_not_mappable' | 'role_not_allowed';
|
|
6
|
+
/** A directory user as kept, with its id and times. */
|
|
7
|
+
export interface SlotlockScimUser extends SlotlockScimUserAttributes {
|
|
8
|
+
id: string;
|
|
9
|
+
createdAt: Date;
|
|
10
|
+
updatedAt: Date;
|
|
11
|
+
}
|
|
12
|
+
export interface SlotlockScimGroup extends SlotlockScimGroupAttributes {
|
|
13
|
+
id: string;
|
|
14
|
+
createdAt: Date;
|
|
15
|
+
updatedAt: Date;
|
|
16
|
+
/** The role an Owner mapped the group to (reserved names map themselves), or null. Not part of SCIM. */
|
|
17
|
+
role: string | null;
|
|
18
|
+
}
|
|
19
|
+
/** A filter as the endpoint passes it: one attribute (username, externalid, id, email / displayname). */
|
|
20
|
+
export type SlotlockScimLookup = {
|
|
21
|
+
attribute: string;
|
|
22
|
+
value: string;
|
|
23
|
+
} | null;
|
|
24
|
+
export interface SlotlockWorkspaceScim {
|
|
25
|
+
enable(tenantRef: string, actor: string, keyPolicy: 'keep' | 'revoke'): Promise<SlotlockScimRefusal | null>;
|
|
26
|
+
disable(tenantRef: string, actor: string): Promise<SlotlockScimRefusal | null>;
|
|
27
|
+
/** A new token, shown once: the raw value, or why not. */
|
|
28
|
+
createToken(tenantRef: string, actor: string): Promise<{
|
|
29
|
+
token: string;
|
|
30
|
+
prefix: string;
|
|
31
|
+
} | {
|
|
32
|
+
refusal: SlotlockScimRefusal;
|
|
33
|
+
}>;
|
|
34
|
+
revokeToken(tenantRef: string, actor: string, id: string): Promise<SlotlockScimRefusal | null>;
|
|
35
|
+
state(tenantRef: string): Promise<{
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
keyPolicy: 'keep' | 'revoke' | null;
|
|
38
|
+
tokens: {
|
|
39
|
+
id: string;
|
|
40
|
+
prefix: string;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
lastUsedAt: Date | null;
|
|
43
|
+
}[];
|
|
44
|
+
}>;
|
|
45
|
+
/** A request's token, for the team its address names. */
|
|
46
|
+
authenticate(token: string, teamId: string): Promise<{
|
|
47
|
+
tenantRef: string;
|
|
48
|
+
prefix: string;
|
|
49
|
+
keyPolicy: 'keep' | 'revoke';
|
|
50
|
+
} | {
|
|
51
|
+
refusal: 'bad_token' | 'wrong_workspace';
|
|
52
|
+
tenantRef?: string;
|
|
53
|
+
prefix?: string;
|
|
54
|
+
}>;
|
|
55
|
+
putUser(tenantRef: string, id: string | null, newId: string, user: SlotlockScimUserAttributes): Promise<{
|
|
56
|
+
id: string;
|
|
57
|
+
membership: 'none' | 'deactivated' | 'activated';
|
|
58
|
+
person: string | null;
|
|
59
|
+
} | {
|
|
60
|
+
refusal: SlotlockScimRefusal;
|
|
61
|
+
person?: string | null;
|
|
62
|
+
}>;
|
|
63
|
+
users(tenantRef: string, lookup: SlotlockScimLookup, startIndex: number, count: number, id?: string): Promise<{
|
|
64
|
+
users: SlotlockScimUser[];
|
|
65
|
+
total: number;
|
|
66
|
+
}>;
|
|
67
|
+
deleteUser(tenantRef: string, id: string): Promise<{
|
|
68
|
+
refusal: SlotlockScimRefusal | null;
|
|
69
|
+
person: string | null;
|
|
70
|
+
}>;
|
|
71
|
+
putGroup(tenantRef: string, id: string | null, newId: string, group: SlotlockScimGroupAttributes): Promise<{
|
|
72
|
+
id: string;
|
|
73
|
+
} | {
|
|
74
|
+
refusal: SlotlockScimRefusal;
|
|
75
|
+
}>;
|
|
76
|
+
groups(tenantRef: string, lookup: SlotlockScimLookup, startIndex: number, count: number, id?: string): Promise<{
|
|
77
|
+
groups: SlotlockScimGroup[];
|
|
78
|
+
total: number;
|
|
79
|
+
}>;
|
|
80
|
+
deleteGroup(tenantRef: string, id: string): Promise<SlotlockScimRefusal | null>;
|
|
81
|
+
mapGroup(tenantRef: string, actor: string, id: string, role: string | null): Promise<SlotlockScimRefusal | null>;
|
|
82
|
+
/**
|
|
83
|
+
* A sign-in holding addresses the domain vouches for: the directory users at them bind to the
|
|
84
|
+
* person. `freeCap` is the member cap of a team on the free plan, where plans apply, else null.
|
|
85
|
+
*/
|
|
86
|
+
bind(person: string, emails: readonly string[], freeCap?: number | null): Promise<string[]>;
|
|
87
|
+
}
|
|
88
|
+
/** Through the functions of ddl-scim.ts. */
|
|
89
|
+
export declare function createSlotlockWorkspaceScim(sql: SlotlockSql): SlotlockWorkspaceScim;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A team's directory (SCIM 2.0) as the dashboard and its SCIM endpoint reach it (teams phase
|
|
2
|
+
// 5.6–5.7): through the SECURITY DEFINER functions of ddl-scim.ts. Tokens are made here and shown
|
|
3
|
+
// once; only their SHA-256 reaches the database.
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
const TEAM = /^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
/** A SCIM token: `slk_scim_` and 256 random bits in hex. */
|
|
8
|
+
export const SLOTLOCK_SCIM_TOKEN = /^slk_scim_[0-9a-f]{64}$/;
|
|
9
|
+
const digest = (token) => createHash('sha256').update(token, 'utf8').digest();
|
|
10
|
+
const refusalOf = (value) => value === null || value === undefined ? null : value;
|
|
11
|
+
/** Through the functions of ddl-scim.ts. */
|
|
12
|
+
export function createSlotlockWorkspaceScim(sql) {
|
|
13
|
+
return {
|
|
14
|
+
async enable(tenantRef, actor, keyPolicy) {
|
|
15
|
+
const [row] = await sql `SELECT slotlock.enable_scim(${tenantRef}::text, ${actor}::text, ${keyPolicy}::text) AS refusal`;
|
|
16
|
+
return refusalOf(row?.refusal);
|
|
17
|
+
},
|
|
18
|
+
async disable(tenantRef, actor) {
|
|
19
|
+
const [row] = await sql `SELECT slotlock.disable_scim(${tenantRef}::text, ${actor}::text) AS refusal`;
|
|
20
|
+
return refusalOf(row?.refusal);
|
|
21
|
+
},
|
|
22
|
+
async createToken(tenantRef, actor) {
|
|
23
|
+
const token = `slk_scim_${randomBytes(32).toString('hex')}`;
|
|
24
|
+
const prefix = token.slice(0, 'slk_scim_'.length + 4);
|
|
25
|
+
const [row] = await sql `
|
|
26
|
+
SELECT slotlock.create_scim_token(${tenantRef}::text, ${actor}::text, ${digest(token)}::bytea, ${prefix}::text) AS refusal`;
|
|
27
|
+
return row?.refusal ? { refusal: refusalOf(row.refusal) ?? 'not_allowed' } : { token, prefix };
|
|
28
|
+
},
|
|
29
|
+
async revokeToken(tenantRef, actor, id) {
|
|
30
|
+
if (!UUID.test(id))
|
|
31
|
+
return 'not_found';
|
|
32
|
+
const [row] = await sql `SELECT slotlock.revoke_scim_token(${tenantRef}::text, ${actor}::text, ${id}::uuid) AS refusal`;
|
|
33
|
+
return refusalOf(row?.refusal);
|
|
34
|
+
},
|
|
35
|
+
async state(tenantRef) {
|
|
36
|
+
const rows = await sql `
|
|
37
|
+
SELECT * FROM slotlock.scim_state(${tenantRef}::text)`;
|
|
38
|
+
return {
|
|
39
|
+
enabled: rows[0]?.enabled === true,
|
|
40
|
+
keyPolicy: rows[0]?.key_policy ?? null,
|
|
41
|
+
tokens: rows.flatMap((row) => row.token_id && row.prefix && row.created_at ? [{ id: row.token_id, prefix: row.prefix, createdAt: row.created_at, lastUsedAt: row.last_used_at }] : []),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
async authenticate(token, teamId) {
|
|
45
|
+
if (!SLOTLOCK_SCIM_TOKEN.test(token) || !UUID.test(teamId))
|
|
46
|
+
return { refusal: 'bad_token' };
|
|
47
|
+
const [row] = await sql `
|
|
48
|
+
SELECT * FROM slotlock.scim_authenticate(${digest(token)}::bytea, ${`team:${teamId.toLowerCase()}`}::text)`;
|
|
49
|
+
if (!row || row.refusal === 'bad_token' || (row.refusal === null && (!row.team_ref || !row.key_policy)))
|
|
50
|
+
return { refusal: 'bad_token' };
|
|
51
|
+
if (row.refusal === 'wrong_workspace') {
|
|
52
|
+
return { refusal: 'wrong_workspace', ...(row.team_ref ? { tenantRef: row.team_ref } : {}), ...(row.token_prefix ? { prefix: row.token_prefix } : {}) };
|
|
53
|
+
}
|
|
54
|
+
return { tenantRef: row.team_ref, prefix: row.token_prefix, keyPolicy: row.key_policy };
|
|
55
|
+
},
|
|
56
|
+
async putUser(tenantRef, id, newId, user) {
|
|
57
|
+
if (!TEAM.test(tenantRef) || (id !== null && !UUID.test(id)) || !UUID.test(newId))
|
|
58
|
+
return { refusal: 'not_found' };
|
|
59
|
+
const [row] = await sql `
|
|
60
|
+
SELECT * FROM slotlock.scim_put_user(${tenantRef}::text, ${id}::uuid, ${newId}::uuid, ${user.userName}::text,
|
|
61
|
+
${user.externalId}::text, ${user.active}::boolean, ${user.givenName}::text, ${user.familyName}::text,
|
|
62
|
+
${user.displayName}::text, ${user.workEmail}::text)`;
|
|
63
|
+
if (!row || row.refusal)
|
|
64
|
+
return { refusal: refusalOf(row?.refusal) ?? 'not_found', person: row?.person ?? null };
|
|
65
|
+
return {
|
|
66
|
+
id: row.user_id,
|
|
67
|
+
membership: row.membership === 'deactivated' || row.membership === 'activated' ? row.membership : 'none',
|
|
68
|
+
person: row.person,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
async users(tenantRef, lookup, startIndex, count, id) {
|
|
72
|
+
if (!TEAM.test(tenantRef) || (id !== undefined && !UUID.test(id)))
|
|
73
|
+
return { users: [], total: 0 };
|
|
74
|
+
const rows = await sql `SELECT * FROM slotlock.scim_users_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text, ${lookup?.value ?? null}::text,
|
|
75
|
+
${startIndex}::integer, ${count}::integer, ${id ?? null}::uuid)`;
|
|
76
|
+
// A page past the end (or count=0) has no rows to carry the total: one row from the start does.
|
|
77
|
+
const total = rows[0]?.total ??
|
|
78
|
+
(startIndex > 1 || count === 0
|
|
79
|
+
? ((await sql `SELECT total FROM slotlock.scim_users_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text,
|
|
80
|
+
${lookup?.value ?? null}::text, 1, 1, ${id ?? null}::uuid)`)[0]?.total ?? 0)
|
|
81
|
+
: 0);
|
|
82
|
+
return {
|
|
83
|
+
total: Number(total),
|
|
84
|
+
users: rows.map((row) => ({
|
|
85
|
+
id: row.user_id,
|
|
86
|
+
userName: row.user_name,
|
|
87
|
+
externalId: row.external_id,
|
|
88
|
+
active: row.active,
|
|
89
|
+
givenName: row.given_name,
|
|
90
|
+
familyName: row.family_name,
|
|
91
|
+
displayName: row.display_name,
|
|
92
|
+
workEmail: row.work_email,
|
|
93
|
+
createdAt: row.created_at,
|
|
94
|
+
updatedAt: row.updated_at,
|
|
95
|
+
})),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async deleteUser(tenantRef, id) {
|
|
99
|
+
if (!UUID.test(id))
|
|
100
|
+
return { refusal: 'not_found', person: null };
|
|
101
|
+
const [row] = await sql `SELECT * FROM slotlock.scim_delete_user(${tenantRef}::text, ${id}::uuid)`;
|
|
102
|
+
return { refusal: refusalOf(row?.refusal), person: row?.person ?? null };
|
|
103
|
+
},
|
|
104
|
+
async putGroup(tenantRef, id, newId, group) {
|
|
105
|
+
if (!TEAM.test(tenantRef) || (id !== null && !UUID.test(id)) || !UUID.test(newId))
|
|
106
|
+
return { refusal: 'not_found' };
|
|
107
|
+
if (!group.members.every((member) => UUID.test(member)))
|
|
108
|
+
return { refusal: 'invalid_member' };
|
|
109
|
+
const [row] = await sql `
|
|
110
|
+
SELECT * FROM slotlock.scim_put_group(${tenantRef}::text, ${id}::uuid, ${newId}::uuid, ${group.displayName}::text,
|
|
111
|
+
${group.externalId}::text, ${group.members}::uuid[])`;
|
|
112
|
+
return row?.group_id && !row.refusal ? { id: row.group_id } : { refusal: refusalOf(row?.refusal) ?? 'not_found' };
|
|
113
|
+
},
|
|
114
|
+
async groups(tenantRef, lookup, startIndex, count, id) {
|
|
115
|
+
if (!TEAM.test(tenantRef) || (id !== undefined && !UUID.test(id)))
|
|
116
|
+
return { groups: [], total: 0 };
|
|
117
|
+
const rows = await sql `SELECT * FROM slotlock.scim_groups_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text, ${lookup?.value ?? null}::text,
|
|
118
|
+
${startIndex}::integer, ${count}::integer, ${id ?? null}::uuid)`;
|
|
119
|
+
const total = rows[0]?.total ??
|
|
120
|
+
(startIndex > 1 || count === 0
|
|
121
|
+
? ((await sql `SELECT total FROM slotlock.scim_groups_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text,
|
|
122
|
+
${lookup?.value ?? null}::text, 1, 1, ${id ?? null}::uuid)`)[0]?.total ?? 0)
|
|
123
|
+
: 0);
|
|
124
|
+
return {
|
|
125
|
+
total: Number(total),
|
|
126
|
+
groups: rows.map((row) => ({
|
|
127
|
+
id: row.group_id,
|
|
128
|
+
displayName: row.display_name,
|
|
129
|
+
externalId: row.external_id,
|
|
130
|
+
members: row.members,
|
|
131
|
+
createdAt: row.created_at,
|
|
132
|
+
updatedAt: row.updated_at,
|
|
133
|
+
role: row.mapped_role,
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
async deleteGroup(tenantRef, id) {
|
|
138
|
+
if (!UUID.test(id))
|
|
139
|
+
return 'not_found';
|
|
140
|
+
const [row] = await sql `SELECT slotlock.scim_delete_group(${tenantRef}::text, ${id}::uuid) AS refusal`;
|
|
141
|
+
return refusalOf(row?.refusal);
|
|
142
|
+
},
|
|
143
|
+
async mapGroup(tenantRef, actor, id, role) {
|
|
144
|
+
if (!UUID.test(id))
|
|
145
|
+
return 'not_found';
|
|
146
|
+
const [row] = await sql `
|
|
147
|
+
SELECT slotlock.map_scim_group_role(${tenantRef}::text, ${actor}::text, ${id}::uuid, ${role}::text) AS refusal`;
|
|
148
|
+
return refusalOf(row?.refusal);
|
|
149
|
+
},
|
|
150
|
+
async bind(person, emails, freeCap) {
|
|
151
|
+
if (emails.length === 0)
|
|
152
|
+
return [];
|
|
153
|
+
const rows = await sql `SELECT team_ref FROM slotlock.scim_bind(${person}::text, ${[...emails].slice(0, 10)}::text[], ${freeCap ?? null}::integer)`;
|
|
154
|
+
return rows.map((row) => row.team_ref);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { SlotlockOidcAlgorithm } from './oidc.js';
|
|
2
|
+
import type { SlotlockSql } from './store.js';
|
|
3
|
+
import { type SlotlockWorkspaceRole } from './workspace-roles.js';
|
|
4
|
+
/** The providers a team may connect: their own rules apply (oidc.ts, dashboard). */
|
|
5
|
+
export type SlotlockSsoKind = 'okta' | 'entra' | 'google_workspace' | 'oidc';
|
|
6
|
+
/** Why a change to a team's single sign-on, or a sign-in through it, was refused. */
|
|
7
|
+
export type SlotlockSsoRefusal = 'not_found' | 'not_allowed' | 'no_verified_domain' | 'secret_required' | 'conflict' | 'stale_config' | 'untested' | 'no_breakglass_owner' | 'role_not_allowed' | 'owner_not_mappable' | 'invalid_value' | 'mapping_limit' | 'connection_inactive' | 'guest_refused' | 'not_member' | 'identity_taken' | 'identity_untrusted' | 'hd_mismatch' | 'member_cap' | 'invalid_max_age' | 'no_active_connection' | 'stale_sso_proof';
|
|
8
|
+
/** A team's connection, as saved. */
|
|
9
|
+
export interface SlotlockSsoConnection {
|
|
10
|
+
id: string;
|
|
11
|
+
kind: SlotlockSsoKind;
|
|
12
|
+
status: 'draft' | 'tested' | 'active' | 'disabled';
|
|
13
|
+
issuer: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
/** The client secret as sealed (null for Google Workspace, which uses Slotlock's own client). */
|
|
16
|
+
secretSealed: string | null;
|
|
17
|
+
secretLast4: string | null;
|
|
18
|
+
authorizationEndpoint: string;
|
|
19
|
+
tokenEndpoint: string;
|
|
20
|
+
jwksUri: string;
|
|
21
|
+
algorithms: SlotlockOidcAlgorithm[];
|
|
22
|
+
issParameter: boolean;
|
|
23
|
+
entraTenant: string | null;
|
|
24
|
+
groupsClaim: string;
|
|
25
|
+
jitEnabled: boolean;
|
|
26
|
+
defaultRole: 'viewer' | 'member' | 'developer';
|
|
27
|
+
configVersion: number;
|
|
28
|
+
testedVersion: number | null;
|
|
29
|
+
lastTestedAt: Date | null;
|
|
30
|
+
lastTestClaims: Record<string, unknown> | null;
|
|
31
|
+
requireSso: boolean;
|
|
32
|
+
maxAgeHours: number;
|
|
33
|
+
}
|
|
34
|
+
/** A connection as its callback reads it: with its team. */
|
|
35
|
+
export interface SlotlockSsoConnectionForSignIn {
|
|
36
|
+
id: string;
|
|
37
|
+
tenantRef: string;
|
|
38
|
+
handle: string;
|
|
39
|
+
name: string;
|
|
40
|
+
kind: SlotlockSsoKind;
|
|
41
|
+
status: SlotlockSsoConnection['status'];
|
|
42
|
+
issuer: string;
|
|
43
|
+
clientId: string;
|
|
44
|
+
secretSealed: string | null;
|
|
45
|
+
tokenEndpoint: string;
|
|
46
|
+
jwksUri: string;
|
|
47
|
+
algorithms: SlotlockOidcAlgorithm[];
|
|
48
|
+
issParameter: boolean;
|
|
49
|
+
entraTenant: string | null;
|
|
50
|
+
groupsClaim: string;
|
|
51
|
+
configVersion: number;
|
|
52
|
+
}
|
|
53
|
+
/** What a sign-in through a team's provider gave: who, and what happened. */
|
|
54
|
+
export interface SlotlockSsoSignIn {
|
|
55
|
+
person: string | null;
|
|
56
|
+
/** `joined`, `signed_in`, `role_changed` (alone); `linked`, `proof` (signed in already). */
|
|
57
|
+
outcome: 'joined' | 'signed_in' | 'role_changed' | 'linked' | 'proof' | null;
|
|
58
|
+
refusal: SlotlockSsoRefusal | null;
|
|
59
|
+
role: SlotlockWorkspaceRole | null;
|
|
60
|
+
}
|
|
61
|
+
export interface SlotlockWorkspaceSso {
|
|
62
|
+
connection(tenantRef: string): Promise<SlotlockSsoConnection | null>;
|
|
63
|
+
connectionById(id: string): Promise<SlotlockSsoConnectionForSignIn | null>;
|
|
64
|
+
/** A team's active connection, by its handle or a domain it holds verified. */
|
|
65
|
+
route(by: {
|
|
66
|
+
handle?: string;
|
|
67
|
+
domain?: string;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
id: string;
|
|
70
|
+
tenantRef: string;
|
|
71
|
+
handle: string;
|
|
72
|
+
name: string;
|
|
73
|
+
authorizationEndpoint: string;
|
|
74
|
+
clientId: string;
|
|
75
|
+
kind: SlotlockSsoKind;
|
|
76
|
+
entraTenant: string | null;
|
|
77
|
+
} | null>;
|
|
78
|
+
save(params: {
|
|
79
|
+
tenantRef: string;
|
|
80
|
+
actor: string;
|
|
81
|
+
id: string;
|
|
82
|
+
kind: SlotlockSsoKind;
|
|
83
|
+
issuer: string;
|
|
84
|
+
clientId: string;
|
|
85
|
+
secretSealed: string | null;
|
|
86
|
+
secretLast4: string | null;
|
|
87
|
+
authorizationEndpoint: string;
|
|
88
|
+
tokenEndpoint: string;
|
|
89
|
+
jwksUri: string;
|
|
90
|
+
algorithms: readonly SlotlockOidcAlgorithm[];
|
|
91
|
+
issParameter: boolean;
|
|
92
|
+
entraTenant: string | null;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
id: string;
|
|
95
|
+
enforcementOff: boolean;
|
|
96
|
+
} | {
|
|
97
|
+
refusal: SlotlockSsoRefusal;
|
|
98
|
+
id?: string;
|
|
99
|
+
}>;
|
|
100
|
+
recordTest(tenantRef: string, actor: string, id: string, version: number, claims: Record<string, unknown>): Promise<SlotlockSsoRefusal | null>;
|
|
101
|
+
setStatus(tenantRef: string, actor: string, id: string, status: 'active' | 'disabled'): Promise<SlotlockSsoRefusal | null>;
|
|
102
|
+
remove(tenantRef: string, actor: string, id: string): Promise<SlotlockSsoRefusal | null>;
|
|
103
|
+
setProvisioning(tenantRef: string, actor: string, jit: boolean, defaultRole: string, groupsClaim: string): Promise<SlotlockSsoRefusal | null>;
|
|
104
|
+
setMapping(tenantRef: string, actor: string, value: string, role: string | null): Promise<SlotlockSsoRefusal | null>;
|
|
105
|
+
mappings(tenantRef: string): Promise<{
|
|
106
|
+
value: string;
|
|
107
|
+
role: SlotlockWorkspaceRole;
|
|
108
|
+
}[]>;
|
|
109
|
+
signIn(params: {
|
|
110
|
+
connectionId: string;
|
|
111
|
+
issuer: string;
|
|
112
|
+
subject: string;
|
|
113
|
+
entraTid: string | null;
|
|
114
|
+
entraOid: string | null;
|
|
115
|
+
email: string | null;
|
|
116
|
+
emailOk: boolean;
|
|
117
|
+
groups: readonly string[];
|
|
118
|
+
overage: boolean;
|
|
119
|
+
guest: boolean;
|
|
120
|
+
sessionPerson: string | null;
|
|
121
|
+
memberCap: number | null;
|
|
122
|
+
}): Promise<SlotlockSsoSignIn>;
|
|
123
|
+
/** A session's proof from the connection, now; `scoped` for a session begun through it alone. */
|
|
124
|
+
/** `configVersion`: the connection's configuration the sign-in went through, where known. */
|
|
125
|
+
recordProof(sid: string, person: string, connectionId: string, scoped: boolean, configVersion?: number): Promise<boolean>;
|
|
126
|
+
requireSso(tenantRef: string, actor: string, sid: string, on: boolean, maxAgeHours: number): Promise<SlotlockSsoRefusal | null>;
|
|
127
|
+
bypass(tenantRef: string, person: string, sid: string): Promise<SlotlockSsoRefusal | null>;
|
|
128
|
+
prune(): Promise<number>;
|
|
129
|
+
}
|
|
130
|
+
/** Through the functions of ddl-sso.ts. */
|
|
131
|
+
export declare function createSlotlockWorkspaceSso(sql: SlotlockSql): SlotlockWorkspaceSso;
|
package/workspace-sso.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// A team's single sign-on as the dashboard reaches it (teams phase 5.3–5.5): through the SECURITY
|
|
2
|
+
// DEFINER functions of ddl-sso.ts, which check the actor's role, lock the team and decide whom a
|
|
3
|
+
// provider's sign-in is. Tokens are checked before (oidc.ts); secrets are sealed before (oidc.ts
|
|
4
|
+
// createSlotlockSecretBox), so only a sealed secret ever reaches the database.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { isSlotlockWorkspaceRole } from './workspace-roles.js';
|
|
7
|
+
const TEAM = /^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const sessionDigest = (sid) => createHash('sha256').update(sid, 'utf8').digest();
|
|
10
|
+
const refusalOf = (value) => value === null || value === undefined ? null : value;
|
|
11
|
+
/** Through the functions of ddl-sso.ts. */
|
|
12
|
+
export function createSlotlockWorkspaceSso(sql) {
|
|
13
|
+
return {
|
|
14
|
+
async connection(tenantRef) {
|
|
15
|
+
if (!TEAM.test(tenantRef))
|
|
16
|
+
return null;
|
|
17
|
+
const [row] = await sql `SELECT * FROM slotlock.sso_connection_of(${tenantRef}::text)`;
|
|
18
|
+
if (!row)
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
id: row.connection_id,
|
|
22
|
+
kind: row.kind,
|
|
23
|
+
status: row.status,
|
|
24
|
+
issuer: row.issuer,
|
|
25
|
+
clientId: row.client_id,
|
|
26
|
+
secretSealed: row.secret_sealed,
|
|
27
|
+
secretLast4: row.secret_last4,
|
|
28
|
+
authorizationEndpoint: row.authorization_endpoint,
|
|
29
|
+
tokenEndpoint: row.token_endpoint,
|
|
30
|
+
jwksUri: row.jwks_uri,
|
|
31
|
+
algorithms: row.algorithms,
|
|
32
|
+
issParameter: row.iss_parameter,
|
|
33
|
+
entraTenant: row.entra_tenant,
|
|
34
|
+
groupsClaim: row.groups_claim,
|
|
35
|
+
jitEnabled: row.jit_enabled,
|
|
36
|
+
defaultRole: row.default_role,
|
|
37
|
+
configVersion: Number(row.config_version),
|
|
38
|
+
testedVersion: row.tested_version === null ? null : Number(row.tested_version),
|
|
39
|
+
lastTestedAt: row.last_tested_at,
|
|
40
|
+
lastTestClaims: row.last_test_claims,
|
|
41
|
+
requireSso: row.require_sso,
|
|
42
|
+
maxAgeHours: Number(row.sso_max_age_hours),
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
async connectionById(id) {
|
|
46
|
+
if (!UUID.test(id))
|
|
47
|
+
return null;
|
|
48
|
+
const [row] = await sql `SELECT * FROM slotlock.sso_connection_by_id(${id}::uuid)`;
|
|
49
|
+
if (!row)
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
tenantRef: row.team_ref,
|
|
54
|
+
handle: row.handle,
|
|
55
|
+
name: row.name,
|
|
56
|
+
kind: row.kind,
|
|
57
|
+
status: row.status,
|
|
58
|
+
issuer: row.issuer,
|
|
59
|
+
clientId: row.client_id,
|
|
60
|
+
secretSealed: row.secret_sealed,
|
|
61
|
+
tokenEndpoint: row.token_endpoint,
|
|
62
|
+
jwksUri: row.jwks_uri,
|
|
63
|
+
algorithms: row.algorithms,
|
|
64
|
+
issParameter: row.iss_parameter,
|
|
65
|
+
entraTenant: row.entra_tenant,
|
|
66
|
+
groupsClaim: row.groups_claim,
|
|
67
|
+
configVersion: Number(row.config_version),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
async route(by) {
|
|
71
|
+
const handle = by.handle?.trim().toLowerCase() || null;
|
|
72
|
+
const domain = by.domain?.trim().toLowerCase() || null;
|
|
73
|
+
if (handle === null && domain === null)
|
|
74
|
+
return null;
|
|
75
|
+
const [row] = await sql `SELECT * FROM slotlock.sso_connection_route(${handle}::text, ${domain}::text)`;
|
|
76
|
+
return row
|
|
77
|
+
? {
|
|
78
|
+
id: row.connection_id,
|
|
79
|
+
tenantRef: row.team_ref,
|
|
80
|
+
handle: row.handle,
|
|
81
|
+
name: row.name,
|
|
82
|
+
authorizationEndpoint: row.authorization_endpoint,
|
|
83
|
+
clientId: row.client_id,
|
|
84
|
+
kind: row.kind,
|
|
85
|
+
entraTenant: row.entra_tenant,
|
|
86
|
+
}
|
|
87
|
+
: null;
|
|
88
|
+
},
|
|
89
|
+
async save(params) {
|
|
90
|
+
if (!TEAM.test(params.tenantRef) || !UUID.test(params.id))
|
|
91
|
+
return { refusal: 'not_found' };
|
|
92
|
+
const [row] = await sql `
|
|
93
|
+
SELECT connection_id, refusal, enforcement_off FROM slotlock.save_sso_connection(
|
|
94
|
+
${params.tenantRef}::text, ${params.actor}::text, ${params.id}::uuid, ${params.kind}::text, ${params.issuer}::text,
|
|
95
|
+
${params.clientId}::text, ${params.secretSealed}::text, ${params.secretLast4}::text,
|
|
96
|
+
${params.authorizationEndpoint}::text, ${params.tokenEndpoint}::text, ${params.jwksUri}::text,
|
|
97
|
+
${[...params.algorithms]}::text[], ${params.issParameter}::boolean, ${params.entraTenant}::text)`;
|
|
98
|
+
if (row?.refusal)
|
|
99
|
+
return { refusal: refusalOf(row.refusal) ?? 'not_allowed', ...(row.connection_id ? { id: row.connection_id } : {}) };
|
|
100
|
+
return row?.connection_id ? { id: row.connection_id, enforcementOff: row.enforcement_off } : { refusal: 'not_allowed' };
|
|
101
|
+
},
|
|
102
|
+
async recordTest(tenantRef, actor, id, version, claims) {
|
|
103
|
+
if (!UUID.test(id))
|
|
104
|
+
return 'not_found';
|
|
105
|
+
const [row] = await sql `
|
|
106
|
+
SELECT slotlock.record_sso_test(${tenantRef}::text, ${actor}::text, ${id}::uuid, ${version}::integer,
|
|
107
|
+
${JSON.stringify(claims)}::text::jsonb) AS refusal`;
|
|
108
|
+
return refusalOf(row?.refusal);
|
|
109
|
+
},
|
|
110
|
+
async setStatus(tenantRef, actor, id, status) {
|
|
111
|
+
if (!UUID.test(id))
|
|
112
|
+
return 'not_found';
|
|
113
|
+
const [row] = await sql `
|
|
114
|
+
SELECT slotlock.set_sso_connection_status(${tenantRef}::text, ${actor}::text, ${id}::uuid, ${status}::text) AS refusal`;
|
|
115
|
+
return refusalOf(row?.refusal);
|
|
116
|
+
},
|
|
117
|
+
async remove(tenantRef, actor, id) {
|
|
118
|
+
if (!UUID.test(id))
|
|
119
|
+
return 'not_found';
|
|
120
|
+
const [row] = await sql `
|
|
121
|
+
SELECT slotlock.delete_sso_connection(${tenantRef}::text, ${actor}::text, ${id}::uuid) AS refusal`;
|
|
122
|
+
return refusalOf(row?.refusal);
|
|
123
|
+
},
|
|
124
|
+
async setProvisioning(tenantRef, actor, jit, defaultRole, groupsClaim) {
|
|
125
|
+
const [row] = await sql `
|
|
126
|
+
SELECT slotlock.set_sso_provisioning(${tenantRef}::text, ${actor}::text, ${jit}::boolean, ${defaultRole}::text,
|
|
127
|
+
${groupsClaim}::text) AS refusal`;
|
|
128
|
+
return refusalOf(row?.refusal);
|
|
129
|
+
},
|
|
130
|
+
async setMapping(tenantRef, actor, value, role) {
|
|
131
|
+
const [row] = await sql `
|
|
132
|
+
SELECT slotlock.set_sso_role_mapping(${tenantRef}::text, ${actor}::text, ${value}::text, ${role}::text) AS refusal`;
|
|
133
|
+
return refusalOf(row?.refusal);
|
|
134
|
+
},
|
|
135
|
+
async mappings(tenantRef) {
|
|
136
|
+
if (!TEAM.test(tenantRef))
|
|
137
|
+
return [];
|
|
138
|
+
const rows = await sql `SELECT * FROM slotlock.sso_role_mappings_of(${tenantRef}::text)`;
|
|
139
|
+
return rows.flatMap((row) => (isSlotlockWorkspaceRole(row.role) ? [{ value: row.idp_value, role: row.role }] : []));
|
|
140
|
+
},
|
|
141
|
+
async signIn(params) {
|
|
142
|
+
if (!UUID.test(params.connectionId))
|
|
143
|
+
return { person: null, outcome: null, refusal: 'connection_inactive', role: null };
|
|
144
|
+
const [row] = await sql `
|
|
145
|
+
SELECT person, outcome, refusal, member_role FROM slotlock.sso_sign_in(
|
|
146
|
+
${params.connectionId}::uuid, ${params.issuer}::text, ${params.subject}::text, ${params.entraTid}::text,
|
|
147
|
+
${params.entraOid}::text, ${params.email}::text, ${params.emailOk}::boolean, ${[...params.groups].slice(0, 200)}::text[],
|
|
148
|
+
${params.overage}::boolean, ${params.guest}::boolean, ${params.sessionPerson}::text, ${params.memberCap}::integer)`;
|
|
149
|
+
return {
|
|
150
|
+
person: row?.person ?? null,
|
|
151
|
+
outcome: row?.outcome ?? null,
|
|
152
|
+
refusal: refusalOf(row?.refusal),
|
|
153
|
+
role: isSlotlockWorkspaceRole(row?.member_role) ? row?.member_role : null,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
async recordProof(sid, person, connectionId, scoped, configVersion) {
|
|
157
|
+
if (!UUID.test(connectionId))
|
|
158
|
+
return false;
|
|
159
|
+
const [row] = await sql `
|
|
160
|
+
SELECT slotlock.record_sso_proof(${sessionDigest(sid)}::bytea, ${person}::text, ${connectionId}::uuid, ${scoped}::boolean,
|
|
161
|
+
${configVersion ?? null}::integer) AS recorded`;
|
|
162
|
+
return row?.recorded === true;
|
|
163
|
+
},
|
|
164
|
+
async requireSso(tenantRef, actor, sid, on, maxAgeHours) {
|
|
165
|
+
const [row] = await sql `
|
|
166
|
+
SELECT slotlock.set_require_sso(${tenantRef}::text, ${actor}::text, ${sessionDigest(sid)}::bytea, ${on}::boolean,
|
|
167
|
+
${maxAgeHours}::integer) AS refusal`;
|
|
168
|
+
return refusalOf(row?.refusal);
|
|
169
|
+
},
|
|
170
|
+
async bypass(tenantRef, person, sid) {
|
|
171
|
+
const [row] = await sql `
|
|
172
|
+
SELECT slotlock.sso_owner_bypass(${tenantRef}::text, ${person}::text, ${sessionDigest(sid)}::bytea) AS refusal`;
|
|
173
|
+
return refusalOf(row?.refusal);
|
|
174
|
+
},
|
|
175
|
+
async prune() {
|
|
176
|
+
const [row] = await sql `SELECT slotlock.prune_sso() AS pruned`;
|
|
177
|
+
return Number(row?.pruned ?? 0);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|