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.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// transaction: under autocommit the pin ends with its own statement.
|
|
11
11
|
import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_MAX_KEY_SCOPE_REFS, SLOTLOCK_MAX_COMPOSITE_PARTS, SLOTLOCK_RECONCILE_TOLERANCE_PERCENT, SLOTLOCK_MAX_POOL_SETTINGS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
|
|
12
12
|
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_WEBHOOK_FILTER_REFS, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, legacyCoverageSourceDigestSql, } from './event-feed.js';
|
|
13
|
+
import { SLOTLOCK_IDENTITY_DDL, SLOTLOCK_IDENTITY_FUNCTIONS, SLOTLOCK_IDENTITY_TABLES, SLOTLOCK_INVITE_DOMAIN_REFUSED, } from './ddl-identity.js';
|
|
14
|
+
import { SLOTLOCK_SSO_DDL, SLOTLOCK_SSO_FUNCTIONS, SLOTLOCK_SSO_TABLES } from './ddl-sso.js';
|
|
15
|
+
import { SLOTLOCK_SCIM_DDL, SLOTLOCK_SCIM_FUNCTIONS, SLOTLOCK_SCIM_TABLES } from './ddl-scim.js';
|
|
13
16
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
14
17
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
15
18
|
const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
|
|
@@ -69,9 +72,10 @@ export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into sc
|
|
|
69
72
|
* The schema version this release creates, recorded in `slotlock.schema_version`. Up to 0.2
|
|
70
73
|
* recorded nothing (0.1 was 1, 0.2 was 2); 0.3 to 0.5 recorded 3. 0.6 is 4, so a 0.5 release
|
|
71
74
|
* refuses it rather than restoring the trigger that names a coverage source by its guessable
|
|
72
|
-
* digest.
|
|
75
|
+
* digest. 0.10 is 5, so a 0.9 release refuses it rather than putting back an erase_tenant that
|
|
76
|
+
* leaves team workspaces, their members and invitations behind.
|
|
73
77
|
*/
|
|
74
|
-
export const SLOTLOCK_SCHEMA_VERSION =
|
|
78
|
+
export const SLOTLOCK_SCHEMA_VERSION = 5;
|
|
75
79
|
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
76
80
|
export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
|
|
77
81
|
/** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
|
|
@@ -218,7 +222,21 @@ export const SLOTLOCK_API_KEY_FUNCTIONS = Object.freeze([
|
|
|
218
222
|
* The table behind the dashboard's shared records (dashboard.ts). No role but its owner holds a
|
|
219
223
|
* right on it; it is reached only through SLOTLOCK_DASHBOARD_FUNCTIONS.
|
|
220
224
|
*/
|
|
221
|
-
export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze([
|
|
225
|
+
export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze([
|
|
226
|
+
'dashboard_tokens',
|
|
227
|
+
'dashboard_organizations',
|
|
228
|
+
'dashboard_people',
|
|
229
|
+
'workspace_memberships',
|
|
230
|
+
'workspaces',
|
|
231
|
+
'workspace_invitations',
|
|
232
|
+
'dashboard_sessions',
|
|
233
|
+
// Verified domains and requests to join (ddl-identity.ts).
|
|
234
|
+
...SLOTLOCK_IDENTITY_TABLES,
|
|
235
|
+
// Single sign-on (ddl-sso.ts).
|
|
236
|
+
...SLOTLOCK_SSO_TABLES,
|
|
237
|
+
// SCIM provisioning (ddl-scim.ts).
|
|
238
|
+
...SLOTLOCK_SCIM_TABLES,
|
|
239
|
+
]);
|
|
222
240
|
/** The SECURITY DEFINER functions through which the serving role reaches the dashboard's records. */
|
|
223
241
|
export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
|
|
224
242
|
'slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)',
|
|
@@ -226,8 +244,68 @@ export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
|
|
|
226
244
|
'slotlock.enabled_organizations(text)',
|
|
227
245
|
'slotlock.enable_organization(text, text, text)',
|
|
228
246
|
'slotlock.disable_organization(text, text)',
|
|
247
|
+
'slotlock.dashboard_sign_in(text, text)',
|
|
248
|
+
'slotlock.dashboard_standing(text, text, bytea)',
|
|
249
|
+
'slotlock.dashboard_sign_out_everywhere(text)',
|
|
250
|
+
'slotlock.prune_audit_events(text)',
|
|
251
|
+
'slotlock.dashboard_display(text, text)',
|
|
252
|
+
'slotlock.create_team(text, text, text, integer)',
|
|
253
|
+
'slotlock.workspace_info(text)',
|
|
254
|
+
'slotlock.person_workspaces(text)',
|
|
255
|
+
'slotlock.workspace_members(text)',
|
|
256
|
+
'slotlock.workspace_invitations_list(text)',
|
|
257
|
+
'slotlock.invite_member(text, text, text, text, text, text, bytea, integer, integer, integer)',
|
|
258
|
+
'slotlock.invitation_preview(bytea)',
|
|
259
|
+
'slotlock.accept_invitation(bytea, uuid, text, text[], integer)',
|
|
260
|
+
'slotlock.revoke_invitation(text, text, uuid)',
|
|
261
|
+
'slotlock.resend_invitation(text, text, uuid, bytea, integer, integer, integer)',
|
|
262
|
+
'slotlock.change_member_role(text, text, text, text)',
|
|
263
|
+
'slotlock.remove_member(text, text, text)',
|
|
264
|
+
'slotlock.pending_invitations_for(text, text[])',
|
|
265
|
+
'slotlock.workspace_active(text)',
|
|
266
|
+
'slotlock.prune_workspace_invitations()',
|
|
267
|
+
'slotlock.dashboard_session_started(text, bytea, timestamptz, text, text)',
|
|
268
|
+
'slotlock.dashboard_session_seen(text, bytea)',
|
|
269
|
+
'slotlock.dashboard_sessions_of(text)',
|
|
270
|
+
'slotlock.dashboard_session_end(text, bytea)',
|
|
271
|
+
'slotlock.prune_dashboard_sessions()',
|
|
272
|
+
'slotlock.schedule_team_deletion(text, text, text)',
|
|
273
|
+
'slotlock.cancel_team_deletion(text, text)',
|
|
274
|
+
'slotlock.erase_deleted_teams()',
|
|
275
|
+
'slotlock.workspace_delete_after(text)',
|
|
276
|
+
'slotlock.member_scope(text, text)',
|
|
277
|
+
'slotlock.set_member_scope(text, text, text, jsonb)',
|
|
278
|
+
'slotlock.member_scopes(text)',
|
|
279
|
+
...SLOTLOCK_IDENTITY_FUNCTIONS,
|
|
280
|
+
...SLOTLOCK_SSO_FUNCTIONS,
|
|
281
|
+
...SLOTLOCK_SCIM_FUNCTIONS,
|
|
229
282
|
]);
|
|
283
|
+
/**
|
|
284
|
+
* A workspace's roles (workspace-roles.ts), as workspace_memberships keeps them; and how long the
|
|
285
|
+
* audit log keeps an event.
|
|
286
|
+
*/
|
|
287
|
+
export const SLOTLOCK_WORKSPACE_ROLE_VALUES = Object.freeze(['owner', 'admin', 'developer', 'member', 'viewer', 'billing']);
|
|
288
|
+
export const SLOTLOCK_AUDIT_RETENTION_DAYS = 180;
|
|
289
|
+
/**
|
|
290
|
+
* Days the change history (slotlock.outbox: the feed, and each booking's history) keeps a change on
|
|
291
|
+
* each hosted plan (RFC 0004 D2). A tenant with no plan keeps the store's `retentionDays` (default
|
|
292
|
+
* 30); one with a plan keeps its plan's days, or the store's if longer. A cancelled plan keeps Free's.
|
|
293
|
+
* The workspace audit log keeps SLOTLOCK_AUDIT_RETENTION_DAYS, or the plan's days if longer.
|
|
294
|
+
*/
|
|
295
|
+
export const SLOTLOCK_CHANGE_RETENTION_BY_PLAN = Object.freeze({ free: 30, developer: 90, team: 400 });
|
|
296
|
+
/** The plan's change retention in days for the tenant `tenant` (an SQL expression), null without a plan. */
|
|
297
|
+
const planChangeRetentionDaysSql = (tenant) => `(SELECT CASE WHEN given.status = 'canceled' THEN ${SLOTLOCK_CHANGE_RETENTION_BY_PLAN.free}
|
|
298
|
+
ELSE CASE given.plan ${Object.entries(SLOTLOCK_CHANGE_RETENTION_BY_PLAN)
|
|
299
|
+
.map(([plan, days]) => `WHEN '${plan}' THEN ${days}`)
|
|
300
|
+
.join(' ')} END END
|
|
301
|
+
FROM slotlock.tenant_plan(${tenant}) given LIMIT 1)`;
|
|
230
302
|
/** Live form records one person may leave in the dashboard's records (12 hours of forms). */
|
|
303
|
+
/**
|
|
304
|
+
* The invitations one address is sent in a day, from every team together, where invitations are
|
|
305
|
+
* limited (a deployment with plans): with sign-up open, many accounts each within their own daily
|
|
306
|
+
* limit could otherwise fill one inbox.
|
|
307
|
+
*/
|
|
308
|
+
export const SLOTLOCK_INVITATIONS_PER_ADDRESS = 5;
|
|
231
309
|
export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
|
|
232
310
|
/** The table that records which release migrated the schema (SLOTLOCK_SCHEMA_VERSION). */
|
|
233
311
|
export const SLOTLOCK_SCHEMA_TABLES = Object.freeze(['schema_version']);
|
|
@@ -277,6 +355,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
|
277
355
|
'slotlock.note_usage_threshold(text, date, text, integer, bigint, bigint)',
|
|
278
356
|
'slotlock.tenant_plan_quotas(text)',
|
|
279
357
|
'slotlock.reset_test_tenant(text)',
|
|
358
|
+
'slotlock.prune_changes(text, integer, integer)',
|
|
359
|
+
'slotlock.erase_subject_changes(text, text, text, text)',
|
|
280
360
|
]);
|
|
281
361
|
/** The functions usage metering runs (SLOTLOCK_USAGE=on): serve checks it may, before it starts. */
|
|
282
362
|
export const SLOTLOCK_USAGE_FUNCTIONS = Object.freeze([
|
|
@@ -365,6 +445,13 @@ export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
|
365
445
|
...SLOTLOCK_SCHEMA_FUNCTIONS,
|
|
366
446
|
...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
|
|
367
447
|
]);
|
|
448
|
+
/**
|
|
449
|
+
* Tenant tables the serving role may only add to and read (no UPDATE or DELETE): the audit log,
|
|
450
|
+
* whose rows go only through slotlock.prune_audit_events and erasure, and the change history,
|
|
451
|
+
* whose rows go only through slotlock.prune_changes and erasure, and whose one edit is taking a
|
|
452
|
+
* person out of it (slotlock.erase_subject_changes).
|
|
453
|
+
*/
|
|
454
|
+
export const SLOTLOCK_APPEND_ONLY_TABLES = Object.freeze(['outbox', 'audit_events']);
|
|
368
455
|
/** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
|
|
369
456
|
export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
|
|
370
457
|
...SLOTLOCK_API_KEY_TABLES,
|
|
@@ -1045,59 +1132,1410 @@ CREATE TABLE IF NOT EXISTS slotlock.dashboard_organizations (
|
|
|
1045
1132
|
ALTER TABLE slotlock.dashboard_organizations ENABLE ROW LEVEL SECURITY;
|
|
1046
1133
|
REVOKE ALL ON slotlock.dashboard_organizations FROM PUBLIC;
|
|
1047
1134
|
|
|
1048
|
-
-- Of these organization ids (comma-separated), the ones turned on.
|
|
1049
|
-
CREATE OR REPLACE FUNCTION slotlock.enabled_organizations(requested_ids text)
|
|
1050
|
-
RETURNS SETOF text
|
|
1051
|
-
LANGUAGE sql
|
|
1052
|
-
STABLE
|
|
1135
|
+
-- Of these organization ids (comma-separated), the ones turned on.
|
|
1136
|
+
CREATE OR REPLACE FUNCTION slotlock.enabled_organizations(requested_ids text)
|
|
1137
|
+
RETURNS SETOF text
|
|
1138
|
+
LANGUAGE sql
|
|
1139
|
+
STABLE
|
|
1140
|
+
SECURITY DEFINER
|
|
1141
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1142
|
+
AS $slotlock_enabled_organizations$
|
|
1143
|
+
SELECT org.org_id FROM slotlock.dashboard_organizations org
|
|
1144
|
+
WHERE org.enabled
|
|
1145
|
+
AND org.org_id = ANY (pg_catalog.string_to_array(requested_ids, ','));
|
|
1146
|
+
$slotlock_enabled_organizations$;
|
|
1147
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[2]} FROM PUBLIC;
|
|
1148
|
+
|
|
1149
|
+
-- Turn Slotlock on for an organization; true when it was not on. Who may (an owner of it, read from
|
|
1150
|
+
-- GitHub at sign-in) is the dashboard's to check.
|
|
1151
|
+
CREATE OR REPLACE FUNCTION slotlock.enable_organization(requested_org text, requested_login text, requested_by text)
|
|
1152
|
+
RETURNS boolean
|
|
1153
|
+
LANGUAGE plpgsql
|
|
1154
|
+
VOLATILE
|
|
1155
|
+
SECURITY DEFINER
|
|
1156
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1157
|
+
AS $slotlock_enable_organization$
|
|
1158
|
+
DECLARE
|
|
1159
|
+
was boolean;
|
|
1160
|
+
BEGIN
|
|
1161
|
+
SELECT org.enabled INTO was FROM slotlock.dashboard_organizations org
|
|
1162
|
+
WHERE org.org_id = requested_org FOR UPDATE;
|
|
1163
|
+
INSERT INTO slotlock.dashboard_organizations AS org (org_id, login, enabled, changed_by)
|
|
1164
|
+
VALUES (requested_org, requested_login, true, requested_by)
|
|
1165
|
+
ON CONFLICT (org_id) DO UPDATE
|
|
1166
|
+
SET login = EXCLUDED.login, enabled = true,
|
|
1167
|
+
changed_by = CASE WHEN org.enabled THEN org.changed_by ELSE EXCLUDED.changed_by END,
|
|
1168
|
+
changed_at = CASE WHEN org.enabled THEN org.changed_at ELSE statement_timestamp() END;
|
|
1169
|
+
RETURN was IS NOT TRUE;
|
|
1170
|
+
END
|
|
1171
|
+
$slotlock_enable_organization$;
|
|
1172
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[3]} FROM PUBLIC;
|
|
1173
|
+
|
|
1174
|
+
-- Turn it off; true when it was on.
|
|
1175
|
+
CREATE OR REPLACE FUNCTION slotlock.disable_organization(requested_org text, requested_by text)
|
|
1176
|
+
RETURNS boolean
|
|
1177
|
+
LANGUAGE sql
|
|
1178
|
+
VOLATILE
|
|
1179
|
+
SECURITY DEFINER
|
|
1180
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1181
|
+
AS $slotlock_disable_organization$
|
|
1182
|
+
UPDATE slotlock.dashboard_organizations org
|
|
1183
|
+
SET enabled = false, changed_by = requested_by, changed_at = statement_timestamp()
|
|
1184
|
+
WHERE org.org_id = requested_org AND org.enabled
|
|
1185
|
+
RETURNING true;
|
|
1186
|
+
$slotlock_disable_organization$;
|
|
1187
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[4]} FROM PUBLIC;
|
|
1188
|
+
|
|
1189
|
+
-- The people who sign in to the dashboard, by their identity (github:<id> or google:<subject>,
|
|
1190
|
+
-- never an email): when they last signed in, and the epoch their sessions carry. The epoch is
|
|
1191
|
+
-- drawn at random: "Sign out everywhere" draws another, and a session sealed under any other is
|
|
1192
|
+
-- refused (ASVS 5.0 7.4.1's per-user revocation). A person without a row (erased) has no epoch,
|
|
1193
|
+
-- so none of their sessions passes, and a row made again draws afresh. Owner-only; reached
|
|
1194
|
+
-- through the functions below.
|
|
1195
|
+
CREATE TABLE IF NOT EXISTS slotlock.dashboard_people (
|
|
1196
|
+
person_ref text PRIMARY KEY,
|
|
1197
|
+
session_epoch integer NOT NULL DEFAULT (1 + floor(random() * 2147483646))::integer,
|
|
1198
|
+
last_sign_in_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1199
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1200
|
+
CONSTRAINT slotlock_dashboard_people_valid CHECK (
|
|
1201
|
+
person_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
1202
|
+
AND session_epoch > 0
|
|
1203
|
+
)
|
|
1204
|
+
);
|
|
1205
|
+
ALTER TABLE slotlock.dashboard_people ENABLE ROW LEVEL SECURITY;
|
|
1206
|
+
REVOKE ALL ON slotlock.dashboard_people FROM PUBLIC;
|
|
1207
|
+
|
|
1208
|
+
-- Who belongs to a shared workspace, and as what (workspace-roles.ts). A person's own workspace
|
|
1209
|
+
-- (their github:<id> or google:<subject> tenant) has no row: they are its Owner. The live tenant
|
|
1210
|
+
-- holds the rows; its test environment (~test) has the same members. Inactive rows are kept so
|
|
1211
|
+
-- someone added back gets their role again. Owner-only: the serving role reads and writes roles only
|
|
1212
|
+
-- through the functions below, which check what they are given; it cannot write the table itself.
|
|
1213
|
+
-- A change made in Slotlock (a role given, a member removed) sets source to 'created' or
|
|
1214
|
+
-- 'invitation', and sign-in's mirroring of GitHub never touches such a row again.
|
|
1215
|
+
CREATE TABLE IF NOT EXISTS slotlock.workspace_memberships (
|
|
1216
|
+
tenant_ref text NOT NULL,
|
|
1217
|
+
person_ref text NOT NULL,
|
|
1218
|
+
role text NOT NULL,
|
|
1219
|
+
status text NOT NULL DEFAULT 'active',
|
|
1220
|
+
source text NOT NULL,
|
|
1221
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1222
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1223
|
+
deactivated_at timestamptz,
|
|
1224
|
+
CONSTRAINT slotlock_workspace_memberships_pkey PRIMARY KEY (tenant_ref, person_ref),
|
|
1225
|
+
CONSTRAINT slotlock_workspace_memberships_valid CHECK (
|
|
1226
|
+
tenant_ref ~ '^(github-org:[1-9][0-9]{0,19}|team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
1227
|
+
AND person_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
1228
|
+
AND role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
|
|
1229
|
+
AND status IN ('active', 'inactive')
|
|
1230
|
+
AND source IN ('github_org', 'invitation', 'created', 'domain', 'sso', 'scim')
|
|
1231
|
+
AND (status = 'active') = (deactivated_at IS NULL)
|
|
1232
|
+
)
|
|
1233
|
+
);
|
|
1234
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_memberships_person_idx
|
|
1235
|
+
ON slotlock.workspace_memberships (person_ref) WHERE status = 'active';
|
|
1236
|
+
ALTER TABLE slotlock.workspace_memberships ENABLE ROW LEVEL SECURITY;
|
|
1237
|
+
REVOKE ALL ON slotlock.workspace_memberships FROM PUBLIC;
|
|
1238
|
+
|
|
1239
|
+
-- A sign-in: the person is recorded (their epoch returned, to seal into the session) and, signing
|
|
1240
|
+
-- in with GitHub, the organizations GitHub lists them in, as JSON [{"id":"123","role":"admin"}],
|
|
1241
|
+
-- become their memberships there: GitHub's owners Owner, its members Developer. An organization
|
|
1242
|
+
-- GitHub no longer lists ends the membership it gave. A role given in Slotlock (source other than
|
|
1243
|
+
-- github_org) is never changed here.
|
|
1244
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_sign_in(requested_person text, requested_github_orgs text)
|
|
1245
|
+
RETURNS integer
|
|
1246
|
+
LANGUAGE plpgsql
|
|
1247
|
+
VOLATILE
|
|
1248
|
+
SECURITY DEFINER
|
|
1249
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1250
|
+
AS $slotlock_dashboard_sign_in$
|
|
1251
|
+
DECLARE
|
|
1252
|
+
listed jsonb;
|
|
1253
|
+
epoch integer;
|
|
1254
|
+
BEGIN
|
|
1255
|
+
IF requested_person IS NULL
|
|
1256
|
+
OR requested_person !~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
1257
|
+
RAISE EXCEPTION 'Slotlock dashboard: invalid person' USING ERRCODE = '22023';
|
|
1258
|
+
END IF;
|
|
1259
|
+
-- A person a team's provider made exists only while an identity of theirs does: one erased while
|
|
1260
|
+
-- their sign-in was under way (ddl-sso.ts) is not made again. The identity is locked, so an
|
|
1261
|
+
-- erasure waits for this, or this sees it.
|
|
1262
|
+
IF requested_person ~ '^sso:' THEN
|
|
1263
|
+
PERFORM 1 FROM slotlock.sso_identities identity WHERE identity.person_ref = requested_person FOR SHARE;
|
|
1264
|
+
IF NOT FOUND THEN
|
|
1265
|
+
RAISE EXCEPTION 'Slotlock dashboard: that person is no longer here' USING ERRCODE = 'P0002';
|
|
1266
|
+
END IF;
|
|
1267
|
+
END IF;
|
|
1268
|
+
INSERT INTO slotlock.dashboard_people AS person (person_ref) VALUES (requested_person)
|
|
1269
|
+
ON CONFLICT (person_ref) DO UPDATE SET last_sign_in_at = statement_timestamp()
|
|
1270
|
+
RETURNING person.session_epoch INTO epoch;
|
|
1271
|
+
IF requested_github_orgs IS NULL THEN
|
|
1272
|
+
RETURN epoch;
|
|
1273
|
+
END IF;
|
|
1274
|
+
IF requested_person !~ '^github:' OR octet_length(requested_github_orgs) > 8192 THEN
|
|
1275
|
+
RAISE EXCEPTION 'Slotlock dashboard: invalid organizations' USING ERRCODE = '22023';
|
|
1276
|
+
END IF;
|
|
1277
|
+
listed := requested_github_orgs::jsonb;
|
|
1278
|
+
IF jsonb_typeof(listed) <> 'array' OR jsonb_array_length(listed) > 100 OR EXISTS (
|
|
1279
|
+
SELECT 1 FROM jsonb_array_elements(listed) org
|
|
1280
|
+
WHERE jsonb_typeof(org) <> 'object'
|
|
1281
|
+
OR coalesce(org ->> 'id', '') !~ '^[1-9][0-9]{0,19}$'
|
|
1282
|
+
OR coalesce(org ->> 'role', '') NOT IN ('admin', 'member')
|
|
1283
|
+
) THEN
|
|
1284
|
+
RAISE EXCEPTION 'Slotlock dashboard: invalid organizations' USING ERRCODE = '22023';
|
|
1285
|
+
END IF;
|
|
1286
|
+
INSERT INTO slotlock.workspace_memberships AS member (tenant_ref, person_ref, role, status, source)
|
|
1287
|
+
SELECT DISTINCT ON (org ->> 'id') 'github-org:' || (org ->> 'id'), requested_person,
|
|
1288
|
+
CASE org ->> 'role' WHEN 'admin' THEN 'owner' ELSE 'developer' END, 'active', 'github_org'
|
|
1289
|
+
FROM jsonb_array_elements(listed) org
|
|
1290
|
+
ORDER BY org ->> 'id', org ->> 'role'
|
|
1291
|
+
ON CONFLICT (tenant_ref, person_ref) DO UPDATE
|
|
1292
|
+
SET role = EXCLUDED.role, status = 'active', deactivated_at = NULL,
|
|
1293
|
+
updated_at = statement_timestamp()
|
|
1294
|
+
WHERE member.source = 'github_org';
|
|
1295
|
+
UPDATE slotlock.workspace_memberships member
|
|
1296
|
+
SET status = 'inactive', deactivated_at = statement_timestamp(), updated_at = statement_timestamp()
|
|
1297
|
+
WHERE member.person_ref = requested_person
|
|
1298
|
+
AND member.source = 'github_org'
|
|
1299
|
+
AND member.status = 'active'
|
|
1300
|
+
AND NOT EXISTS (
|
|
1301
|
+
SELECT 1 FROM jsonb_array_elements(listed) org
|
|
1302
|
+
WHERE member.tenant_ref = 'github-org:' || (org ->> 'id'));
|
|
1303
|
+
RETURN epoch;
|
|
1304
|
+
END
|
|
1305
|
+
$slotlock_dashboard_sign_in$;
|
|
1306
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[5]} FROM PUBLIC;
|
|
1307
|
+
|
|
1308
|
+
-- A person's standing in a workspace now is slotlock.dashboard_standing(tenant, person, session),
|
|
1309
|
+
-- in ddl-sso.ts: it reads the single sign-on tables, so it is made after them. The form before
|
|
1310
|
+
-- single sign-on, without the session, is dropped, so every caller passes the session.
|
|
1311
|
+
DROP FUNCTION IF EXISTS slotlock.dashboard_standing(text, text);
|
|
1312
|
+
|
|
1313
|
+
-- Sign a person out everywhere: a new epoch is drawn (never the old one), and every session sealed
|
|
1314
|
+
-- under the old is refused from now on. Returns the new epoch.
|
|
1315
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_sign_out_everywhere(requested_person text)
|
|
1316
|
+
RETURNS integer
|
|
1317
|
+
LANGUAGE plpgsql
|
|
1318
|
+
VOLATILE
|
|
1319
|
+
SECURITY DEFINER
|
|
1320
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1321
|
+
AS $slotlock_dashboard_sign_out_everywhere$
|
|
1322
|
+
DECLARE
|
|
1323
|
+
epoch integer;
|
|
1324
|
+
BEGIN
|
|
1325
|
+
IF requested_person IS NULL
|
|
1326
|
+
OR requested_person !~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
1327
|
+
RAISE EXCEPTION 'Slotlock dashboard: invalid person' USING ERRCODE = '22023';
|
|
1328
|
+
END IF;
|
|
1329
|
+
INSERT INTO slotlock.dashboard_people AS person (person_ref) VALUES (requested_person)
|
|
1330
|
+
ON CONFLICT (person_ref) DO UPDATE
|
|
1331
|
+
SET session_epoch = (
|
|
1332
|
+
SELECT CASE WHEN drawn.epoch = person.session_epoch THEN drawn.epoch % 2147483646 + 1 ELSE drawn.epoch END
|
|
1333
|
+
FROM (SELECT (1 + floor(random() * 2147483646))::integer AS epoch) drawn)
|
|
1334
|
+
RETURNING person.session_epoch INTO epoch;
|
|
1335
|
+
RETURN epoch;
|
|
1336
|
+
END
|
|
1337
|
+
$slotlock_dashboard_sign_out_everywhere$;
|
|
1338
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[7]} FROM PUBLIC;
|
|
1339
|
+
|
|
1340
|
+
-- Team workspaces (team:<uuid>): a workspace Slotlock owns, not a person's and not a GitHub
|
|
1341
|
+
-- organization's, so its owners can change. Owner-only; reached through the functions below.
|
|
1342
|
+
CREATE TABLE IF NOT EXISTS slotlock.workspaces (
|
|
1343
|
+
tenant_ref text PRIMARY KEY,
|
|
1344
|
+
handle text NOT NULL,
|
|
1345
|
+
name text NOT NULL,
|
|
1346
|
+
status text NOT NULL DEFAULT 'active',
|
|
1347
|
+
-- Who created it, for the free team workspaces a person may create; cleared when they are erased.
|
|
1348
|
+
created_by text,
|
|
1349
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1350
|
+
CONSTRAINT slotlock_workspaces_handle_key UNIQUE (handle),
|
|
1351
|
+
CONSTRAINT slotlock_workspaces_valid CHECK (
|
|
1352
|
+
tenant_ref ~ '^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
1353
|
+
AND handle ~ '^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$'
|
|
1354
|
+
AND char_length(name) BETWEEN 1 AND 80
|
|
1355
|
+
AND name !~ '[[:cntrl:]]'
|
|
1356
|
+
AND status IN ('active')
|
|
1357
|
+
AND (created_by IS NULL OR created_by ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$')
|
|
1358
|
+
)
|
|
1359
|
+
);
|
|
1360
|
+
ALTER TABLE slotlock.workspaces ENABLE ROW LEVEL SECURITY;
|
|
1361
|
+
REVOKE ALL ON slotlock.workspaces FROM PUBLIC;
|
|
1362
|
+
|
|
1363
|
+
-- How a person is shown to the others in their workspaces: their GitHub login or the Google email
|
|
1364
|
+
-- Google verified, as they last signed in. Never an identifier: people are their github:/google: ref.
|
|
1365
|
+
ALTER TABLE slotlock.dashboard_people ADD COLUMN IF NOT EXISTS display text;
|
|
1366
|
+
DO $slotlock_people_display$
|
|
1367
|
+
BEGIN
|
|
1368
|
+
IF NOT EXISTS (
|
|
1369
|
+
SELECT 1 FROM pg_constraint
|
|
1370
|
+
WHERE conname = 'slotlock_dashboard_people_display_valid'
|
|
1371
|
+
AND conrelid = 'slotlock.dashboard_people'::regclass
|
|
1372
|
+
) THEN
|
|
1373
|
+
ALTER TABLE slotlock.dashboard_people ADD CONSTRAINT slotlock_dashboard_people_display_valid
|
|
1374
|
+
CHECK (display IS NULL OR (char_length(display) BETWEEN 1 AND 254 AND display !~ '[[:cntrl:]<>]'));
|
|
1375
|
+
END IF;
|
|
1376
|
+
END
|
|
1377
|
+
$slotlock_people_display$;
|
|
1378
|
+
|
|
1379
|
+
-- Invitations to a team workspace: to an email address or a GitHub account, with a role, by a
|
|
1380
|
+
-- member entitled to give it. Only a SHA-256 digest of the token is kept (the token is 256 random
|
|
1381
|
+
-- bits, shown or sent once). An invitation is pending until accepted, revoked or past expires_at;
|
|
1382
|
+
-- accepting needs the signed-in person to be the GitHub account invited, or to hold the invited
|
|
1383
|
+
-- email verified. Owner-only; reached through the functions below.
|
|
1384
|
+
CREATE TABLE IF NOT EXISTS slotlock.workspace_invitations (
|
|
1385
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1386
|
+
tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
|
|
1387
|
+
token_sha256 bytea NOT NULL,
|
|
1388
|
+
email text,
|
|
1389
|
+
github_ref text,
|
|
1390
|
+
github_login text,
|
|
1391
|
+
role text NOT NULL,
|
|
1392
|
+
inviter_ref text NOT NULL,
|
|
1393
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1394
|
+
sent_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1395
|
+
expires_at timestamptz NOT NULL,
|
|
1396
|
+
accepted_at timestamptz,
|
|
1397
|
+
accepted_by text,
|
|
1398
|
+
revoked_at timestamptz,
|
|
1399
|
+
CONSTRAINT slotlock_workspace_invitations_token_key UNIQUE (token_sha256),
|
|
1400
|
+
CONSTRAINT slotlock_workspace_invitations_valid CHECK (
|
|
1401
|
+
octet_length(token_sha256) = 32
|
|
1402
|
+
AND (email IS NULL) <> (github_ref IS NULL)
|
|
1403
|
+
AND (email IS NULL OR (char_length(email) BETWEEN 3 AND 254 AND email = lower(email)
|
|
1404
|
+
AND email ~ '^[^@[:space:][:cntrl:]<>]+@[^@[:space:][:cntrl:]<>]+$'))
|
|
1405
|
+
AND (github_ref IS NULL OR github_ref ~ '^github:[1-9][0-9]{0,19}$')
|
|
1406
|
+
AND (github_login IS NULL OR github_login ~ '^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$')
|
|
1407
|
+
AND role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
|
|
1408
|
+
AND inviter_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
1409
|
+
AND (accepted_by IS NULL OR accepted_by ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$')
|
|
1410
|
+
AND expires_at > created_at
|
|
1411
|
+
)
|
|
1412
|
+
);
|
|
1413
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_tenant_idx
|
|
1414
|
+
ON slotlock.workspace_invitations (tenant_ref, sent_at DESC);
|
|
1415
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_github_idx
|
|
1416
|
+
ON slotlock.workspace_invitations (github_ref) WHERE accepted_at IS NULL AND revoked_at IS NULL;
|
|
1417
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_email_idx
|
|
1418
|
+
ON slotlock.workspace_invitations (email) WHERE accepted_at IS NULL AND revoked_at IS NULL;
|
|
1419
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_sent_email_idx
|
|
1420
|
+
ON slotlock.workspace_invitations (email, sent_at) WHERE email IS NOT NULL;
|
|
1421
|
+
ALTER TABLE slotlock.workspace_invitations ENABLE ROW LEVEL SECURITY;
|
|
1422
|
+
REVOKE ALL ON slotlock.workspace_invitations FROM PUBLIC;
|
|
1423
|
+
|
|
1424
|
+
-- Whether a role may give another (workspace-roles.ts slotlockRoleMayGrant): an Owner any role, an
|
|
1425
|
+
-- Admin up to Admin (never Owner or Billing), anyone else none.
|
|
1426
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_may_grant(granter text, granted text)
|
|
1427
|
+
RETURNS boolean
|
|
1428
|
+
LANGUAGE sql
|
|
1429
|
+
IMMUTABLE
|
|
1430
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1431
|
+
AS $slotlock_workspace_may_grant$
|
|
1432
|
+
SELECT CASE granter
|
|
1433
|
+
WHEN 'owner' THEN granted IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
|
|
1434
|
+
WHEN 'admin' THEN granted IN ('admin', 'developer', 'member', 'viewer')
|
|
1435
|
+
ELSE false
|
|
1436
|
+
END;
|
|
1437
|
+
$slotlock_workspace_may_grant$;
|
|
1438
|
+
REVOKE ALL ON FUNCTION slotlock.workspace_may_grant(text, text) FROM PUBLIC;
|
|
1439
|
+
|
|
1440
|
+
-- A person's display (their GitHub login, or the email Google verified), at sign-in.
|
|
1441
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_display(requested_person text, requested_display text)
|
|
1442
|
+
RETURNS void
|
|
1443
|
+
LANGUAGE plpgsql
|
|
1444
|
+
VOLATILE
|
|
1445
|
+
SECURITY DEFINER
|
|
1446
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1447
|
+
AS $slotlock_dashboard_display$
|
|
1448
|
+
BEGIN
|
|
1449
|
+
UPDATE slotlock.dashboard_people person
|
|
1450
|
+
SET display = NULLIF(requested_display, '')
|
|
1451
|
+
WHERE person.person_ref = requested_person;
|
|
1452
|
+
END
|
|
1453
|
+
$slotlock_dashboard_display$;
|
|
1454
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[9]} FROM PUBLIC;
|
|
1455
|
+
|
|
1456
|
+
-- A new team workspace, its creator its Owner. With requested_cap, a person may have created at
|
|
1457
|
+
-- most that many active team workspaces that are not on a paid plan.
|
|
1458
|
+
CREATE OR REPLACE FUNCTION slotlock.create_team(
|
|
1459
|
+
requested_person text, requested_name text, requested_handle text, requested_cap integer)
|
|
1460
|
+
RETURNS TABLE (tenant_ref text, refusal text)
|
|
1461
|
+
LANGUAGE plpgsql
|
|
1462
|
+
VOLATILE
|
|
1463
|
+
SECURITY DEFINER
|
|
1464
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1465
|
+
AS $slotlock_create_team$
|
|
1466
|
+
DECLARE
|
|
1467
|
+
created text := 'team:' || gen_random_uuid()::text;
|
|
1468
|
+
free_count integer;
|
|
1469
|
+
BEGIN
|
|
1470
|
+
IF requested_person !~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
1471
|
+
RAISE EXCEPTION 'Slotlock teams: invalid person' USING ERRCODE = '22023';
|
|
1472
|
+
END IF;
|
|
1473
|
+
-- A person a team's identity provider made (sso:) creates no team: their organization holds them.
|
|
1474
|
+
IF requested_person ~ '^sso:' THEN
|
|
1475
|
+
RETURN QUERY SELECT NULL::text, 'not_allowed'::text;
|
|
1476
|
+
RETURN;
|
|
1477
|
+
END IF;
|
|
1478
|
+
IF requested_handle IS NULL OR requested_handle !~ '^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$' THEN
|
|
1479
|
+
RETURN QUERY SELECT NULL::text, 'invalid_handle'::text;
|
|
1480
|
+
RETURN;
|
|
1481
|
+
END IF;
|
|
1482
|
+
IF requested_name IS NULL OR char_length(btrim(requested_name)) NOT BETWEEN 1 AND 80
|
|
1483
|
+
OR requested_name ~ '[[:cntrl:]]' THEN
|
|
1484
|
+
RETURN QUERY SELECT NULL::text, 'invalid_name'::text;
|
|
1485
|
+
RETURN;
|
|
1486
|
+
END IF;
|
|
1487
|
+
-- One person's creations at a time, so two at once cannot both pass the cap.
|
|
1488
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:create-team:' || requested_person, 0));
|
|
1489
|
+
IF requested_cap IS NOT NULL THEN
|
|
1490
|
+
SELECT count(*) INTO free_count FROM slotlock.workspaces ws
|
|
1491
|
+
WHERE ws.created_by = requested_person AND ws.status = 'active'
|
|
1492
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans paid
|
|
1493
|
+
WHERE paid.tenant_ref = ws.tenant_ref AND paid.plan <> 'free' AND paid.status <> 'canceled');
|
|
1494
|
+
IF free_count >= requested_cap THEN
|
|
1495
|
+
RETURN QUERY SELECT NULL::text, 'team_limit'::text;
|
|
1496
|
+
RETURN;
|
|
1497
|
+
END IF;
|
|
1498
|
+
END IF;
|
|
1499
|
+
IF EXISTS (SELECT 1 FROM slotlock.workspaces ws WHERE ws.handle = requested_handle) THEN
|
|
1500
|
+
RETURN QUERY SELECT NULL::text, 'handle_taken'::text;
|
|
1501
|
+
RETURN;
|
|
1502
|
+
END IF;
|
|
1503
|
+
INSERT INTO slotlock.workspaces (tenant_ref, handle, name, created_by)
|
|
1504
|
+
VALUES (created, requested_handle, btrim(requested_name), requested_person);
|
|
1505
|
+
INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source)
|
|
1506
|
+
VALUES (created, requested_person, 'owner', 'created');
|
|
1507
|
+
RETURN QUERY SELECT created, NULL::text;
|
|
1508
|
+
EXCEPTION WHEN unique_violation THEN
|
|
1509
|
+
RETURN QUERY SELECT NULL::text, 'handle_taken'::text;
|
|
1510
|
+
END
|
|
1511
|
+
$slotlock_create_team$;
|
|
1512
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[10]} FROM PUBLIC;
|
|
1513
|
+
|
|
1514
|
+
-- A team workspace's handle, name and status (its test environment's are its own).
|
|
1515
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_info(requested_tenant text)
|
|
1516
|
+
RETURNS TABLE (handle text, name text, status text)
|
|
1517
|
+
LANGUAGE plpgsql
|
|
1518
|
+
STABLE
|
|
1519
|
+
SECURITY DEFINER
|
|
1520
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1521
|
+
AS $slotlock_workspace_info$
|
|
1522
|
+
BEGIN
|
|
1523
|
+
RETURN QUERY SELECT ws.handle, ws.name, ws.status FROM slotlock.workspaces ws
|
|
1524
|
+
WHERE ws.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '');
|
|
1525
|
+
END
|
|
1526
|
+
$slotlock_workspace_info$;
|
|
1527
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[11]} FROM PUBLIC;
|
|
1528
|
+
|
|
1529
|
+
-- The team workspaces a person is an active member of, with their role, by name.
|
|
1530
|
+
CREATE OR REPLACE FUNCTION slotlock.person_workspaces(requested_person text)
|
|
1531
|
+
RETURNS TABLE (tenant_ref text, handle text, name text, role text)
|
|
1532
|
+
LANGUAGE plpgsql
|
|
1533
|
+
STABLE
|
|
1534
|
+
SECURITY DEFINER
|
|
1535
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1536
|
+
AS $slotlock_person_workspaces$
|
|
1537
|
+
BEGIN
|
|
1538
|
+
RETURN QUERY SELECT ws.tenant_ref, ws.handle, ws.name, member.role
|
|
1539
|
+
FROM slotlock.workspace_memberships member
|
|
1540
|
+
JOIN slotlock.workspaces ws ON ws.tenant_ref = member.tenant_ref AND ws.status IN ('active', 'deleting')
|
|
1541
|
+
WHERE member.person_ref = requested_person AND member.status = 'active'
|
|
1542
|
+
ORDER BY lower(ws.name), ws.handle
|
|
1543
|
+
LIMIT 100;
|
|
1544
|
+
END
|
|
1545
|
+
$slotlock_person_workspaces$;
|
|
1546
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[12]} FROM PUBLIC;
|
|
1547
|
+
|
|
1548
|
+
-- A workspace's active members, Owners first, with how each is shown.
|
|
1549
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_members(requested_tenant text)
|
|
1550
|
+
RETURNS TABLE (person_ref text, role text, source text, display text, joined_at timestamptz)
|
|
1551
|
+
LANGUAGE plpgsql
|
|
1552
|
+
STABLE
|
|
1553
|
+
SECURITY DEFINER
|
|
1554
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1555
|
+
AS $slotlock_workspace_members$
|
|
1556
|
+
BEGIN
|
|
1557
|
+
RETURN QUERY SELECT member.person_ref, member.role, member.source, person.display, member.created_at
|
|
1558
|
+
FROM slotlock.workspace_memberships member
|
|
1559
|
+
LEFT JOIN slotlock.dashboard_people person ON person.person_ref = member.person_ref
|
|
1560
|
+
WHERE member.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
|
|
1561
|
+
AND member.status = 'active'
|
|
1562
|
+
ORDER BY array_position(ARRAY['owner', 'admin', 'developer', 'member', 'viewer', 'billing'], member.role),
|
|
1563
|
+
lower(coalesce(person.display, member.person_ref))
|
|
1564
|
+
LIMIT 1000;
|
|
1565
|
+
END
|
|
1566
|
+
$slotlock_workspace_members$;
|
|
1567
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[13]} FROM PUBLIC;
|
|
1568
|
+
|
|
1569
|
+
-- A workspace's invitations not accepted or revoked: pending ones, and those expired in the last
|
|
1570
|
+
-- 30 days (to send again), newest first.
|
|
1571
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_invitations_list(requested_tenant text)
|
|
1572
|
+
RETURNS TABLE (id uuid, email text, github_login text, role text, inviter_display text,
|
|
1573
|
+
sent_at timestamptz, expires_at timestamptz)
|
|
1574
|
+
LANGUAGE plpgsql
|
|
1575
|
+
STABLE
|
|
1576
|
+
SECURITY DEFINER
|
|
1577
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1578
|
+
AS $slotlock_workspace_invitations_list$
|
|
1579
|
+
BEGIN
|
|
1580
|
+
RETURN QUERY SELECT invitation.id, invitation.email, invitation.github_login, invitation.role,
|
|
1581
|
+
coalesce(inviter.display, invitation.inviter_ref), invitation.sent_at, invitation.expires_at
|
|
1582
|
+
FROM slotlock.workspace_invitations invitation
|
|
1583
|
+
LEFT JOIN slotlock.dashboard_people inviter ON inviter.person_ref = invitation.inviter_ref
|
|
1584
|
+
WHERE invitation.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
|
|
1585
|
+
AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
|
|
1586
|
+
AND invitation.expires_at > statement_timestamp() - interval '30 days'
|
|
1587
|
+
ORDER BY invitation.sent_at DESC
|
|
1588
|
+
LIMIT 500;
|
|
1589
|
+
END
|
|
1590
|
+
$slotlock_workspace_invitations_list$;
|
|
1591
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[14]} FROM PUBLIC;
|
|
1592
|
+
|
|
1593
|
+
-- Invite to a team workspace: by email or GitHub account, with a role the inviter may give, one
|
|
1594
|
+
-- pending invitation per address (a new one replaces it), at most requested_daily_limit sent a day,
|
|
1595
|
+
-- and with requested_member_cap, no more people than it counting pending invitations. The token
|
|
1596
|
+
-- is given as its SHA-256 digest only.
|
|
1597
|
+
CREATE OR REPLACE FUNCTION slotlock.invite_member(
|
|
1598
|
+
requested_tenant text, requested_inviter text, requested_email text, requested_github text,
|
|
1599
|
+
requested_github_login text, requested_role text, requested_token bytea, requested_days integer,
|
|
1600
|
+
requested_daily_limit integer, requested_member_cap integer)
|
|
1601
|
+
RETURNS TABLE (invitation_id uuid, refusal text)
|
|
1602
|
+
LANGUAGE plpgsql
|
|
1603
|
+
VOLATILE
|
|
1604
|
+
SECURITY DEFINER
|
|
1605
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1606
|
+
AS $slotlock_invite_member$
|
|
1607
|
+
DECLARE
|
|
1608
|
+
inviter_role text;
|
|
1609
|
+
email_normalized text := NULLIF(lower(btrim(requested_email)), '');
|
|
1610
|
+
occupied integer;
|
|
1611
|
+
created uuid;
|
|
1612
|
+
BEGIN
|
|
1613
|
+
IF requested_days IS NULL OR requested_days NOT BETWEEN 1 AND 30 THEN
|
|
1614
|
+
RAISE EXCEPTION 'Slotlock teams: invitations last 1-30 days' USING ERRCODE = '22023';
|
|
1615
|
+
END IF;
|
|
1616
|
+
-- The workspace's invitations one at a time: the cap and the daily limit see each other.
|
|
1617
|
+
PERFORM 1 FROM slotlock.workspaces ws
|
|
1618
|
+
WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
1619
|
+
IF NOT FOUND THEN
|
|
1620
|
+
RETURN QUERY SELECT NULL::uuid, 'not_found'::text;
|
|
1621
|
+
RETURN;
|
|
1622
|
+
END IF;
|
|
1623
|
+
SELECT member.role INTO inviter_role FROM slotlock.workspace_memberships member
|
|
1624
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_inviter AND member.status = 'active';
|
|
1625
|
+
IF inviter_role IS NULL OR NOT slotlock.workspace_may_grant(inviter_role, requested_role) THEN
|
|
1626
|
+
RETURN QUERY SELECT NULL::uuid, 'not_allowed'::text;
|
|
1627
|
+
RETURN;
|
|
1628
|
+
END IF;
|
|
1629
|
+
IF (email_normalized IS NULL) = (requested_github IS NULL) THEN
|
|
1630
|
+
RAISE EXCEPTION 'Slotlock teams: invite an email or a GitHub account' USING ERRCODE = '22023';
|
|
1631
|
+
END IF;
|
|
1632
|
+
-- Held to the team's verified domains (ddl-identity.ts): only addresses at one, and so no GitHub
|
|
1633
|
+
-- account, which carries none.
|
|
1634
|
+
IF ${SLOTLOCK_INVITE_DOMAIN_REFUSED('requested_tenant', 'email_normalized', 'requested_github')} THEN
|
|
1635
|
+
RETURN QUERY SELECT NULL::uuid, 'domain_restricted'::text;
|
|
1636
|
+
RETURN;
|
|
1637
|
+
END IF;
|
|
1638
|
+
IF requested_github IS NOT NULL AND EXISTS (
|
|
1639
|
+
SELECT 1 FROM slotlock.workspace_memberships member
|
|
1640
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_github AND member.status = 'active'
|
|
1641
|
+
) THEN
|
|
1642
|
+
RETURN QUERY SELECT NULL::uuid, 'already_member'::text;
|
|
1643
|
+
RETURN;
|
|
1644
|
+
END IF;
|
|
1645
|
+
-- The team's invitations a day, and the inviter's across every team they are in.
|
|
1646
|
+
IF requested_daily_limit IS NOT NULL AND (
|
|
1647
|
+
(SELECT count(*) FROM slotlock.workspace_invitations sent
|
|
1648
|
+
WHERE sent.tenant_ref = requested_tenant AND sent.sent_at > statement_timestamp() - interval '1 day')
|
|
1649
|
+
>= requested_daily_limit
|
|
1650
|
+
OR (SELECT count(*) FROM slotlock.workspace_invitations sent
|
|
1651
|
+
WHERE sent.inviter_ref = requested_inviter AND sent.sent_at > statement_timestamp() - interval '1 day')
|
|
1652
|
+
>= requested_daily_limit
|
|
1653
|
+
) THEN
|
|
1654
|
+
RETURN QUERY SELECT NULL::uuid, 'invitation_limit'::text;
|
|
1655
|
+
RETURN;
|
|
1656
|
+
END IF;
|
|
1657
|
+
-- One address's invitations a day, from every team together (SLOTLOCK_INVITATIONS_PER_ADDRESS).
|
|
1658
|
+
IF requested_daily_limit IS NOT NULL AND email_normalized IS NOT NULL AND
|
|
1659
|
+
(SELECT count(*) FROM slotlock.workspace_invitations sent
|
|
1660
|
+
WHERE sent.email = email_normalized AND sent.sent_at > statement_timestamp() - interval '1 day')
|
|
1661
|
+
>= ${SLOTLOCK_INVITATIONS_PER_ADDRESS} THEN
|
|
1662
|
+
RETURN QUERY SELECT NULL::uuid, 'recipient_limit'::text;
|
|
1663
|
+
RETURN;
|
|
1664
|
+
END IF;
|
|
1665
|
+
-- A pending invitation to the same address is replaced, so it is not counted against the cap.
|
|
1666
|
+
IF requested_member_cap IS NOT NULL THEN
|
|
1667
|
+
SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
|
|
1668
|
+
WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
|
|
1669
|
+
+ (SELECT count(*) FROM slotlock.workspace_invitations pending
|
|
1670
|
+
WHERE pending.tenant_ref = requested_tenant AND pending.accepted_at IS NULL
|
|
1671
|
+
AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp()
|
|
1672
|
+
AND NOT coalesce(pending.email = email_normalized OR pending.github_ref = requested_github, false))
|
|
1673
|
+
INTO occupied;
|
|
1674
|
+
IF occupied >= requested_member_cap THEN
|
|
1675
|
+
RETURN QUERY SELECT NULL::uuid, 'member_cap'::text;
|
|
1676
|
+
RETURN;
|
|
1677
|
+
END IF;
|
|
1678
|
+
END IF;
|
|
1679
|
+
UPDATE slotlock.workspace_invitations old
|
|
1680
|
+
SET revoked_at = statement_timestamp()
|
|
1681
|
+
WHERE old.tenant_ref = requested_tenant AND old.accepted_at IS NULL AND old.revoked_at IS NULL
|
|
1682
|
+
AND (old.email = email_normalized OR old.github_ref = requested_github);
|
|
1683
|
+
INSERT INTO slotlock.workspace_invitations
|
|
1684
|
+
(tenant_ref, token_sha256, email, github_ref, github_login, role, inviter_ref, expires_at)
|
|
1685
|
+
VALUES (requested_tenant, requested_token, email_normalized, requested_github,
|
|
1686
|
+
CASE WHEN requested_github IS NULL THEN NULL ELSE requested_github_login END,
|
|
1687
|
+
requested_role, requested_inviter,
|
|
1688
|
+
statement_timestamp() + make_interval(days => requested_days))
|
|
1689
|
+
RETURNING id INTO created;
|
|
1690
|
+
RETURN QUERY SELECT created, NULL::text;
|
|
1691
|
+
END
|
|
1692
|
+
$slotlock_invite_member$;
|
|
1693
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[15]} FROM PUBLIC;
|
|
1694
|
+
|
|
1695
|
+
-- What an invitation's holder may see before joining: the workspace, who invited them, the role,
|
|
1696
|
+
-- whom it is for, and its state. Nothing else of the workspace.
|
|
1697
|
+
CREATE OR REPLACE FUNCTION slotlock.invitation_preview(requested_token bytea)
|
|
1698
|
+
RETURNS TABLE (invitation_id uuid, tenant_ref text, workspace_name text, workspace_handle text,
|
|
1699
|
+
inviter_display text, role text, email text, github_ref text, github_login text, state text)
|
|
1700
|
+
LANGUAGE plpgsql
|
|
1701
|
+
STABLE
|
|
1702
|
+
SECURITY DEFINER
|
|
1703
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1704
|
+
AS $slotlock_invitation_preview$
|
|
1705
|
+
BEGIN
|
|
1706
|
+
RETURN QUERY SELECT invitation.id, invitation.tenant_ref, ws.name, ws.handle,
|
|
1707
|
+
coalesce(inviter.display, 'A member'), invitation.role, invitation.email, invitation.github_ref,
|
|
1708
|
+
invitation.github_login,
|
|
1709
|
+
CASE WHEN invitation.revoked_at IS NOT NULL THEN 'revoked'
|
|
1710
|
+
WHEN invitation.accepted_at IS NOT NULL THEN 'accepted'
|
|
1711
|
+
WHEN invitation.expires_at <= statement_timestamp() THEN 'expired'
|
|
1712
|
+
ELSE 'pending' END
|
|
1713
|
+
FROM slotlock.workspace_invitations invitation
|
|
1714
|
+
JOIN slotlock.workspaces ws ON ws.tenant_ref = invitation.tenant_ref
|
|
1715
|
+
LEFT JOIN slotlock.dashboard_people inviter ON inviter.person_ref = invitation.inviter_ref
|
|
1716
|
+
WHERE invitation.token_sha256 = requested_token;
|
|
1717
|
+
END
|
|
1718
|
+
$slotlock_invitation_preview$;
|
|
1719
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[16]} FROM PUBLIC;
|
|
1720
|
+
|
|
1721
|
+
-- Accept an invitation, by its token's digest or (one found for the person) its id: in one
|
|
1722
|
+
-- transaction, only while pending, only by the GitHub account invited or a person holding the
|
|
1723
|
+
-- invited email verified, only while the inviter may still give the role, and within
|
|
1724
|
+
-- requested_member_cap. A member already keeps their role.
|
|
1725
|
+
CREATE OR REPLACE FUNCTION slotlock.accept_invitation(
|
|
1726
|
+
requested_token bytea, requested_id uuid, requested_person text, requested_emails text[],
|
|
1727
|
+
requested_member_cap integer)
|
|
1728
|
+
RETURNS TABLE (tenant_ref text, refusal text)
|
|
1729
|
+
LANGUAGE plpgsql
|
|
1730
|
+
VOLATILE
|
|
1731
|
+
SECURITY DEFINER
|
|
1732
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1733
|
+
AS $slotlock_accept_invitation$
|
|
1734
|
+
DECLARE
|
|
1735
|
+
invitation slotlock.workspace_invitations%ROWTYPE;
|
|
1736
|
+
found_id uuid;
|
|
1737
|
+
found_tenant text;
|
|
1738
|
+
inviter_role text;
|
|
1739
|
+
members integer;
|
|
1740
|
+
BEGIN
|
|
1741
|
+
IF requested_person !~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
1742
|
+
RAISE EXCEPTION 'Slotlock teams: invalid person' USING ERRCODE = '22023';
|
|
1743
|
+
END IF;
|
|
1744
|
+
SELECT found.id, found.tenant_ref INTO found_id, found_tenant FROM slotlock.workspace_invitations found
|
|
1745
|
+
WHERE (requested_token IS NOT NULL AND found.token_sha256 = requested_token)
|
|
1746
|
+
OR (requested_token IS NULL AND found.id = requested_id);
|
|
1747
|
+
IF found_id IS NULL THEN
|
|
1748
|
+
RETURN QUERY SELECT NULL::text, 'not_found'::text;
|
|
1749
|
+
RETURN;
|
|
1750
|
+
END IF;
|
|
1751
|
+
-- The workspace first, then the invitation: the order every change to it takes, so none deadlock.
|
|
1752
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = found_tenant AND ws.status = 'active' FOR UPDATE;
|
|
1753
|
+
IF NOT FOUND THEN
|
|
1754
|
+
RETURN QUERY SELECT NULL::text, 'not_found'::text;
|
|
1755
|
+
RETURN;
|
|
1756
|
+
END IF;
|
|
1757
|
+
SELECT * INTO invitation FROM slotlock.workspace_invitations found WHERE found.id = found_id FOR UPDATE;
|
|
1758
|
+
-- Sent again while this waited: the old token no longer opens it.
|
|
1759
|
+
IF NOT FOUND OR (requested_token IS NOT NULL AND invitation.token_sha256 <> requested_token) THEN
|
|
1760
|
+
RETURN QUERY SELECT NULL::text, 'not_found'::text;
|
|
1761
|
+
RETURN;
|
|
1762
|
+
END IF;
|
|
1763
|
+
-- An Owner signs in with GitHub or Google too, for when the team's provider is down (ddl-sso.ts).
|
|
1764
|
+
IF invitation.role = 'owner' AND requested_person ~ '^sso:' THEN
|
|
1765
|
+
RETURN QUERY SELECT NULL::text, 'not_for_you'::text;
|
|
1766
|
+
RETURN;
|
|
1767
|
+
END IF;
|
|
1768
|
+
IF invitation.revoked_at IS NOT NULL THEN
|
|
1769
|
+
RETURN QUERY SELECT NULL::text, 'revoked'::text;
|
|
1770
|
+
RETURN;
|
|
1771
|
+
ELSIF invitation.accepted_at IS NOT NULL THEN
|
|
1772
|
+
RETURN QUERY SELECT NULL::text, 'accepted'::text;
|
|
1773
|
+
RETURN;
|
|
1774
|
+
ELSIF invitation.expires_at <= statement_timestamp() THEN
|
|
1775
|
+
RETURN QUERY SELECT NULL::text, 'expired'::text;
|
|
1776
|
+
RETURN;
|
|
1777
|
+
END IF;
|
|
1778
|
+
IF NOT (
|
|
1779
|
+
(invitation.github_ref IS NOT NULL AND invitation.github_ref = requested_person)
|
|
1780
|
+
OR (invitation.email IS NOT NULL AND invitation.email = ANY (
|
|
1781
|
+
SELECT lower(btrim(given)) FROM unnest(coalesce(requested_emails, ARRAY[]::text[])) given))
|
|
1782
|
+
) THEN
|
|
1783
|
+
RETURN QUERY SELECT NULL::text, 'not_for_you'::text;
|
|
1784
|
+
RETURN;
|
|
1785
|
+
END IF;
|
|
1786
|
+
SELECT member.role INTO inviter_role FROM slotlock.workspace_memberships member
|
|
1787
|
+
WHERE member.tenant_ref = invitation.tenant_ref AND member.person_ref = invitation.inviter_ref
|
|
1788
|
+
AND member.status = 'active';
|
|
1789
|
+
IF inviter_role IS NULL OR NOT slotlock.workspace_may_grant(inviter_role, invitation.role) THEN
|
|
1790
|
+
UPDATE slotlock.workspace_invitations SET revoked_at = statement_timestamp() WHERE id = invitation.id;
|
|
1791
|
+
RETURN QUERY SELECT NULL::text, 'revoked'::text;
|
|
1792
|
+
RETURN;
|
|
1793
|
+
END IF;
|
|
1794
|
+
IF EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
1795
|
+
WHERE member.tenant_ref = invitation.tenant_ref AND member.person_ref = requested_person
|
|
1796
|
+
AND member.status = 'active') THEN
|
|
1797
|
+
UPDATE slotlock.workspace_invitations
|
|
1798
|
+
SET accepted_at = statement_timestamp(), accepted_by = requested_person WHERE id = invitation.id;
|
|
1799
|
+
RETURN QUERY SELECT invitation.tenant_ref, NULL::text;
|
|
1800
|
+
RETURN;
|
|
1801
|
+
END IF;
|
|
1802
|
+
IF requested_member_cap IS NOT NULL THEN
|
|
1803
|
+
SELECT count(*) INTO members FROM slotlock.workspace_memberships member
|
|
1804
|
+
WHERE member.tenant_ref = invitation.tenant_ref AND member.status = 'active';
|
|
1805
|
+
IF members >= requested_member_cap THEN
|
|
1806
|
+
RETURN QUERY SELECT NULL::text, 'member_cap'::text;
|
|
1807
|
+
RETURN;
|
|
1808
|
+
END IF;
|
|
1809
|
+
END IF;
|
|
1810
|
+
INSERT INTO slotlock.workspace_memberships AS member (tenant_ref, person_ref, role, source, joined_email)
|
|
1811
|
+
VALUES (invitation.tenant_ref, requested_person, invitation.role, 'invitation', invitation.email)
|
|
1812
|
+
ON CONFLICT ON CONSTRAINT slotlock_workspace_memberships_pkey DO UPDATE
|
|
1813
|
+
SET role = EXCLUDED.role, status = 'active', source = 'invitation', deactivated_at = NULL, deactivated_by = NULL,
|
|
1814
|
+
joined_email = coalesce(EXCLUDED.joined_email, member.joined_email), joined_via_domain = NULL,
|
|
1815
|
+
role_managed_by = 'manual', updated_at = statement_timestamp();
|
|
1816
|
+
UPDATE slotlock.workspace_invitations
|
|
1817
|
+
SET accepted_at = statement_timestamp(), accepted_by = requested_person WHERE id = invitation.id;
|
|
1818
|
+
RETURN QUERY SELECT invitation.tenant_ref, NULL::text;
|
|
1819
|
+
END
|
|
1820
|
+
$slotlock_accept_invitation$;
|
|
1821
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[17]} FROM PUBLIC;
|
|
1822
|
+
|
|
1823
|
+
-- Revoke a pending invitation, by an Owner or Admin of its workspace.
|
|
1824
|
+
CREATE OR REPLACE FUNCTION slotlock.revoke_invitation(requested_tenant text, requested_actor text, requested_id uuid)
|
|
1825
|
+
RETURNS boolean
|
|
1826
|
+
LANGUAGE plpgsql
|
|
1827
|
+
VOLATILE
|
|
1828
|
+
SECURITY DEFINER
|
|
1829
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1830
|
+
AS $slotlock_revoke_invitation$
|
|
1831
|
+
DECLARE
|
|
1832
|
+
actor_role text;
|
|
1833
|
+
changed integer;
|
|
1834
|
+
BEGIN
|
|
1835
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
1836
|
+
IF NOT FOUND THEN
|
|
1837
|
+
RETURN false;
|
|
1838
|
+
END IF;
|
|
1839
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
1840
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
1841
|
+
-- Only an invitation whose role the actor could give: an Admin never withdraws an Owner's.
|
|
1842
|
+
UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
|
|
1843
|
+
WHERE invitation.id = requested_id AND invitation.tenant_ref = requested_tenant
|
|
1844
|
+
AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
|
|
1845
|
+
AND actor_role IS NOT NULL AND slotlock.workspace_may_grant(actor_role, invitation.role);
|
|
1846
|
+
GET DIAGNOSTICS changed = ROW_COUNT;
|
|
1847
|
+
RETURN changed > 0;
|
|
1848
|
+
END
|
|
1849
|
+
$slotlock_revoke_invitation$;
|
|
1850
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[18]} FROM PUBLIC;
|
|
1851
|
+
|
|
1852
|
+
-- Send an invitation again, with a new token and a new expiry: by someone who may give its role,
|
|
1853
|
+
-- within the daily limit and the address's. As inviting the address again does, it withdraws the
|
|
1854
|
+
-- old invitation and adds a new one, so each re-send counts as a send: re-sending one row in place
|
|
1855
|
+
-- made a new link, and an email, the daily limit never saw.
|
|
1856
|
+
CREATE OR REPLACE FUNCTION slotlock.resend_invitation(
|
|
1857
|
+
requested_tenant text, requested_actor text, requested_id uuid, requested_token bytea,
|
|
1858
|
+
requested_days integer, requested_daily_limit integer, requested_member_cap integer)
|
|
1859
|
+
RETURNS text
|
|
1860
|
+
LANGUAGE plpgsql
|
|
1861
|
+
VOLATILE
|
|
1862
|
+
SECURITY DEFINER
|
|
1863
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1864
|
+
AS $slotlock_resend_invitation$
|
|
1865
|
+
DECLARE
|
|
1866
|
+
actor_role text;
|
|
1867
|
+
invitation_role text;
|
|
1868
|
+
invitation_email text;
|
|
1869
|
+
invitation_github text;
|
|
1870
|
+
BEGIN
|
|
1871
|
+
IF requested_days IS NULL OR requested_days NOT BETWEEN 1 AND 30 THEN
|
|
1872
|
+
RAISE EXCEPTION 'Slotlock teams: invitations last 1-30 days' USING ERRCODE = '22023';
|
|
1873
|
+
END IF;
|
|
1874
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
1875
|
+
IF NOT FOUND THEN
|
|
1876
|
+
RETURN 'not_found';
|
|
1877
|
+
END IF;
|
|
1878
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
1879
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
1880
|
+
SELECT invitation.role, invitation.email, invitation.github_ref INTO invitation_role, invitation_email, invitation_github
|
|
1881
|
+
FROM slotlock.workspace_invitations invitation
|
|
1882
|
+
WHERE invitation.id = requested_id AND invitation.tenant_ref = requested_tenant
|
|
1883
|
+
AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
|
|
1884
|
+
FOR UPDATE;
|
|
1885
|
+
IF invitation_role IS NULL THEN
|
|
1886
|
+
RETURN 'not_found';
|
|
1887
|
+
END IF;
|
|
1888
|
+
IF actor_role IS NULL OR NOT slotlock.workspace_may_grant(actor_role, invitation_role) THEN
|
|
1889
|
+
RETURN 'not_allowed';
|
|
1890
|
+
END IF;
|
|
1891
|
+
-- Sent again only where it could be sent now (ddl-identity.ts).
|
|
1892
|
+
IF ${SLOTLOCK_INVITE_DOMAIN_REFUSED('requested_tenant', 'invitation_email', 'invitation_github')} THEN
|
|
1893
|
+
RETURN 'domain_restricted';
|
|
1894
|
+
END IF;
|
|
1895
|
+
IF requested_daily_limit IS NOT NULL AND (
|
|
1896
|
+
(SELECT count(*) FROM slotlock.workspace_invitations sent
|
|
1897
|
+
WHERE sent.tenant_ref = requested_tenant AND sent.sent_at > statement_timestamp() - interval '1 day')
|
|
1898
|
+
>= requested_daily_limit
|
|
1899
|
+
OR (SELECT count(*) FROM slotlock.workspace_invitations sent
|
|
1900
|
+
WHERE sent.inviter_ref = requested_actor AND sent.sent_at > statement_timestamp() - interval '1 day')
|
|
1901
|
+
>= requested_daily_limit
|
|
1902
|
+
) THEN
|
|
1903
|
+
RETURN 'invitation_limit';
|
|
1904
|
+
END IF;
|
|
1905
|
+
IF requested_daily_limit IS NOT NULL AND invitation_email IS NOT NULL AND
|
|
1906
|
+
(SELECT count(*) FROM slotlock.workspace_invitations sent
|
|
1907
|
+
WHERE sent.email = invitation_email AND sent.sent_at > statement_timestamp() - interval '1 day')
|
|
1908
|
+
>= ${SLOTLOCK_INVITATIONS_PER_ADDRESS} THEN
|
|
1909
|
+
RETURN 'recipient_limit';
|
|
1910
|
+
END IF;
|
|
1911
|
+
-- Sent again, it is open again: held to the cap with the members and the other open invitations.
|
|
1912
|
+
IF requested_member_cap IS NOT NULL AND
|
|
1913
|
+
(SELECT count(*) FROM slotlock.workspace_memberships member
|
|
1914
|
+
WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
|
|
1915
|
+
+ (SELECT count(*) FROM slotlock.workspace_invitations pending
|
|
1916
|
+
WHERE pending.tenant_ref = requested_tenant AND pending.id <> requested_id
|
|
1917
|
+
AND pending.accepted_at IS NULL AND pending.revoked_at IS NULL
|
|
1918
|
+
AND pending.expires_at > statement_timestamp())
|
|
1919
|
+
>= requested_member_cap THEN
|
|
1920
|
+
RETURN 'member_cap';
|
|
1921
|
+
END IF;
|
|
1922
|
+
UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
|
|
1923
|
+
WHERE invitation.id = requested_id;
|
|
1924
|
+
INSERT INTO slotlock.workspace_invitations
|
|
1925
|
+
(tenant_ref, token_sha256, email, github_ref, github_login, role, inviter_ref, expires_at)
|
|
1926
|
+
SELECT old.tenant_ref, requested_token, old.email, old.github_ref, old.github_login, old.role,
|
|
1927
|
+
requested_actor, statement_timestamp() + make_interval(days => requested_days)
|
|
1928
|
+
FROM slotlock.workspace_invitations old
|
|
1929
|
+
WHERE old.id = requested_id;
|
|
1930
|
+
RETURN NULL;
|
|
1931
|
+
END
|
|
1932
|
+
$slotlock_resend_invitation$;
|
|
1933
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[19]} FROM PUBLIC;
|
|
1934
|
+
|
|
1935
|
+
-- Change a member's role, by someone who may give both their current role and the new one; a
|
|
1936
|
+
-- workspace always keeps an Owner. A role given in Slotlock is never changed by a GitHub sign-in.
|
|
1937
|
+
CREATE OR REPLACE FUNCTION slotlock.change_member_role(
|
|
1938
|
+
requested_tenant text, requested_actor text, requested_person text, requested_role text)
|
|
1939
|
+
RETURNS text
|
|
1940
|
+
LANGUAGE plpgsql
|
|
1941
|
+
VOLATILE
|
|
1942
|
+
SECURITY DEFINER
|
|
1943
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1944
|
+
AS $slotlock_change_member_role$
|
|
1945
|
+
DECLARE
|
|
1946
|
+
actor_role text;
|
|
1947
|
+
current_role_of text;
|
|
1948
|
+
BEGIN
|
|
1949
|
+
IF requested_role NOT IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing') THEN
|
|
1950
|
+
RAISE EXCEPTION 'Slotlock teams: invalid role' USING ERRCODE = '22023';
|
|
1951
|
+
END IF;
|
|
1952
|
+
-- The workspace's owners one change at a time, so two Owners demoting each other leave one.
|
|
1953
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
1954
|
+
IF NOT FOUND THEN
|
|
1955
|
+
RETURN 'not_found';
|
|
1956
|
+
END IF;
|
|
1957
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
1958
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
1959
|
+
SELECT member.role INTO current_role_of FROM slotlock.workspace_memberships member
|
|
1960
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active';
|
|
1961
|
+
IF current_role_of IS NULL THEN
|
|
1962
|
+
RETURN 'not_found';
|
|
1963
|
+
END IF;
|
|
1964
|
+
IF actor_role IS NULL OR NOT slotlock.workspace_may_grant(actor_role, current_role_of)
|
|
1965
|
+
OR NOT slotlock.workspace_may_grant(actor_role, requested_role) THEN
|
|
1966
|
+
RETURN 'not_allowed';
|
|
1967
|
+
END IF;
|
|
1968
|
+
-- Every Owner can sign in with GitHub or Google, for when the team's provider is down: so the
|
|
1969
|
+
-- Owner a team always keeps is one who may bypass single sign-on (ddl-sso.ts).
|
|
1970
|
+
IF requested_role = 'owner' AND requested_person ~ '^sso:' THEN
|
|
1971
|
+
RETURN 'sso_owner';
|
|
1972
|
+
END IF;
|
|
1973
|
+
IF current_role_of = 'owner' AND requested_role <> 'owner' AND NOT EXISTS (
|
|
1974
|
+
SELECT 1 FROM slotlock.workspace_memberships other
|
|
1975
|
+
WHERE other.tenant_ref = requested_tenant AND other.status = 'active' AND other.role = 'owner'
|
|
1976
|
+
AND other.person_ref <> requested_person
|
|
1977
|
+
) THEN
|
|
1978
|
+
RETURN 'last_owner';
|
|
1979
|
+
END IF;
|
|
1980
|
+
-- A role set by hand stays: the team's provider no longer recomputes it at sign-in.
|
|
1981
|
+
UPDATE slotlock.workspace_memberships member
|
|
1982
|
+
SET role = requested_role, source = CASE WHEN member.source = 'github_org' THEN 'created' ELSE member.source END,
|
|
1983
|
+
role_managed_by = 'manual', updated_at = statement_timestamp()
|
|
1984
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person;
|
|
1985
|
+
RETURN NULL;
|
|
1986
|
+
END
|
|
1987
|
+
$slotlock_change_member_role$;
|
|
1988
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[20]} FROM PUBLIC;
|
|
1989
|
+
|
|
1990
|
+
-- A member limited to part of the workspace (RFC 0004 D1): the locations and groups whose resources
|
|
1991
|
+
-- they reach, as an API key's resource scope (RFC 0003 G6). Null is the whole workspace; an Owner is
|
|
1992
|
+
-- never limited.
|
|
1993
|
+
ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS resource_scope jsonb;
|
|
1994
|
+
DO $slotlock_membership_scope$
|
|
1995
|
+
BEGIN
|
|
1996
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_workspace_memberships_scope_check') THEN
|
|
1997
|
+
-- As a key's scope: 1-20 locations and 1-20 groups by reference, at least one in all. A CASE,
|
|
1998
|
+
-- not AND, so no array function sees what is not an array, and false (never null) otherwise.
|
|
1999
|
+
ALTER TABLE slotlock.workspace_memberships ADD CONSTRAINT slotlock_workspace_memberships_scope_check CHECK (
|
|
2000
|
+
resource_scope IS NULL OR CASE
|
|
2001
|
+
WHEN jsonb_typeof(resource_scope) = 'object'
|
|
2002
|
+
AND jsonb_typeof(resource_scope -> 'locations') IS NOT DISTINCT FROM 'array'
|
|
2003
|
+
AND jsonb_typeof(resource_scope -> 'groups') IS NOT DISTINCT FROM 'array'
|
|
2004
|
+
THEN jsonb_array_length(resource_scope -> 'locations') <= 20
|
|
2005
|
+
AND jsonb_array_length(resource_scope -> 'groups') <= 20
|
|
2006
|
+
AND jsonb_array_length(resource_scope -> 'locations') + jsonb_array_length(resource_scope -> 'groups') >= 1
|
|
2007
|
+
AND NOT jsonb_path_exists(resource_scope,
|
|
2008
|
+
'$.locations[*] ? (@.type() != "string" || !(@ like_regex "^[a-z0-9][a-z0-9._-]{0,99}$"))')
|
|
2009
|
+
AND NOT jsonb_path_exists(resource_scope,
|
|
2010
|
+
'$.groups[*] ? (@.type() != "string" || !(@ like_regex "^[a-z0-9][a-z0-9._-]{0,99}$"))')
|
|
2011
|
+
ELSE false
|
|
2012
|
+
END
|
|
2013
|
+
);
|
|
2014
|
+
END IF;
|
|
2015
|
+
END
|
|
2016
|
+
$slotlock_membership_scope$;
|
|
2017
|
+
|
|
2018
|
+
-- An Owner reaches the whole workspace: made one by any path (a role change, an invitation, a
|
|
2019
|
+
-- GitHub organization's sign-in), their limit is cleared, so it does not return if they stop being
|
|
2020
|
+
-- one.
|
|
2021
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_owner_unlimited()
|
|
2022
|
+
RETURNS trigger
|
|
2023
|
+
LANGUAGE plpgsql
|
|
2024
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2025
|
+
AS $slotlock_workspace_owner_unlimited$
|
|
2026
|
+
BEGIN
|
|
2027
|
+
IF NEW.role = 'owner' THEN
|
|
2028
|
+
NEW.resource_scope := NULL;
|
|
2029
|
+
END IF;
|
|
2030
|
+
RETURN NEW;
|
|
2031
|
+
END
|
|
2032
|
+
$slotlock_workspace_owner_unlimited$;
|
|
2033
|
+
CREATE OR REPLACE TRIGGER slotlock_workspace_owner_unlimited
|
|
2034
|
+
BEFORE INSERT OR UPDATE OF role, resource_scope ON slotlock.workspace_memberships
|
|
2035
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.workspace_owner_unlimited();
|
|
2036
|
+
-- Owners limited before this trigger: none should be, but a database that has one is cleared.
|
|
2037
|
+
UPDATE slotlock.workspace_memberships SET resource_scope = NULL
|
|
2038
|
+
WHERE role = 'owner' AND resource_scope IS NOT NULL;
|
|
2039
|
+
|
|
2040
|
+
-- The part of a workspace (its test environment is its live tenant's) a person is limited to, or
|
|
2041
|
+
-- null for all of it. An Owner reaches all of it.
|
|
2042
|
+
CREATE OR REPLACE FUNCTION slotlock.member_scope(requested_tenant text, requested_person text)
|
|
2043
|
+
RETURNS jsonb
|
|
2044
|
+
LANGUAGE sql
|
|
2045
|
+
STABLE
|
|
2046
|
+
SECURITY DEFINER
|
|
2047
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2048
|
+
AS $slotlock_member_scope$
|
|
2049
|
+
SELECT CASE WHEN member.role = 'owner' THEN NULL ELSE member.resource_scope END
|
|
2050
|
+
FROM slotlock.workspace_memberships member
|
|
2051
|
+
WHERE member.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
|
|
2052
|
+
AND member.person_ref = requested_person AND member.status = 'active';
|
|
2053
|
+
$slotlock_member_scope$;
|
|
2054
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[34]} FROM PUBLIC;
|
|
2055
|
+
|
|
2056
|
+
-- Every member of a workspace limited to part of it, with what they are limited to (Members).
|
|
2057
|
+
CREATE OR REPLACE FUNCTION slotlock.member_scopes(requested_tenant text)
|
|
2058
|
+
RETURNS TABLE (person_ref text, resource_scope jsonb)
|
|
2059
|
+
LANGUAGE sql
|
|
2060
|
+
STABLE
|
|
2061
|
+
SECURITY DEFINER
|
|
2062
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2063
|
+
AS $slotlock_member_scopes$
|
|
2064
|
+
SELECT member.person_ref, member.resource_scope
|
|
2065
|
+
FROM slotlock.workspace_memberships member
|
|
2066
|
+
WHERE member.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
|
|
2067
|
+
AND member.status = 'active' AND member.role <> 'owner' AND member.resource_scope IS NOT NULL
|
|
2068
|
+
LIMIT 1000;
|
|
2069
|
+
$slotlock_member_scopes$;
|
|
2070
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[36]} FROM PUBLIC;
|
|
2071
|
+
|
|
2072
|
+
-- Limit a member to locations and groups, or give them the whole workspace again (null): by an
|
|
2073
|
+
-- Owner or Admin who is not limited themselves, of a member who is not an Owner and whose role they
|
|
2074
|
+
-- may grant. Null on success; else not_found, not_allowed or owner.
|
|
2075
|
+
CREATE OR REPLACE FUNCTION slotlock.set_member_scope(
|
|
2076
|
+
requested_tenant text, requested_actor text, requested_person text, requested_scope jsonb)
|
|
2077
|
+
RETURNS text
|
|
2078
|
+
LANGUAGE plpgsql
|
|
2079
|
+
VOLATILE
|
|
2080
|
+
SECURITY DEFINER
|
|
2081
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2082
|
+
AS $slotlock_set_member_scope$
|
|
2083
|
+
DECLARE
|
|
2084
|
+
actor_role text;
|
|
2085
|
+
actor_scope jsonb;
|
|
2086
|
+
member_role text;
|
|
2087
|
+
BEGIN
|
|
2088
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
2089
|
+
IF NOT FOUND THEN
|
|
2090
|
+
RETURN 'not_found';
|
|
2091
|
+
END IF;
|
|
2092
|
+
SELECT member.role, member.resource_scope INTO actor_role, actor_scope FROM slotlock.workspace_memberships member
|
|
2093
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
2094
|
+
SELECT member.role INTO member_role FROM slotlock.workspace_memberships member
|
|
2095
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active';
|
|
2096
|
+
IF member_role IS NULL THEN
|
|
2097
|
+
RETURN 'not_found';
|
|
2098
|
+
END IF;
|
|
2099
|
+
IF member_role = 'owner' THEN
|
|
2100
|
+
RETURN 'owner';
|
|
2101
|
+
END IF;
|
|
2102
|
+
IF actor_role IS NULL OR actor_role NOT IN ('owner', 'admin') OR actor_scope IS NOT NULL
|
|
2103
|
+
OR NOT slotlock.workspace_may_grant(actor_role, member_role) THEN
|
|
2104
|
+
RETURN 'not_allowed';
|
|
2105
|
+
END IF;
|
|
2106
|
+
UPDATE slotlock.workspace_memberships member
|
|
2107
|
+
SET resource_scope = requested_scope, updated_at = statement_timestamp()
|
|
2108
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person;
|
|
2109
|
+
RETURN NULL;
|
|
2110
|
+
END
|
|
2111
|
+
$slotlock_set_member_scope$;
|
|
2112
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[35]} FROM PUBLIC;
|
|
2113
|
+
|
|
2114
|
+
-- Remove a member (or, the actor themselves, leave): by someone who may give their role; a
|
|
2115
|
+
-- workspace always keeps an Owner. Their pending invitations go with them. The membership is kept
|
|
2116
|
+
-- inactive, so inviting them back restores what an invitation gives.
|
|
2117
|
+
CREATE OR REPLACE FUNCTION slotlock.remove_member(requested_tenant text, requested_actor text, requested_person text)
|
|
2118
|
+
RETURNS text
|
|
2119
|
+
LANGUAGE plpgsql
|
|
2120
|
+
VOLATILE
|
|
2121
|
+
SECURITY DEFINER
|
|
2122
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2123
|
+
AS $slotlock_remove_member$
|
|
2124
|
+
DECLARE
|
|
2125
|
+
actor_role text;
|
|
2126
|
+
removed_role text;
|
|
2127
|
+
BEGIN
|
|
2128
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
2129
|
+
IF NOT FOUND THEN
|
|
2130
|
+
RETURN 'not_found';
|
|
2131
|
+
END IF;
|
|
2132
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
2133
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
2134
|
+
SELECT member.role INTO removed_role FROM slotlock.workspace_memberships member
|
|
2135
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active';
|
|
2136
|
+
IF removed_role IS NULL OR actor_role IS NULL THEN
|
|
2137
|
+
RETURN 'not_found';
|
|
2138
|
+
END IF;
|
|
2139
|
+
IF requested_actor <> requested_person AND NOT slotlock.workspace_may_grant(actor_role, removed_role) THEN
|
|
2140
|
+
RETURN 'not_allowed';
|
|
2141
|
+
END IF;
|
|
2142
|
+
IF removed_role = 'owner' AND NOT EXISTS (
|
|
2143
|
+
SELECT 1 FROM slotlock.workspace_memberships other
|
|
2144
|
+
WHERE other.tenant_ref = requested_tenant AND other.status = 'active' AND other.role = 'owner'
|
|
2145
|
+
AND other.person_ref <> requested_person
|
|
2146
|
+
) THEN
|
|
2147
|
+
RETURN 'last_owner';
|
|
2148
|
+
END IF;
|
|
2149
|
+
-- Removed by someone else, the address they came by joins at once no more (ddl-identity.ts),
|
|
2150
|
+
-- nor do those their sign-ins through the team's provider carried (ddl-sso.ts).
|
|
2151
|
+
IF requested_actor <> requested_person THEN
|
|
2152
|
+
INSERT INTO slotlock.workspace_join_blocks (tenant_ref, email_sha256)
|
|
2153
|
+
SELECT requested_tenant, sha256(convert_to(address.email, 'UTF8'))
|
|
2154
|
+
FROM (SELECT member.joined_email AS email FROM slotlock.workspace_memberships member
|
|
2155
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person
|
|
2156
|
+
UNION
|
|
2157
|
+
SELECT identity.email_last FROM slotlock.sso_identities identity
|
|
2158
|
+
JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id AND linked.tenant_ref = requested_tenant
|
|
2159
|
+
WHERE identity.person_ref = requested_person) address
|
|
2160
|
+
WHERE address.email IS NOT NULL
|
|
2161
|
+
ON CONFLICT ON CONSTRAINT slotlock_workspace_join_blocks_pkey DO NOTHING;
|
|
2162
|
+
END IF;
|
|
2163
|
+
UPDATE slotlock.workspace_memberships member
|
|
2164
|
+
SET status = 'inactive', source = 'created', deactivated_at = statement_timestamp(),
|
|
2165
|
+
deactivated_by = CASE WHEN requested_actor = requested_person THEN 'self' ELSE 'team' END,
|
|
2166
|
+
updated_at = statement_timestamp()
|
|
2167
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person;
|
|
2168
|
+
-- Their pending invitations go with them: those they sent, and those sent to them (their account,
|
|
2169
|
+
-- or an address they joined with or signed in through the team's provider with), which would
|
|
2170
|
+
-- otherwise let them back in.
|
|
2171
|
+
UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
|
|
2172
|
+
WHERE invitation.tenant_ref = requested_tenant AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
|
|
2173
|
+
AND (invitation.inviter_ref = requested_person
|
|
2174
|
+
OR (requested_actor <> requested_person AND (
|
|
2175
|
+
invitation.github_ref = requested_person
|
|
2176
|
+
OR invitation.email IN (
|
|
2177
|
+
SELECT member.joined_email FROM slotlock.workspace_memberships member
|
|
2178
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person
|
|
2179
|
+
UNION
|
|
2180
|
+
SELECT identity.email_last FROM slotlock.sso_identities identity
|
|
2181
|
+
JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id AND linked.tenant_ref = requested_tenant
|
|
2182
|
+
WHERE identity.person_ref = requested_person))));
|
|
2183
|
+
RETURN NULL;
|
|
2184
|
+
END
|
|
2185
|
+
$slotlock_remove_member$;
|
|
2186
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[21]} FROM PUBLIC;
|
|
2187
|
+
|
|
2188
|
+
-- The pending invitations meant for a person: to their GitHub account, or to an email they hold
|
|
2189
|
+
-- verified. For the dashboard to offer "Join" without the link.
|
|
2190
|
+
CREATE OR REPLACE FUNCTION slotlock.pending_invitations_for(requested_person text, requested_emails text[])
|
|
2191
|
+
RETURNS TABLE (invitation_id uuid, tenant_ref text, workspace_name text, workspace_handle text,
|
|
2192
|
+
inviter_display text, role text)
|
|
2193
|
+
LANGUAGE plpgsql
|
|
2194
|
+
STABLE
|
|
2195
|
+
SECURITY DEFINER
|
|
2196
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2197
|
+
AS $slotlock_pending_invitations_for$
|
|
2198
|
+
BEGIN
|
|
2199
|
+
RETURN QUERY SELECT invitation.id, invitation.tenant_ref, ws.name, ws.handle,
|
|
2200
|
+
coalesce(inviter.display, 'A member'), invitation.role
|
|
2201
|
+
FROM slotlock.workspace_invitations invitation
|
|
2202
|
+
JOIN slotlock.workspaces ws ON ws.tenant_ref = invitation.tenant_ref AND ws.status = 'active'
|
|
2203
|
+
LEFT JOIN slotlock.dashboard_people inviter ON inviter.person_ref = invitation.inviter_ref
|
|
2204
|
+
WHERE invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
|
|
2205
|
+
AND invitation.expires_at > statement_timestamp()
|
|
2206
|
+
AND (invitation.github_ref = requested_person
|
|
2207
|
+
OR invitation.email = ANY (SELECT lower(btrim(given)) FROM unnest(coalesce(requested_emails, ARRAY[]::text[])) given))
|
|
2208
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
2209
|
+
WHERE member.tenant_ref = invitation.tenant_ref AND member.person_ref = requested_person
|
|
2210
|
+
AND member.status = 'active')
|
|
2211
|
+
ORDER BY invitation.sent_at DESC
|
|
2212
|
+
LIMIT 20;
|
|
2213
|
+
END
|
|
2214
|
+
$slotlock_pending_invitations_for$;
|
|
2215
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[22]} FROM PUBLIC;
|
|
2216
|
+
|
|
2217
|
+
-- Whether a team workspace serves its keys: only while it exists and is active.
|
|
2218
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_active(requested_tenant text)
|
|
2219
|
+
RETURNS boolean
|
|
2220
|
+
LANGUAGE plpgsql
|
|
2221
|
+
STABLE
|
|
2222
|
+
SECURITY DEFINER
|
|
2223
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2224
|
+
AS $slotlock_workspace_active$
|
|
2225
|
+
BEGIN
|
|
2226
|
+
RETURN EXISTS (SELECT 1 FROM slotlock.workspaces ws
|
|
2227
|
+
WHERE ws.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
|
|
2228
|
+
AND ws.status = 'active');
|
|
2229
|
+
END
|
|
2230
|
+
$slotlock_workspace_active$;
|
|
2231
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[23]} FROM PUBLIC;
|
|
2232
|
+
|
|
2233
|
+
-- Invitations finished (accepted, revoked or expired) more than 30 days ago, with the address they
|
|
2234
|
+
-- were sent to: deleted.
|
|
2235
|
+
CREATE OR REPLACE FUNCTION slotlock.prune_workspace_invitations()
|
|
2236
|
+
RETURNS integer
|
|
2237
|
+
LANGUAGE plpgsql
|
|
2238
|
+
VOLATILE
|
|
2239
|
+
SECURITY DEFINER
|
|
2240
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2241
|
+
AS $slotlock_prune_workspace_invitations$
|
|
2242
|
+
DECLARE
|
|
2243
|
+
pruned integer;
|
|
2244
|
+
BEGIN
|
|
2245
|
+
-- Ended when accepted, revoked or, untouched, expired.
|
|
2246
|
+
DELETE FROM slotlock.workspace_invitations finished
|
|
2247
|
+
WHERE coalesce(finished.accepted_at, finished.revoked_at, finished.expires_at)
|
|
2248
|
+
< statement_timestamp() - interval '30 days';
|
|
2249
|
+
GET DIAGNOSTICS pruned = ROW_COUNT;
|
|
2250
|
+
RETURN pruned;
|
|
2251
|
+
END
|
|
2252
|
+
$slotlock_prune_workspace_invitations$;
|
|
2253
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[24]} FROM PUBLIC;
|
|
2254
|
+
|
|
2255
|
+
-- Each dashboard session a person signed in with, for the Account page: when it began, when it was
|
|
2256
|
+
-- last seen (to five minutes), the browser and address it began from, and whether the person
|
|
2257
|
+
-- signed it out from another. Only a SHA-256 digest of the session's id is kept. Owner-only;
|
|
2258
|
+
-- reached through the functions below.
|
|
2259
|
+
CREATE TABLE IF NOT EXISTS slotlock.dashboard_sessions (
|
|
2260
|
+
sid_sha256 bytea PRIMARY KEY,
|
|
2261
|
+
person_ref text NOT NULL,
|
|
2262
|
+
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
2263
|
+
last_seen_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
2264
|
+
expires_at timestamptz NOT NULL,
|
|
2265
|
+
ended_at timestamptz,
|
|
2266
|
+
user_agent text,
|
|
2267
|
+
ip_address text,
|
|
2268
|
+
CONSTRAINT slotlock_dashboard_sessions_valid CHECK (
|
|
2269
|
+
octet_length(sid_sha256) = 32
|
|
2270
|
+
AND person_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
2271
|
+
AND (user_agent IS NULL OR char_length(user_agent) BETWEEN 1 AND 256)
|
|
2272
|
+
AND (ip_address IS NULL OR char_length(ip_address) BETWEEN 1 AND 64)
|
|
2273
|
+
AND expires_at > started_at
|
|
2274
|
+
)
|
|
2275
|
+
);
|
|
2276
|
+
CREATE INDEX IF NOT EXISTS slotlock_dashboard_sessions_person_idx
|
|
2277
|
+
ON slotlock.dashboard_sessions (person_ref, last_seen_at DESC);
|
|
2278
|
+
ALTER TABLE slotlock.dashboard_sessions ENABLE ROW LEVEL SECURITY;
|
|
2279
|
+
REVOKE ALL ON slotlock.dashboard_sessions FROM PUBLIC;
|
|
2280
|
+
|
|
2281
|
+
-- A session begun: recorded, and the person's expired ones dropped; at most 50 kept, newest first.
|
|
2282
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_session_started(
|
|
2283
|
+
requested_person text, requested_sid bytea, requested_expires timestamptz, requested_agent text,
|
|
2284
|
+
requested_address text)
|
|
2285
|
+
RETURNS void
|
|
2286
|
+
LANGUAGE plpgsql
|
|
2287
|
+
VOLATILE
|
|
2288
|
+
SECURITY DEFINER
|
|
2289
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2290
|
+
AS $slotlock_dashboard_session_started$
|
|
2291
|
+
BEGIN
|
|
2292
|
+
IF requested_person ~ '^sso:' THEN
|
|
2293
|
+
PERFORM 1 FROM slotlock.sso_identities identity WHERE identity.person_ref = requested_person FOR SHARE;
|
|
2294
|
+
IF NOT FOUND THEN
|
|
2295
|
+
RAISE EXCEPTION 'Slotlock dashboard: that person is no longer here' USING ERRCODE = 'P0002';
|
|
2296
|
+
END IF;
|
|
2297
|
+
END IF;
|
|
2298
|
+
INSERT INTO slotlock.dashboard_sessions (sid_sha256, person_ref, expires_at, user_agent, ip_address)
|
|
2299
|
+
VALUES (requested_sid, requested_person, requested_expires,
|
|
2300
|
+
NULLIF(left(requested_agent, 256), ''), NULLIF(left(requested_address, 64), ''))
|
|
2301
|
+
ON CONFLICT ON CONSTRAINT dashboard_sessions_pkey DO NOTHING;
|
|
2302
|
+
DELETE FROM slotlock.dashboard_sessions old
|
|
2303
|
+
WHERE old.person_ref = requested_person
|
|
2304
|
+
AND (old.expires_at <= statement_timestamp()
|
|
2305
|
+
-- The 50 newest are kept, sessions through a team's provider alone going first: a
|
|
2306
|
+
-- provider signing in as a linked member cannot push out the sessions they began.
|
|
2307
|
+
OR old.sid_sha256 IN (SELECT kept.sid_sha256 FROM slotlock.dashboard_sessions kept
|
|
2308
|
+
WHERE kept.person_ref = requested_person
|
|
2309
|
+
ORDER BY (kept.sso_connection_id IS NULL) DESC, kept.started_at DESC OFFSET 50));
|
|
2310
|
+
END
|
|
2311
|
+
$slotlock_dashboard_session_started$;
|
|
2312
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[25]} FROM PUBLIC;
|
|
2313
|
+
|
|
2314
|
+
-- Whether the person's session may go on: recorded for them, not signed out from another device,
|
|
2315
|
+
-- not expired. A session with no record (one the 50 kept dropped, one from before these records)
|
|
2316
|
+
-- does not, so a session signed out stays out however many sign-ins follow. Noted seen at most
|
|
2317
|
+
-- every five minutes.
|
|
2318
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_session_seen(requested_person text, requested_sid bytea)
|
|
2319
|
+
RETURNS boolean
|
|
2320
|
+
LANGUAGE plpgsql
|
|
2321
|
+
VOLATILE
|
|
2322
|
+
SECURITY DEFINER
|
|
2323
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2324
|
+
AS $slotlock_dashboard_session_seen$
|
|
2325
|
+
BEGIN
|
|
2326
|
+
UPDATE slotlock.dashboard_sessions seen SET last_seen_at = statement_timestamp()
|
|
2327
|
+
WHERE seen.sid_sha256 = requested_sid AND seen.person_ref = requested_person AND seen.ended_at IS NULL
|
|
2328
|
+
AND seen.last_seen_at < statement_timestamp() - interval '5 minutes';
|
|
2329
|
+
RETURN EXISTS (
|
|
2330
|
+
SELECT 1 FROM slotlock.dashboard_sessions alive
|
|
2331
|
+
WHERE alive.sid_sha256 = requested_sid AND alive.person_ref = requested_person
|
|
2332
|
+
AND alive.ended_at IS NULL AND alive.expires_at > statement_timestamp());
|
|
2333
|
+
END
|
|
2334
|
+
$slotlock_dashboard_session_seen$;
|
|
2335
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[26]} FROM PUBLIC;
|
|
2336
|
+
|
|
2337
|
+
-- The person's sessions still going, most recently seen first.
|
|
2338
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_sessions_of(requested_person text)
|
|
2339
|
+
RETURNS TABLE (sid_sha256 bytea, started_at timestamptz, last_seen_at timestamptz, expires_at timestamptz,
|
|
2340
|
+
user_agent text, ip_address text)
|
|
2341
|
+
LANGUAGE plpgsql
|
|
2342
|
+
STABLE
|
|
2343
|
+
SECURITY DEFINER
|
|
2344
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2345
|
+
AS $slotlock_dashboard_sessions_of$
|
|
2346
|
+
BEGIN
|
|
2347
|
+
RETURN QUERY SELECT kept.sid_sha256, kept.started_at, kept.last_seen_at, kept.expires_at, kept.user_agent,
|
|
2348
|
+
kept.ip_address
|
|
2349
|
+
FROM slotlock.dashboard_sessions kept
|
|
2350
|
+
WHERE kept.person_ref = requested_person AND kept.ended_at IS NULL
|
|
2351
|
+
AND kept.expires_at > statement_timestamp()
|
|
2352
|
+
ORDER BY kept.last_seen_at DESC
|
|
2353
|
+
LIMIT 50;
|
|
2354
|
+
END
|
|
2355
|
+
$slotlock_dashboard_sessions_of$;
|
|
2356
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[27]} FROM PUBLIC;
|
|
2357
|
+
|
|
2358
|
+
-- Sign one of the person's sessions out (or, with no session, all of them): whether any was.
|
|
2359
|
+
CREATE OR REPLACE FUNCTION slotlock.dashboard_session_end(requested_person text, requested_sid bytea)
|
|
2360
|
+
RETURNS boolean
|
|
2361
|
+
LANGUAGE plpgsql
|
|
2362
|
+
VOLATILE
|
|
2363
|
+
SECURITY DEFINER
|
|
2364
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2365
|
+
AS $slotlock_dashboard_session_end$
|
|
2366
|
+
DECLARE
|
|
2367
|
+
changed integer;
|
|
2368
|
+
BEGIN
|
|
2369
|
+
UPDATE slotlock.dashboard_sessions ending SET ended_at = statement_timestamp()
|
|
2370
|
+
WHERE ending.person_ref = requested_person AND ending.ended_at IS NULL
|
|
2371
|
+
AND (requested_sid IS NULL OR ending.sid_sha256 = requested_sid);
|
|
2372
|
+
GET DIAGNOSTICS changed = ROW_COUNT;
|
|
2373
|
+
RETURN changed > 0;
|
|
2374
|
+
END
|
|
2375
|
+
$slotlock_dashboard_session_end$;
|
|
2376
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[28]} FROM PUBLIC;
|
|
2377
|
+
|
|
2378
|
+
-- Sessions that ended or expired more than a day ago: deleted.
|
|
2379
|
+
CREATE OR REPLACE FUNCTION slotlock.prune_dashboard_sessions()
|
|
2380
|
+
RETURNS integer
|
|
2381
|
+
LANGUAGE plpgsql
|
|
2382
|
+
VOLATILE
|
|
1053
2383
|
SECURITY DEFINER
|
|
1054
2384
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1055
|
-
AS $
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
2385
|
+
AS $slotlock_prune_dashboard_sessions$
|
|
2386
|
+
DECLARE
|
|
2387
|
+
pruned integer;
|
|
2388
|
+
BEGIN
|
|
2389
|
+
DELETE FROM slotlock.dashboard_sessions old
|
|
2390
|
+
WHERE least(coalesce(old.ended_at, 'infinity'), old.expires_at) < statement_timestamp() - interval '1 day';
|
|
2391
|
+
GET DIAGNOSTICS pruned = ROW_COUNT;
|
|
2392
|
+
RETURN pruned;
|
|
2393
|
+
END
|
|
2394
|
+
$slotlock_prune_dashboard_sessions$;
|
|
2395
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[29]} FROM PUBLIC;
|
|
1061
2396
|
|
|
1062
|
-
--
|
|
1063
|
-
--
|
|
1064
|
-
|
|
1065
|
-
|
|
2397
|
+
-- A team workspace being deleted: its keys stop at once, its people stay until it goes seven days
|
|
2398
|
+
-- later, and any Owner can cancel meanwhile. Its status and when it goes, held together.
|
|
2399
|
+
ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS delete_after timestamptz;
|
|
2400
|
+
DO $slotlock_workspaces_deleting$
|
|
2401
|
+
BEGIN
|
|
2402
|
+
IF NOT EXISTS (
|
|
2403
|
+
SELECT 1 FROM pg_constraint
|
|
2404
|
+
WHERE conname = 'slotlock_workspaces_status_valid' AND conrelid = 'slotlock.workspaces'::regclass
|
|
2405
|
+
) THEN
|
|
2406
|
+
ALTER TABLE slotlock.workspaces DROP CONSTRAINT IF EXISTS slotlock_workspaces_valid;
|
|
2407
|
+
ALTER TABLE slotlock.workspaces ADD CONSTRAINT slotlock_workspaces_valid CHECK (
|
|
2408
|
+
tenant_ref ~ '^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
2409
|
+
AND handle ~ '^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$'
|
|
2410
|
+
AND char_length(name) BETWEEN 1 AND 80
|
|
2411
|
+
AND name !~ '[[:cntrl:]]'
|
|
2412
|
+
AND (created_by IS NULL OR created_by ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$')
|
|
2413
|
+
);
|
|
2414
|
+
ALTER TABLE slotlock.workspaces ADD CONSTRAINT slotlock_workspaces_status_valid CHECK (
|
|
2415
|
+
status IN ('active', 'deleting') AND (status = 'deleting') = (delete_after IS NOT NULL)
|
|
2416
|
+
);
|
|
2417
|
+
END IF;
|
|
2418
|
+
END
|
|
2419
|
+
$slotlock_workspaces_deleting$;
|
|
2420
|
+
|
|
2421
|
+
-- Schedule a team's deletion, by an Owner who types its handle: refused while it has a subscription
|
|
2422
|
+
-- (cancelled first, as erase_tenant demands, so nobody is charged for a team that is gone).
|
|
2423
|
+
CREATE OR REPLACE FUNCTION slotlock.schedule_team_deletion(requested_tenant text, requested_actor text, requested_handle text)
|
|
2424
|
+
RETURNS text
|
|
1066
2425
|
LANGUAGE plpgsql
|
|
1067
2426
|
VOLATILE
|
|
1068
2427
|
SECURITY DEFINER
|
|
1069
2428
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1070
|
-
AS $
|
|
2429
|
+
AS $slotlock_schedule_team_deletion$
|
|
1071
2430
|
DECLARE
|
|
1072
|
-
|
|
2431
|
+
team_handle text;
|
|
1073
2432
|
BEGIN
|
|
1074
|
-
SELECT
|
|
1075
|
-
WHERE
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
2433
|
+
SELECT ws.handle INTO team_handle FROM slotlock.workspaces ws
|
|
2434
|
+
WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
2435
|
+
IF team_handle IS NULL THEN
|
|
2436
|
+
RETURN 'not_found';
|
|
2437
|
+
END IF;
|
|
2438
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
2439
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor
|
|
2440
|
+
AND member.status = 'active' AND member.role = 'owner') THEN
|
|
2441
|
+
RETURN 'not_allowed';
|
|
2442
|
+
END IF;
|
|
2443
|
+
IF requested_handle IS DISTINCT FROM team_handle THEN
|
|
2444
|
+
RETURN 'confirm';
|
|
2445
|
+
END IF;
|
|
2446
|
+
IF EXISTS (SELECT 1 FROM slotlock.tenant_plans paid
|
|
2447
|
+
WHERE paid.tenant_ref = requested_tenant AND paid.stripe_subscription IS NOT NULL) THEN
|
|
2448
|
+
RETURN 'subscription_live';
|
|
2449
|
+
END IF;
|
|
2450
|
+
UPDATE slotlock.workspaces ws SET status = 'deleting', delete_after = statement_timestamp() + interval '7 days'
|
|
2451
|
+
WHERE ws.tenant_ref = requested_tenant;
|
|
2452
|
+
RETURN NULL;
|
|
1083
2453
|
END
|
|
1084
|
-
$
|
|
1085
|
-
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[
|
|
2454
|
+
$slotlock_schedule_team_deletion$;
|
|
2455
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[30]} FROM PUBLIC;
|
|
1086
2456
|
|
|
1087
|
-
--
|
|
1088
|
-
CREATE OR REPLACE FUNCTION slotlock.
|
|
1089
|
-
RETURNS
|
|
1090
|
-
LANGUAGE
|
|
2457
|
+
-- Cancel a team's deletion, by any of its Owners, before it goes.
|
|
2458
|
+
CREATE OR REPLACE FUNCTION slotlock.cancel_team_deletion(requested_tenant text, requested_actor text)
|
|
2459
|
+
RETURNS text
|
|
2460
|
+
LANGUAGE plpgsql
|
|
1091
2461
|
VOLATILE
|
|
1092
2462
|
SECURITY DEFINER
|
|
1093
2463
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1094
|
-
AS $
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
2464
|
+
AS $slotlock_cancel_team_deletion$
|
|
2465
|
+
BEGIN
|
|
2466
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'deleting' FOR UPDATE;
|
|
2467
|
+
IF NOT FOUND THEN
|
|
2468
|
+
RETURN 'not_found';
|
|
2469
|
+
END IF;
|
|
2470
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
2471
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor
|
|
2472
|
+
AND member.status = 'active' AND member.role = 'owner') THEN
|
|
2473
|
+
RETURN 'not_allowed';
|
|
2474
|
+
END IF;
|
|
2475
|
+
UPDATE slotlock.workspaces ws SET status = 'active', delete_after = NULL WHERE ws.tenant_ref = requested_tenant;
|
|
2476
|
+
RETURN NULL;
|
|
2477
|
+
END
|
|
2478
|
+
$slotlock_cancel_team_deletion$;
|
|
2479
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[31]} FROM PUBLIC;
|
|
2480
|
+
|
|
2481
|
+
-- Erase the teams whose seven days have passed, live and test, each on its own. One with a
|
|
2482
|
+
-- subscription is left until it is cancelled; one that fails otherwise is tried again a day later,
|
|
2483
|
+
-- so neither holds up the rest. How many went.
|
|
2484
|
+
CREATE OR REPLACE FUNCTION slotlock.erase_deleted_teams()
|
|
2485
|
+
RETURNS integer
|
|
2486
|
+
LANGUAGE plpgsql
|
|
2487
|
+
VOLATILE
|
|
2488
|
+
SECURITY DEFINER
|
|
2489
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2490
|
+
AS $slotlock_erase_deleted_teams$
|
|
2491
|
+
DECLARE
|
|
2492
|
+
due text;
|
|
2493
|
+
erased integer := 0;
|
|
2494
|
+
BEGIN
|
|
2495
|
+
FOR due IN
|
|
2496
|
+
SELECT ws.tenant_ref FROM slotlock.workspaces ws
|
|
2497
|
+
WHERE ws.status = 'deleting' AND ws.delete_after <= statement_timestamp()
|
|
2498
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans billed
|
|
2499
|
+
WHERE billed.tenant_ref = ws.tenant_ref AND billed.stripe_subscription IS NOT NULL)
|
|
2500
|
+
ORDER BY ws.delete_after
|
|
2501
|
+
LIMIT 20
|
|
2502
|
+
LOOP
|
|
2503
|
+
BEGIN
|
|
2504
|
+
-- Read again, and locked, as it is now: a team an Owner kept since the list was read (while
|
|
2505
|
+
-- an earlier one was being erased) is left, and keeping it waits for this erasure to finish.
|
|
2506
|
+
PERFORM 1 FROM slotlock.workspaces ws
|
|
2507
|
+
WHERE ws.tenant_ref = due AND ws.status = 'deleting' AND ws.delete_after <= statement_timestamp()
|
|
2508
|
+
FOR UPDATE;
|
|
2509
|
+
IF FOUND THEN
|
|
2510
|
+
PERFORM * FROM slotlock.erase_tenant(due || '~test');
|
|
2511
|
+
PERFORM * FROM slotlock.erase_tenant(due);
|
|
2512
|
+
erased := erased + 1;
|
|
2513
|
+
END IF;
|
|
2514
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2515
|
+
UPDATE slotlock.workspaces ws SET delete_after = statement_timestamp() + interval '1 day'
|
|
2516
|
+
WHERE ws.tenant_ref = due AND ws.status = 'deleting';
|
|
2517
|
+
RAISE WARNING 'Slotlock could not erase deleted team %, trying again in a day: %', due, SQLERRM;
|
|
2518
|
+
END;
|
|
2519
|
+
END LOOP;
|
|
2520
|
+
RETURN erased;
|
|
2521
|
+
END
|
|
2522
|
+
$slotlock_erase_deleted_teams$;
|
|
2523
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[32]} FROM PUBLIC;
|
|
2524
|
+
|
|
2525
|
+
-- When a team being deleted goes, or null.
|
|
2526
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_delete_after(requested_tenant text)
|
|
2527
|
+
RETURNS timestamptz
|
|
2528
|
+
LANGUAGE plpgsql
|
|
2529
|
+
STABLE
|
|
2530
|
+
SECURITY DEFINER
|
|
2531
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2532
|
+
AS $slotlock_workspace_delete_after$
|
|
2533
|
+
BEGIN
|
|
2534
|
+
RETURN (SELECT ws.delete_after FROM slotlock.workspaces ws
|
|
2535
|
+
WHERE ws.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', ''));
|
|
2536
|
+
END
|
|
2537
|
+
$slotlock_workspace_delete_after$;
|
|
2538
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[33]} FROM PUBLIC;
|
|
1101
2539
|
`;
|
|
1102
2540
|
// The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
|
|
1103
2541
|
// is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
|
|
@@ -1897,6 +3335,9 @@ DECLARE
|
|
|
1897
3335
|
setting text;
|
|
1898
3336
|
previous jsonb := '{}'::jsonb;
|
|
1899
3337
|
tested record;
|
|
3338
|
+
cleared_rows bigint;
|
|
3339
|
+
sso_people text[] := ARRAY[]::text[];
|
|
3340
|
+
departing text;
|
|
1900
3341
|
BEGIN
|
|
1901
3342
|
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
|
|
1902
3343
|
RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
|
|
@@ -1914,6 +3355,26 @@ BEGIN
|
|
|
1914
3355
|
RAISE EXCEPTION 'Slotlock tenant has a live subscription: cancel it before erasing the tenant'
|
|
1915
3356
|
USING ERRCODE = 'object_in_use', HINT = 'billing_active';
|
|
1916
3357
|
END IF;
|
|
3358
|
+
-- Erasing a person never takes other people's workspace with it: a team's only Owner hands it
|
|
3359
|
+
-- on, or deletes it, first. The teams they own are locked first, as every change to a team's
|
|
3360
|
+
-- people locks it, so an Owner leaving meanwhile waits, and this check sees what it left.
|
|
3361
|
+
PERFORM 1 FROM slotlock.workspaces ws
|
|
3362
|
+
WHERE ws.tenant_ref IN (SELECT mine.tenant_ref FROM slotlock.workspace_memberships mine
|
|
3363
|
+
WHERE mine.person_ref = requested_tenant_ref AND mine.status = 'active'
|
|
3364
|
+
AND mine.role = 'owner')
|
|
3365
|
+
ORDER BY ws.tenant_ref
|
|
3366
|
+
FOR UPDATE;
|
|
3367
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' AND EXISTS (
|
|
3368
|
+
SELECT 1 FROM slotlock.workspace_memberships mine
|
|
3369
|
+
JOIN slotlock.workspaces ws ON ws.tenant_ref = mine.tenant_ref AND ws.status = 'active'
|
|
3370
|
+
WHERE mine.person_ref = requested_tenant_ref AND mine.status = 'active' AND mine.role = 'owner'
|
|
3371
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships other
|
|
3372
|
+
WHERE other.tenant_ref = mine.tenant_ref AND other.status = 'active'
|
|
3373
|
+
AND other.role = 'owner' AND other.person_ref <> mine.person_ref)
|
|
3374
|
+
) THEN
|
|
3375
|
+
RAISE EXCEPTION 'Slotlock person is the only owner of a team workspace: make another owner, or delete it, first'
|
|
3376
|
+
USING ERRCODE = 'object_in_use', HINT = 'sole_owner';
|
|
3377
|
+
END IF;
|
|
1917
3378
|
FOR setting IN
|
|
1918
3379
|
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
1919
3380
|
LOOP
|
|
@@ -1984,6 +3445,14 @@ BEGIN
|
|
|
1984
3445
|
DELETE FROM slotlock.source_busy WHERE tenant_ref = requested_tenant_ref;
|
|
1985
3446
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
1986
3447
|
RETURN NEXT;
|
|
3448
|
+
erased_table := 'tenant_export_parts';
|
|
3449
|
+
DELETE FROM slotlock.tenant_export_parts WHERE tenant_ref = requested_tenant_ref;
|
|
3450
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3451
|
+
RETURN NEXT;
|
|
3452
|
+
erased_table := 'tenant_exports';
|
|
3453
|
+
DELETE FROM slotlock.tenant_exports WHERE tenant_ref = requested_tenant_ref;
|
|
3454
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3455
|
+
RETURN NEXT;
|
|
1987
3456
|
erased_table := 'reservations';
|
|
1988
3457
|
DELETE FROM slotlock.reservations
|
|
1989
3458
|
WHERE tenant_ref = requested_tenant_ref
|
|
@@ -2071,6 +3540,10 @@ BEGIN
|
|
|
2071
3540
|
DELETE FROM slotlock.assistant_chats WHERE tenant_ref = requested_tenant_ref;
|
|
2072
3541
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2073
3542
|
RETURN NEXT;
|
|
3543
|
+
erased_table := 'audit_events';
|
|
3544
|
+
DELETE FROM slotlock.audit_events WHERE tenant_ref = requested_tenant_ref;
|
|
3545
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3546
|
+
RETURN NEXT;
|
|
2074
3547
|
erased_table := 'assistant_settings';
|
|
2075
3548
|
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
|
|
2076
3549
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -2153,6 +3626,124 @@ BEGIN
|
|
|
2153
3626
|
)
|
|
2154
3627
|
SELECT count(*) INTO erased_rows FROM erased;
|
|
2155
3628
|
RETURN NEXT;
|
|
3629
|
+
-- A team's directory goes with it; a person comes off the directory users bound to them.
|
|
3630
|
+
erased_table := 'scim_users';
|
|
3631
|
+
DELETE FROM slotlock.scim_users directory WHERE directory.tenant_ref = requested_tenant_ref;
|
|
3632
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3633
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
3634
|
+
UPDATE slotlock.scim_users directory SET person_ref = NULL WHERE directory.person_ref = requested_tenant_ref;
|
|
3635
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3636
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3637
|
+
END IF;
|
|
3638
|
+
RETURN NEXT;
|
|
3639
|
+
erased_table := 'scim_groups';
|
|
3640
|
+
DELETE FROM slotlock.scim_groups grouped WHERE grouped.tenant_ref = requested_tenant_ref;
|
|
3641
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3642
|
+
RETURN NEXT;
|
|
3643
|
+
erased_table := 'scim_tokens';
|
|
3644
|
+
DELETE FROM slotlock.scim_tokens token WHERE token.tenant_ref = requested_tenant_ref;
|
|
3645
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3646
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
3647
|
+
UPDATE slotlock.scim_tokens token SET created_by = NULL WHERE token.created_by = requested_tenant_ref;
|
|
3648
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3649
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3650
|
+
END IF;
|
|
3651
|
+
RETURN NEXT;
|
|
3652
|
+
-- A team's single sign-on goes with it (its identities, mappings, proofs and the sessions signed in
|
|
3653
|
+
-- through it alone with the connection); a person takes their identities, and their bypasses.
|
|
3654
|
+
erased_table := 'sso_bypasses';
|
|
3655
|
+
DELETE FROM slotlock.sso_bypasses bypass
|
|
3656
|
+
WHERE bypass.tenant_ref = requested_tenant_ref OR bypass.person_ref = requested_tenant_ref;
|
|
3657
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3658
|
+
RETURN NEXT;
|
|
3659
|
+
-- Who signed in only through the team's provider: erased below, with no way in left.
|
|
3660
|
+
IF requested_tenant_ref ~ '^team:' THEN
|
|
3661
|
+
SELECT coalesce(array_agg(DISTINCT identity.person_ref), ARRAY[]::text[]) INTO sso_people
|
|
3662
|
+
FROM slotlock.sso_identities identity
|
|
3663
|
+
JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id AND linked.tenant_ref = requested_tenant_ref
|
|
3664
|
+
WHERE identity.person_ref ~ '^sso:';
|
|
3665
|
+
END IF;
|
|
3666
|
+
erased_table := 'sso_identities';
|
|
3667
|
+
DELETE FROM slotlock.sso_identities identity
|
|
3668
|
+
WHERE identity.person_ref = requested_tenant_ref
|
|
3669
|
+
OR identity.connection_id IN (SELECT linked.id FROM slotlock.sso_connections linked WHERE linked.tenant_ref = requested_tenant_ref);
|
|
3670
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3671
|
+
RETURN NEXT;
|
|
3672
|
+
erased_table := 'sso_connections';
|
|
3673
|
+
DELETE FROM slotlock.sso_connections linked WHERE linked.tenant_ref = requested_tenant_ref;
|
|
3674
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3675
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
3676
|
+
UPDATE slotlock.sso_connections linked SET created_by = NULL WHERE linked.created_by = requested_tenant_ref;
|
|
3677
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3678
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3679
|
+
-- What a test sign-in showed (the tester's email among it) goes with the tester.
|
|
3680
|
+
UPDATE slotlock.sso_connections linked SET last_test_claims = NULL, tested_by = NULL
|
|
3681
|
+
WHERE linked.tested_by = requested_tenant_ref;
|
|
3682
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3683
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3684
|
+
END IF;
|
|
3685
|
+
RETURN NEXT;
|
|
3686
|
+
-- A team's requests to join and domains go with it; a person's own tenant takes their requests,
|
|
3687
|
+
-- and their name off the domains they claimed and the requests they decided.
|
|
3688
|
+
erased_table := 'workspace_join_requests';
|
|
3689
|
+
DELETE FROM slotlock.workspace_join_requests asked
|
|
3690
|
+
WHERE asked.tenant_ref = requested_tenant_ref
|
|
3691
|
+
OR (requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
3692
|
+
AND asked.person_ref = requested_tenant_ref);
|
|
3693
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3694
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
3695
|
+
UPDATE slotlock.workspace_join_requests asked SET decided_by = NULL WHERE asked.decided_by = requested_tenant_ref;
|
|
3696
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3697
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3698
|
+
END IF;
|
|
3699
|
+
RETURN NEXT;
|
|
3700
|
+
erased_table := 'workspace_join_blocks';
|
|
3701
|
+
DELETE FROM slotlock.workspace_join_blocks blocked WHERE blocked.tenant_ref = requested_tenant_ref;
|
|
3702
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3703
|
+
RETURN NEXT;
|
|
3704
|
+
erased_table := 'workspace_domains';
|
|
3705
|
+
DELETE FROM slotlock.workspace_domains claim WHERE claim.tenant_ref = requested_tenant_ref;
|
|
3706
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3707
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
3708
|
+
UPDATE slotlock.workspace_domains claim SET created_by = NULL WHERE claim.created_by = requested_tenant_ref;
|
|
3709
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3710
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3711
|
+
END IF;
|
|
3712
|
+
RETURN NEXT;
|
|
3713
|
+
-- A team workspace's record and invitations go with it.
|
|
3714
|
+
erased_table := 'workspace_invitations';
|
|
3715
|
+
DELETE FROM slotlock.workspace_invitations invitation
|
|
3716
|
+
WHERE invitation.tenant_ref = requested_tenant_ref
|
|
3717
|
+
OR (requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
3718
|
+
AND (invitation.inviter_ref = requested_tenant_ref OR invitation.accepted_by = requested_tenant_ref));
|
|
3719
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3720
|
+
RETURN NEXT;
|
|
3721
|
+
erased_table := 'workspaces';
|
|
3722
|
+
DELETE FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant_ref;
|
|
3723
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3724
|
+
IF requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' THEN
|
|
3725
|
+
UPDATE slotlock.workspaces ws SET created_by = NULL WHERE ws.created_by = requested_tenant_ref;
|
|
3726
|
+
GET DIAGNOSTICS cleared_rows = ROW_COUNT;
|
|
3727
|
+
erased_rows := erased_rows + cleared_rows;
|
|
3728
|
+
END IF;
|
|
3729
|
+
RETURN NEXT;
|
|
3730
|
+
-- A shared workspace's members go with it; a person's own tenant takes their memberships of
|
|
3731
|
+
-- every workspace, and their record of signing in, with it.
|
|
3732
|
+
erased_table := 'workspace_memberships';
|
|
3733
|
+
DELETE FROM slotlock.workspace_memberships member
|
|
3734
|
+
WHERE member.tenant_ref = requested_tenant_ref
|
|
3735
|
+
OR (requested_tenant_ref ~ '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
3736
|
+
AND member.person_ref = requested_tenant_ref);
|
|
3737
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3738
|
+
RETURN NEXT;
|
|
3739
|
+
erased_table := 'dashboard_sessions';
|
|
3740
|
+
DELETE FROM slotlock.dashboard_sessions session_kept WHERE session_kept.person_ref = requested_tenant_ref;
|
|
3741
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3742
|
+
RETURN NEXT;
|
|
3743
|
+
erased_table := 'dashboard_people';
|
|
3744
|
+
DELETE FROM slotlock.dashboard_people person WHERE person.person_ref = requested_tenant_ref;
|
|
3745
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3746
|
+
RETURN NEXT;
|
|
2156
3747
|
-- An organization's record of being turned on goes with its tenant (Teams on Cloud).
|
|
2157
3748
|
erased_table := 'dashboard_organizations';
|
|
2158
3749
|
DELETE FROM slotlock.dashboard_organizations org
|
|
@@ -2194,6 +3785,22 @@ BEGIN
|
|
|
2194
3785
|
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
2195
3786
|
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
2196
3787
|
END LOOP;
|
|
3788
|
+
|
|
3789
|
+
-- The people who signed in only through the erased team's provider (sso:), reported as one line:
|
|
3790
|
+
-- they were members of no other team, and can no longer sign in to ask for their own erasure.
|
|
3791
|
+
IF cardinality(sso_people) > 0 THEN
|
|
3792
|
+
erased_table := 'sso_people';
|
|
3793
|
+
erased_rows := 0;
|
|
3794
|
+
FOREACH departing IN ARRAY sso_people LOOP
|
|
3795
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships other
|
|
3796
|
+
WHERE other.person_ref = departing AND other.status = 'active')
|
|
3797
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.sso_identities other WHERE other.person_ref = departing) THEN
|
|
3798
|
+
PERFORM 1 FROM slotlock.erase_tenant(departing);
|
|
3799
|
+
erased_rows := erased_rows + 1;
|
|
3800
|
+
END IF;
|
|
3801
|
+
END LOOP;
|
|
3802
|
+
RETURN NEXT;
|
|
3803
|
+
END IF;
|
|
2197
3804
|
END
|
|
2198
3805
|
$slotlock_erase_tenant$;
|
|
2199
3806
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
|
|
@@ -2241,7 +3848,8 @@ BEGIN
|
|
|
2241
3848
|
'calendar_event_occurrences', 'composite_claims', 'approvals', 'approval_policies',
|
|
2242
3849
|
'availability_exceptions', 'holiday_calendars', 'availability_defaults', 'contacts', 'reminder_dispatches',
|
|
2243
3850
|
'reservation_reminder_dispatches',
|
|
2244
|
-
'hold_payments', 'source_busy', '
|
|
3851
|
+
'hold_payments', 'source_busy', 'tenant_export_parts', 'tenant_exports',
|
|
3852
|
+
'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
|
|
2245
3853
|
'reservation_tombstones', 'reservation_conflict_archive', 'calendar_coverage', 'hold_commands',
|
|
2246
3854
|
'waitlist_entries', 'booking_rule_defaults', 'pool_settings', 'resource_group_members',
|
|
2247
3855
|
'resources', 'resource_groups', 'outbox'
|
|
@@ -3113,6 +4721,300 @@ SELECT moved.tenant_ref, moved.member_ref,
|
|
|
3113
4721
|
) first_user ON true
|
|
3114
4722
|
ON CONFLICT DO NOTHING;
|
|
3115
4723
|
|
|
4724
|
+
-- A workspace's audit log: who did what to its people, keys, webhooks, billing and settings, from
|
|
4725
|
+
-- where, and whether it was refused. Not the change feed: that keeps 30 days and goes to the
|
|
4726
|
+
-- tenant's webhooks; this keeps SLOTLOCK_AUDIT_RETENTION_DAYS (180) and goes nowhere. The serving
|
|
4727
|
+
-- role may only add and read rows (no UPDATE, DELETE or TRUNCATE): an event once written stays as
|
|
4728
|
+
-- it was until slotlock.prune_audit_events, or erasure, takes it. Names are never stored, only
|
|
4729
|
+
-- references (github:<id>, api_key:<id>), resolved when the log is read.
|
|
4730
|
+
CREATE TABLE IF NOT EXISTS slotlock.audit_events (
|
|
4731
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4732
|
+
tenant_ref text NOT NULL,
|
|
4733
|
+
occurred_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4734
|
+
action text NOT NULL,
|
|
4735
|
+
actor_type text NOT NULL,
|
|
4736
|
+
actor_ref text NOT NULL,
|
|
4737
|
+
actor_role text,
|
|
4738
|
+
target_type text,
|
|
4739
|
+
target_ref text,
|
|
4740
|
+
outcome text NOT NULL DEFAULT 'success',
|
|
4741
|
+
reason text,
|
|
4742
|
+
ip_address text,
|
|
4743
|
+
user_agent text,
|
|
4744
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4745
|
+
CONSTRAINT slotlock_audit_events_valid CHECK (
|
|
4746
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
4747
|
+
AND action ~ '^[a-z][a-z_]{0,31}(\\.[a-z][a-z_]{0,31}){1,2}$'
|
|
4748
|
+
AND actor_type IN ('person', 'api_key', 'assistant', 'system')
|
|
4749
|
+
AND octet_length(actor_ref) BETWEEN 1 AND 500
|
|
4750
|
+
AND (actor_role IS NULL OR actor_role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing'))
|
|
4751
|
+
AND (target_type IS NULL OR target_type ~ '^[a-z][a-z_]{0,31}$')
|
|
4752
|
+
AND (target_ref IS NULL OR octet_length(target_ref) BETWEEN 1 AND 500)
|
|
4753
|
+
AND outcome IN ('success', 'denied')
|
|
4754
|
+
AND (reason IS NULL OR octet_length(reason) BETWEEN 1 AND 200)
|
|
4755
|
+
AND (ip_address IS NULL OR octet_length(ip_address) BETWEEN 1 AND 64)
|
|
4756
|
+
AND (user_agent IS NULL OR octet_length(user_agent) BETWEEN 1 AND 256)
|
|
4757
|
+
AND jsonb_typeof(metadata) = 'object'
|
|
4758
|
+
AND pg_column_size(metadata) <= 4096
|
|
4759
|
+
)
|
|
4760
|
+
);
|
|
4761
|
+
CREATE INDEX IF NOT EXISTS slotlock_audit_events_recent_idx
|
|
4762
|
+
ON slotlock.audit_events (tenant_ref, occurred_at DESC, id);
|
|
4763
|
+
|
|
4764
|
+
-- Each tenant's events form a chain: each holds the SHA-256 of the one before it with its own
|
|
4765
|
+
-- content, numbered in the order they were written. Rewriting, removing or reordering an event
|
|
4766
|
+
-- (which the serving role cannot do, but an owner of the database could) breaks every hash after
|
|
4767
|
+
-- it, and slotlock.verify_audit_chain says where. Events from before the chain have none.
|
|
4768
|
+
ALTER TABLE slotlock.audit_events ADD COLUMN IF NOT EXISTS chain_index bigint;
|
|
4769
|
+
ALTER TABLE slotlock.audit_events ADD COLUMN IF NOT EXISTS prev_hash bytea;
|
|
4770
|
+
ALTER TABLE slotlock.audit_events ADD COLUMN IF NOT EXISTS hash bytea;
|
|
4771
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_audit_events_chain_idx
|
|
4772
|
+
ON slotlock.audit_events (tenant_ref, chain_index);
|
|
4773
|
+
|
|
4774
|
+
-- An event as its hash covers it: every field, in a fixed order, separated by the unit separator.
|
|
4775
|
+
CREATE OR REPLACE FUNCTION slotlock.audit_event_text(event slotlock.audit_events)
|
|
4776
|
+
RETURNS text
|
|
4777
|
+
LANGUAGE sql
|
|
4778
|
+
STABLE
|
|
4779
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4780
|
+
AS $slotlock_audit_event_text$
|
|
4781
|
+
SELECT concat_ws(chr(31), event.tenant_ref, event.chain_index::text, event.id::text,
|
|
4782
|
+
(extract(epoch FROM event.occurred_at) * 1000000)::bigint::text, event.action,
|
|
4783
|
+
event.actor_type, event.actor_ref, coalesce(event.actor_role, ''),
|
|
4784
|
+
coalesce(event.target_type, ''), coalesce(event.target_ref, ''), event.outcome,
|
|
4785
|
+
coalesce(event.reason, ''), coalesce(event.ip_address, ''),
|
|
4786
|
+
coalesce(event.user_agent, ''), event.metadata::text);
|
|
4787
|
+
$slotlock_audit_event_text$;
|
|
4788
|
+
|
|
4789
|
+
-- Before each insert: the tenant's chain one writer at a time, the event numbered after the last
|
|
4790
|
+
-- and hashed with it.
|
|
4791
|
+
CREATE OR REPLACE FUNCTION slotlock.chain_audit_event()
|
|
4792
|
+
RETURNS trigger
|
|
4793
|
+
LANGUAGE plpgsql
|
|
4794
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4795
|
+
AS $slotlock_chain_audit_event$
|
|
4796
|
+
DECLARE
|
|
4797
|
+
last_index bigint;
|
|
4798
|
+
last_hash bytea;
|
|
4799
|
+
BEGIN
|
|
4800
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:audit-chain:' || NEW.tenant_ref, 0));
|
|
4801
|
+
SELECT kept.chain_index, kept.hash INTO last_index, last_hash FROM slotlock.audit_events kept
|
|
4802
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.chain_index IS NOT NULL
|
|
4803
|
+
ORDER BY kept.chain_index DESC LIMIT 1;
|
|
4804
|
+
-- The time is the database's, whatever the insert said: an event dated in the past would have
|
|
4805
|
+
-- retention take the events after it.
|
|
4806
|
+
NEW.occurred_at := statement_timestamp();
|
|
4807
|
+
NEW.chain_index := coalesce(last_index, 0) + 1;
|
|
4808
|
+
NEW.prev_hash := last_hash;
|
|
4809
|
+
NEW.hash := sha256(coalesce(last_hash, ''::bytea) || convert_to(slotlock.audit_event_text(NEW), 'UTF8'));
|
|
4810
|
+
RETURN NEW;
|
|
4811
|
+
END
|
|
4812
|
+
$slotlock_chain_audit_event$;
|
|
4813
|
+
DROP TRIGGER IF EXISTS slotlock_audit_events_chain ON slotlock.audit_events;
|
|
4814
|
+
CREATE TRIGGER slotlock_audit_events_chain BEFORE INSERT ON slotlock.audit_events
|
|
4815
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.chain_audit_event();
|
|
4816
|
+
|
|
4817
|
+
-- Whether a tenant's chain holds, from its oldest event kept: how many events it checked, and the
|
|
4818
|
+
-- first whose number, link or hash is wrong (null when none is). Run as the serving role, under
|
|
4819
|
+
-- the tenant's own row security.
|
|
4820
|
+
CREATE OR REPLACE FUNCTION slotlock.verify_audit_chain(requested_tenant text)
|
|
4821
|
+
RETURNS TABLE (checked bigint, broken_at bigint)
|
|
4822
|
+
LANGUAGE plpgsql
|
|
4823
|
+
STABLE
|
|
4824
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4825
|
+
AS $slotlock_verify_audit_chain$
|
|
4826
|
+
DECLARE
|
|
4827
|
+
event slotlock.audit_events;
|
|
4828
|
+
expected_index bigint;
|
|
4829
|
+
expected_prev bytea;
|
|
4830
|
+
seen bigint := 0;
|
|
4831
|
+
loose_at timestamptz;
|
|
4832
|
+
BEGIN
|
|
4833
|
+
-- An event with no place in the chain, written after the chain began, was taken off it: broken
|
|
4834
|
+
-- where it stood, the first chained event after it (or past the last).
|
|
4835
|
+
SELECT min(loose.occurred_at) INTO loose_at FROM slotlock.audit_events loose
|
|
4836
|
+
WHERE loose.tenant_ref = requested_tenant AND loose.chain_index IS NULL
|
|
4837
|
+
AND loose.occurred_at >= (SELECT min(first.occurred_at) FROM slotlock.audit_events first
|
|
4838
|
+
WHERE first.tenant_ref = requested_tenant AND first.chain_index IS NOT NULL);
|
|
4839
|
+
IF loose_at IS NOT NULL THEN
|
|
4840
|
+
RETURN QUERY SELECT 0::bigint, coalesce(
|
|
4841
|
+
(SELECT min(after.chain_index) FROM slotlock.audit_events after
|
|
4842
|
+
WHERE after.tenant_ref = requested_tenant AND after.chain_index IS NOT NULL AND after.occurred_at > loose_at),
|
|
4843
|
+
(SELECT max(last.chain_index) + 1 FROM slotlock.audit_events last WHERE last.tenant_ref = requested_tenant));
|
|
4844
|
+
RETURN;
|
|
4845
|
+
END IF;
|
|
4846
|
+
FOR event IN
|
|
4847
|
+
SELECT * FROM slotlock.audit_events kept
|
|
4848
|
+
WHERE kept.tenant_ref = requested_tenant AND kept.chain_index IS NOT NULL
|
|
4849
|
+
ORDER BY kept.chain_index
|
|
4850
|
+
LOOP
|
|
4851
|
+
IF (seen > 0 AND (event.chain_index <> expected_index OR event.prev_hash IS DISTINCT FROM expected_prev))
|
|
4852
|
+
OR event.hash IS DISTINCT FROM
|
|
4853
|
+
sha256(coalesce(event.prev_hash, ''::bytea) || convert_to(slotlock.audit_event_text(event), 'UTF8')) THEN
|
|
4854
|
+
RETURN QUERY SELECT seen, event.chain_index;
|
|
4855
|
+
RETURN;
|
|
4856
|
+
END IF;
|
|
4857
|
+
seen := seen + 1;
|
|
4858
|
+
expected_index := event.chain_index + 1;
|
|
4859
|
+
expected_prev := event.hash;
|
|
4860
|
+
END LOOP;
|
|
4861
|
+
RETURN QUERY SELECT seen, NULL::bigint;
|
|
4862
|
+
END
|
|
4863
|
+
$slotlock_verify_audit_chain$;
|
|
4864
|
+
|
|
4865
|
+
-- A tenant's events past retention. The serving role cannot delete audit events itself: this is
|
|
4866
|
+
-- the one way they go, besides erasure, and it reaches only events retention has ended.
|
|
4867
|
+
CREATE OR REPLACE FUNCTION slotlock.prune_audit_events(requested_tenant text)
|
|
4868
|
+
RETURNS integer
|
|
4869
|
+
LANGUAGE plpgsql
|
|
4870
|
+
VOLATILE
|
|
4871
|
+
SECURITY DEFINER
|
|
4872
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4873
|
+
AS $slotlock_prune_audit_events$
|
|
4874
|
+
DECLARE
|
|
4875
|
+
pruned integer;
|
|
4876
|
+
kept_days integer;
|
|
4877
|
+
BEGIN
|
|
4878
|
+
-- SLOTLOCK_AUDIT_RETENTION_DAYS, or the plan's change history if longer (Team's 400 days).
|
|
4879
|
+
kept_days := greatest(${SLOTLOCK_AUDIT_RETENTION_DAYS}, coalesce(${planChangeRetentionDaysSql('requested_tenant')}, 0));
|
|
4880
|
+
-- The start of the chain only, so what is kept still links up: every event up to the last one
|
|
4881
|
+
-- written before retention's cut, and events from before the chain past it.
|
|
4882
|
+
DELETE FROM slotlock.audit_events gone
|
|
4883
|
+
WHERE gone.tenant_ref = requested_tenant
|
|
4884
|
+
AND ((gone.chain_index IS NULL
|
|
4885
|
+
AND gone.occurred_at < statement_timestamp() - make_interval(days => kept_days))
|
|
4886
|
+
OR gone.chain_index <= (
|
|
4887
|
+
SELECT max(old.chain_index) FROM slotlock.audit_events old
|
|
4888
|
+
WHERE old.tenant_ref = requested_tenant
|
|
4889
|
+
AND old.occurred_at < statement_timestamp() - make_interval(days => kept_days)));
|
|
4890
|
+
GET DIAGNOSTICS pruned = ROW_COUNT;
|
|
4891
|
+
RETURN pruned;
|
|
4892
|
+
END
|
|
4893
|
+
$slotlock_prune_audit_events$;
|
|
4894
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[8]} FROM PUBLIC;
|
|
4895
|
+
|
|
4896
|
+
-- A tenant's changes past retention (RFC 0004 D2). The serving role cannot change or delete a
|
|
4897
|
+
-- change itself: this is the one way they go, besides erasure. It keeps the store's days
|
|
4898
|
+
-- (requested_days, 1 to 3650), or the plan's (SLOTLOCK_CHANGE_RETENTION_BY_PLAN) if longer, so a
|
|
4899
|
+
-- serving role cannot cut a hosted tenant's history short. Oldest first in feed order, and only up
|
|
4900
|
+
-- to the first change kept (within retention, still owed to an enabled webhook, or later than the
|
|
4901
|
+
-- oldest running transaction), so every kept change is after every pruned one and a reader starting
|
|
4902
|
+
-- over reads past the gap.
|
|
4903
|
+
CREATE OR REPLACE FUNCTION slotlock.prune_changes(requested_tenant text, batch integer, requested_days integer)
|
|
4904
|
+
RETURNS integer
|
|
4905
|
+
LANGUAGE plpgsql
|
|
4906
|
+
VOLATILE
|
|
4907
|
+
SECURITY DEFINER
|
|
4908
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4909
|
+
AS $slotlock_prune_changes$
|
|
4910
|
+
DECLARE
|
|
4911
|
+
kept_days integer;
|
|
4912
|
+
pruned integer;
|
|
4913
|
+
BEGIN
|
|
4914
|
+
IF batch IS NULL OR batch < 1 OR batch > 10000 OR requested_days IS NULL OR requested_days < 1
|
|
4915
|
+
OR requested_days > 3650 THEN
|
|
4916
|
+
RAISE EXCEPTION 'Slotlock prunes 1 to 10000 changes at a time, keeping 1 to 3650 days'
|
|
4917
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
4918
|
+
END IF;
|
|
4919
|
+
-- A role that cannot delete changes itself (the serving role) acts only for the tenant it
|
|
4920
|
+
-- acts in, where row security names one: the function runs as the schema's owner, which row
|
|
4921
|
+
-- security may not bind. The owner, or a library on its connection, needs no tenant context.
|
|
4922
|
+
IF NOT pg_catalog.has_table_privilege(session_user, 'slotlock.outbox', 'DELETE')
|
|
4923
|
+
AND EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts)
|
|
4924
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts contract
|
|
4925
|
+
WHERE current_setting(contract.tenant_context_setting, true) = requested_tenant) THEN
|
|
4926
|
+
RAISE EXCEPTION 'Slotlock prunes the history of only the tenant the caller acts in'
|
|
4927
|
+
USING ERRCODE = 'insufficient_privilege';
|
|
4928
|
+
END IF;
|
|
4929
|
+
kept_days := greatest(requested_days, coalesce(${planChangeRetentionDaysSql('requested_tenant')}, 0));
|
|
4930
|
+
WITH head AS MATERIALIZED (
|
|
4931
|
+
SELECT outbox.id, outbox.txid,
|
|
4932
|
+
outbox.occurred_at >= now() - make_interval(days => kept_days)
|
|
4933
|
+
OR EXISTS (
|
|
4934
|
+
SELECT 1 FROM slotlock.webhook_endpoints endpoint
|
|
4935
|
+
WHERE endpoint.tenant_ref = outbox.tenant_ref AND endpoint.disabled_at IS NULL
|
|
4936
|
+
AND (outbox.txid, outbox.id) > (endpoint.cursor_txid, endpoint.cursor_id)
|
|
4937
|
+
) AS kept
|
|
4938
|
+
FROM slotlock.outbox
|
|
4939
|
+
WHERE outbox.tenant_ref = requested_tenant
|
|
4940
|
+
AND outbox.txid < pg_snapshot_xmin(pg_current_snapshot())
|
|
4941
|
+
ORDER BY outbox.txid, outbox.id
|
|
4942
|
+
LIMIT batch
|
|
4943
|
+
), doomed AS MATERIALIZED (
|
|
4944
|
+
SELECT change.id, change.txid FROM head change
|
|
4945
|
+
WHERE NOT EXISTS (
|
|
4946
|
+
SELECT 1 FROM head keeper
|
|
4947
|
+
WHERE keeper.kept AND (keeper.txid, keeper.id) <= (change.txid, change.id))
|
|
4948
|
+
), missed AS (
|
|
4949
|
+
-- A disabled endpoint that had not received one of them has lost it.
|
|
4950
|
+
UPDATE slotlock.webhook_endpoints endpoint SET changes_pruned = true
|
|
4951
|
+
WHERE endpoint.tenant_ref = requested_tenant AND endpoint.disabled_at IS NOT NULL
|
|
4952
|
+
AND NOT endpoint.changes_pruned
|
|
4953
|
+
AND EXISTS (
|
|
4954
|
+
SELECT 1 FROM doomed
|
|
4955
|
+
WHERE (doomed.txid, doomed.id) > (endpoint.cursor_txid, endpoint.cursor_id))
|
|
4956
|
+
), deleted AS (
|
|
4957
|
+
DELETE FROM slotlock.outbox change USING doomed WHERE change.id = doomed.id RETURNING 1
|
|
4958
|
+
)
|
|
4959
|
+
SELECT count(*)::integer INTO pruned FROM deleted;
|
|
4960
|
+
RETURN pruned;
|
|
4961
|
+
END
|
|
4962
|
+
$slotlock_prune_changes$;
|
|
4963
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[21]} FROM PUBLIC;
|
|
4964
|
+
|
|
4965
|
+
-- Taking one person out of a tenant's change history (RFC 0004 D3, slotlock subject erase): the
|
|
4966
|
+
-- one edit it allows. A holder's reference and a booking's reference become \`replacement\` (an
|
|
4967
|
+
-- \`erased-\` name, the same one everywhere the erasure reached, so what was one booking still reads
|
|
4968
|
+
-- as one), and a contact's reference is taken off. Nothing else in a change is touched, and the
|
|
4969
|
+
-- serving role can write nothing but such a name. Row security keeps it to the caller's tenant.
|
|
4970
|
+
CREATE OR REPLACE FUNCTION slotlock.erase_subject_changes(
|
|
4971
|
+
requested_tenant text, kind text, erased text, replacement text
|
|
4972
|
+
)
|
|
4973
|
+
RETURNS integer
|
|
4974
|
+
LANGUAGE plpgsql
|
|
4975
|
+
VOLATILE
|
|
4976
|
+
SECURITY DEFINER
|
|
4977
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4978
|
+
AS $slotlock_erase_subject_changes$
|
|
4979
|
+
DECLARE
|
|
4980
|
+
changed integer;
|
|
4981
|
+
BEGIN
|
|
4982
|
+
IF kind IS NULL OR kind NOT IN ('holder', 'contact', 'reference')
|
|
4983
|
+
OR erased IS NULL OR octet_length(erased) NOT BETWEEN 1 AND 500
|
|
4984
|
+
OR (kind <> 'contact' AND (replacement IS NULL OR replacement !~ '^erased-[0-9a-f]{32}$')) THEN
|
|
4985
|
+
RAISE EXCEPTION 'Slotlock erases a holder, a contact or a reference from the history, and names it erased-<32 hex>'
|
|
4986
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
4987
|
+
END IF;
|
|
4988
|
+
-- A role that cannot update changes itself (the serving role) acts only for the tenant it
|
|
4989
|
+
-- acts in, where row security names one: the function runs as the schema's owner, which row
|
|
4990
|
+
-- security may not bind. The owner, or a library on its connection, needs no tenant context.
|
|
4991
|
+
IF NOT pg_catalog.has_table_privilege(session_user, 'slotlock.outbox', 'UPDATE')
|
|
4992
|
+
AND EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts)
|
|
4993
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts contract
|
|
4994
|
+
WHERE current_setting(contract.tenant_context_setting, true) = requested_tenant) THEN
|
|
4995
|
+
RAISE EXCEPTION 'Slotlock erases from the history of only the tenant the caller acts in'
|
|
4996
|
+
USING ERRCODE = 'insufficient_privilege';
|
|
4997
|
+
END IF;
|
|
4998
|
+
IF kind = 'holder' THEN
|
|
4999
|
+
UPDATE slotlock.outbox change
|
|
5000
|
+
SET data = pg_catalog.jsonb_set(change.data, '{holder_ref}', pg_catalog.to_jsonb(replacement))
|
|
5001
|
+
WHERE change.tenant_ref = requested_tenant AND change.data ->> 'holder_ref' = erased;
|
|
5002
|
+
ELSIF kind = 'contact' THEN
|
|
5003
|
+
UPDATE slotlock.outbox change SET data = change.data - 'contact_ref'
|
|
5004
|
+
WHERE change.tenant_ref = requested_tenant AND change.data ->> 'contact_ref' = erased;
|
|
5005
|
+
ELSE
|
|
5006
|
+
-- A booking's changes are named by its reference and carry it.
|
|
5007
|
+
UPDATE slotlock.outbox change
|
|
5008
|
+
SET subject = CASE WHEN change.subject = erased THEN replacement ELSE change.subject END,
|
|
5009
|
+
data = pg_catalog.jsonb_set(change.data, '{reference}', pg_catalog.to_jsonb(replacement))
|
|
5010
|
+
WHERE change.tenant_ref = requested_tenant AND change.data ->> 'reference' = erased;
|
|
5011
|
+
END IF;
|
|
5012
|
+
GET DIAGNOSTICS changed = ROW_COUNT;
|
|
5013
|
+
RETURN changed;
|
|
5014
|
+
END
|
|
5015
|
+
$slotlock_erase_subject_changes$;
|
|
5016
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[22]} FROM PUBLIC;
|
|
5017
|
+
|
|
3116
5018
|
-- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
|
|
3117
5019
|
CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
|
|
3118
5020
|
RETURNS trigger
|
|
@@ -3178,6 +5080,15 @@ BEGIN
|
|
|
3178
5080
|
'contact_ref', changed.contact_ref,
|
|
3179
5081
|
'notify', CASE WHEN NOT changed.notify THEN false END,
|
|
3180
5082
|
'payment_required', CASE WHEN changed.payment_required THEN true END,
|
|
5083
|
+
-- A paid hold that ended without a booking (expired, let go, or left when another of its
|
|
5084
|
+
-- group was booked): its payment was never applied, and is the integrator's to refund (RFC
|
|
5085
|
+
-- 0004 C3). Booked from (slotlock.ending = 'confirmed') or taken over by an approval, it was.
|
|
5086
|
+
'payment_unapplied', CASE
|
|
5087
|
+
WHEN TG_OP = 'DELETE' AND OLD.status = 'held' AND OLD.payment_ref IS NOT NULL
|
|
5088
|
+
AND COALESCE(current_setting('slotlock.ending', true), '') NOT IN ('confirmed', 'approval')
|
|
5089
|
+
THEN jsonb_strip_nulls(jsonb_build_object(
|
|
5090
|
+
'payment_ref', OLD.payment_ref, 'amount_minor', OLD.paid_amount_minor, 'currency', OLD.paid_currency))
|
|
5091
|
+
END,
|
|
3181
5092
|
-- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
|
|
3182
5093
|
'cancellation_fee', CASE WHEN kind = 'reservation.cancelled'
|
|
3183
5094
|
THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
|
|
@@ -5912,7 +7823,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reminder_dispatches (
|
|
|
5912
7823
|
ordinal integer NOT NULL,
|
|
5913
7824
|
due_at timestamptz NOT NULL,
|
|
5914
7825
|
dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
5915
|
-
PRIMARY KEY (event_id, recurrence_id,
|
|
7826
|
+
PRIMARY KEY (event_id, recurrence_id, due_at),
|
|
5916
7827
|
CONSTRAINT slotlock_reminder_dispatches_event_fkey FOREIGN KEY (event_id, tenant_ref)
|
|
5917
7828
|
REFERENCES slotlock.calendar_events (id, tenant_ref) ON DELETE CASCADE
|
|
5918
7829
|
);
|
|
@@ -5922,6 +7833,11 @@ CREATE INDEX IF NOT EXISTS slotlock_reminder_dispatches_tenant_idx
|
|
|
5922
7833
|
-- A booking's reminders (RFC 0004 P3): minutes before its start, at most five; each said once on
|
|
5923
7834
|
-- the feed as reminder.due, and again if the booking moves.
|
|
5924
7835
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS reminder_minutes integer[];
|
|
7836
|
+
-- The start a booking or event had when it was moved while cancelling it was already late (past its
|
|
7837
|
+
-- cancel cutoff, or inside its fee's window): a later cancellation is judged from it, so moving
|
|
7838
|
+
-- cannot take a booking out of what a late cancellation owes (RFC 0004 B5).
|
|
7839
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS cutoff_anchor timestamptz;
|
|
7840
|
+
ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS cutoff_anchor timestamptz;
|
|
5925
7841
|
DO $slotlock_reservation_reminders$
|
|
5926
7842
|
BEGIN
|
|
5927
7843
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_reminders_valid'
|
|
@@ -5940,10 +7856,43 @@ CREATE TABLE IF NOT EXISTS slotlock.reservation_reminder_dispatches (
|
|
|
5940
7856
|
ordinal integer NOT NULL CHECK (ordinal BETWEEN 0 AND 4),
|
|
5941
7857
|
due_at timestamptz NOT NULL,
|
|
5942
7858
|
dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
5943
|
-
PRIMARY KEY (reservation_id,
|
|
7859
|
+
PRIMARY KEY (reservation_id, due_at),
|
|
5944
7860
|
CONSTRAINT slotlock_reservation_reminder_dispatches_fkey FOREIGN KEY (reservation_id, tenant_ref)
|
|
5945
7861
|
REFERENCES slotlock.reservations (id, tenant_ref) ON DELETE CASCADE
|
|
5946
7862
|
);
|
|
7863
|
+
-- A reminder said is kept by when it was due, not by its place in the list: one added, removed or
|
|
7864
|
+
-- reordered (so every place has another time) says nothing again that was said, and a booking or
|
|
7865
|
+
-- occurrence moved has new times, said again. Reminders due at the same time are said once. Kept by
|
|
7866
|
+
-- place before, so the key moves, each time said kept once.
|
|
7867
|
+
DO $slotlock_reminder_dispatch_keys$
|
|
7868
|
+
DECLARE
|
|
7869
|
+
dispatches text;
|
|
7870
|
+
kept_by_place text;
|
|
7871
|
+
BEGIN
|
|
7872
|
+
FOREACH dispatches IN ARRAY ARRAY['reminder_dispatches', 'reservation_reminder_dispatches'] LOOP
|
|
7873
|
+
SELECT key.conname INTO kept_by_place
|
|
7874
|
+
FROM pg_catalog.pg_constraint key
|
|
7875
|
+
WHERE key.conrelid = pg_catalog.format('slotlock.%I', dispatches)::regclass AND key.contype = 'p'
|
|
7876
|
+
AND EXISTS (SELECT 1 FROM pg_catalog.pg_attribute column_
|
|
7877
|
+
WHERE column_.attrelid = key.conrelid AND column_.attnum = ANY (key.conkey)
|
|
7878
|
+
AND column_.attname = 'ordinal');
|
|
7879
|
+
IF kept_by_place IS NOT NULL THEN
|
|
7880
|
+
IF dispatches = 'reminder_dispatches' THEN
|
|
7881
|
+
DELETE FROM slotlock.reminder_dispatches sent USING slotlock.reminder_dispatches other
|
|
7882
|
+
WHERE other.event_id = sent.event_id AND other.recurrence_id = sent.recurrence_id
|
|
7883
|
+
AND other.due_at = sent.due_at AND other.ordinal < sent.ordinal;
|
|
7884
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.reminder_dispatches DROP CONSTRAINT %I', kept_by_place);
|
|
7885
|
+
ALTER TABLE slotlock.reminder_dispatches ADD PRIMARY KEY (event_id, recurrence_id, due_at);
|
|
7886
|
+
ELSE
|
|
7887
|
+
DELETE FROM slotlock.reservation_reminder_dispatches sent USING slotlock.reservation_reminder_dispatches other
|
|
7888
|
+
WHERE other.reservation_id = sent.reservation_id AND other.due_at = sent.due_at AND other.ordinal < sent.ordinal;
|
|
7889
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.reservation_reminder_dispatches DROP CONSTRAINT %I', kept_by_place);
|
|
7890
|
+
ALTER TABLE slotlock.reservation_reminder_dispatches ADD PRIMARY KEY (reservation_id, due_at);
|
|
7891
|
+
END IF;
|
|
7892
|
+
END IF;
|
|
7893
|
+
END LOOP;
|
|
7894
|
+
END
|
|
7895
|
+
$slotlock_reminder_dispatch_keys$;
|
|
5947
7896
|
CREATE INDEX IF NOT EXISTS slotlock_reservation_reminder_dispatches_tenant_idx
|
|
5948
7897
|
ON slotlock.reservation_reminder_dispatches (tenant_ref, due_at);
|
|
5949
7898
|
|
|
@@ -5967,6 +7916,24 @@ CREATE TABLE IF NOT EXISTS slotlock.hold_payments (
|
|
|
5967
7916
|
CONSTRAINT slotlock_hold_payments_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
|
|
5968
7917
|
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE
|
|
5969
7918
|
);
|
|
7919
|
+
-- Whether a paid hold was booked (RFC 0004 C3): confirmed, or booked from by a write that named it
|
|
7920
|
+
-- (booked_at), or taken over by an approval (approval_id) and so booked as that approval goes. A
|
|
7921
|
+
-- payment whose hold ended any other way was never applied: markHoldPaid answers it hold_ended, and
|
|
7922
|
+
-- the feed's hold.expired or hold.released says so (payment_unapplied), for the payment to be
|
|
7923
|
+
-- refunded. Payments recorded before this was kept are taken as applied: none is refunded unasked.
|
|
7924
|
+
DO $slotlock_hold_payments_outcome$
|
|
7925
|
+
BEGIN
|
|
7926
|
+
IF NOT EXISTS (
|
|
7927
|
+
SELECT 1 FROM information_schema.columns
|
|
7928
|
+
WHERE table_schema = 'slotlock' AND table_name = 'hold_payments' AND column_name = 'booked_at'
|
|
7929
|
+
) THEN
|
|
7930
|
+
ALTER TABLE slotlock.hold_payments ADD COLUMN booked_at timestamptz, ADD COLUMN approval_id uuid;
|
|
7931
|
+
UPDATE slotlock.hold_payments payment SET booked_at = payment.paid_at
|
|
7932
|
+
WHERE NOT EXISTS (SELECT 1 FROM slotlock.reservations hold
|
|
7933
|
+
WHERE hold.id = payment.hold_id AND hold.status = 'held');
|
|
7934
|
+
END IF;
|
|
7935
|
+
END
|
|
7936
|
+
$slotlock_hold_payments_outcome$;
|
|
5970
7937
|
-- When each channel's consent was last given or withdrawn, as proof of it.
|
|
5971
7938
|
ALTER TABLE slotlock.contacts ADD COLUMN IF NOT EXISTS consent_at jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
5972
7939
|
|
|
@@ -5990,6 +7957,56 @@ CREATE INDEX IF NOT EXISTS slotlock_source_busy_resource_idx
|
|
|
5990
7957
|
ON slotlock.source_busy (resource_id, starts_at, ends_at);
|
|
5991
7958
|
CREATE INDEX IF NOT EXISTS slotlock_source_busy_source_idx
|
|
5992
7959
|
ON slotlock.source_busy (resource_id, source, starts_at);
|
|
7960
|
+
-- Busy time that ended long ago goes in retention (pruneBookingRetention), tenant by tenant.
|
|
7961
|
+
CREATE INDEX IF NOT EXISTS slotlock_source_busy_ended_idx
|
|
7962
|
+
ON slotlock.source_busy (tenant_ref, ends_at);
|
|
7963
|
+
|
|
7964
|
+
-- A copy of everything a tenant keeps, for it to download (RFC 0004 D3, POST /v1/tenant/exports):
|
|
7965
|
+
-- read in one snapshot into parts of NDJSON, one table's rows to a part. Kept for
|
|
7966
|
+
-- SLOTLOCK_TENANT_EXPORT_HOURS once it is ready (or since it was asked for, if it never is), then
|
|
7967
|
+
-- deleted by maintenance; at once when the tenant deletes it or is erased.
|
|
7968
|
+
CREATE TABLE IF NOT EXISTS slotlock.tenant_exports (
|
|
7969
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7970
|
+
tenant_ref text NOT NULL,
|
|
7971
|
+
status text NOT NULL DEFAULT 'pending',
|
|
7972
|
+
requested_by text,
|
|
7973
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7974
|
+
finished_at timestamptz,
|
|
7975
|
+
expires_at timestamptz NOT NULL,
|
|
7976
|
+
parts integer NOT NULL DEFAULT 0,
|
|
7977
|
+
row_count bigint NOT NULL DEFAULT 0,
|
|
7978
|
+
bytes bigint NOT NULL DEFAULT 0,
|
|
7979
|
+
tables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
7980
|
+
error text,
|
|
7981
|
+
CONSTRAINT slotlock_tenant_exports_tenant_key UNIQUE (id, tenant_ref),
|
|
7982
|
+
CONSTRAINT slotlock_tenant_exports_valid CHECK (
|
|
7983
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
7984
|
+
AND status IN ('pending', 'ready', 'failed')
|
|
7985
|
+
AND (requested_by IS NULL OR octet_length(requested_by) BETWEEN 1 AND 200)
|
|
7986
|
+
AND (error IS NULL OR octet_length(error) BETWEEN 1 AND 200)
|
|
7987
|
+
AND parts >= 0 AND row_count >= 0 AND bytes >= 0
|
|
7988
|
+
AND jsonb_typeof(tables) = 'object'
|
|
7989
|
+
)
|
|
7990
|
+
);
|
|
7991
|
+
CREATE INDEX IF NOT EXISTS slotlock_tenant_exports_tenant_idx
|
|
7992
|
+
ON slotlock.tenant_exports (tenant_ref, created_at);
|
|
7993
|
+
CREATE INDEX IF NOT EXISTS slotlock_tenant_exports_expiry_idx
|
|
7994
|
+
ON slotlock.tenant_exports (tenant_ref, expires_at);
|
|
7995
|
+
CREATE TABLE IF NOT EXISTS slotlock.tenant_export_parts (
|
|
7996
|
+
export_id uuid NOT NULL,
|
|
7997
|
+
tenant_ref text NOT NULL,
|
|
7998
|
+
part integer NOT NULL,
|
|
7999
|
+
table_name text NOT NULL,
|
|
8000
|
+
row_count integer NOT NULL,
|
|
8001
|
+
content text NOT NULL,
|
|
8002
|
+
CONSTRAINT slotlock_tenant_export_parts_pkey PRIMARY KEY (export_id, part),
|
|
8003
|
+
CONSTRAINT slotlock_tenant_export_parts_export_fkey FOREIGN KEY (export_id, tenant_ref)
|
|
8004
|
+
REFERENCES slotlock.tenant_exports (id, tenant_ref) ON DELETE CASCADE,
|
|
8005
|
+
CONSTRAINT slotlock_tenant_export_parts_valid CHECK (
|
|
8006
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
8007
|
+
AND part >= 1 AND table_name ~ '^[a-z_]{1,63}$' AND row_count >= 1
|
|
8008
|
+
)
|
|
8009
|
+
);
|
|
5993
8010
|
|
|
5994
8011
|
-- A cancelled UID remains terminal. This blocks delayed create delivery after the event row and
|
|
5995
8012
|
-- its derived reservations have been removed.
|
|
@@ -6205,7 +8222,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
6205
8222
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
6206
8223
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
6207
8224
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
6208
|
-
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_PARTNER_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
8225
|
+
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_PARTNER_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_IDENTITY_DDL}${SLOTLOCK_SSO_DDL}${SLOTLOCK_SCIM_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
6209
8226
|
/**
|
|
6210
8227
|
* Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
|
|
6211
8228
|
* or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
|
|
@@ -6336,6 +8353,7 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
6336
8353
|
'assistant_settings',
|
|
6337
8354
|
'assistant_conversations',
|
|
6338
8355
|
'assistant_chats',
|
|
8356
|
+
'audit_events',
|
|
6339
8357
|
'locations',
|
|
6340
8358
|
'resource_groups',
|
|
6341
8359
|
'resource_group_members',
|
|
@@ -6351,6 +8369,8 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
6351
8369
|
'reservation_reminder_dispatches',
|
|
6352
8370
|
'hold_payments',
|
|
6353
8371
|
'source_busy',
|
|
8372
|
+
'tenant_exports',
|
|
8373
|
+
'tenant_export_parts',
|
|
6354
8374
|
]);
|
|
6355
8375
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
6356
8376
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -6366,9 +8386,15 @@ export function createSlotlockApplicationRoleGrantsDdl(role) {
|
|
|
6366
8386
|
if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
|
|
6367
8387
|
throw Object.assign(new Error('Slotlock application role must be a lowercase, non-reserved PostgreSQL role name'), { code: 'invalid_role_name' });
|
|
6368
8388
|
}
|
|
6369
|
-
const
|
|
8389
|
+
const appendOnly = SLOTLOCK_APPEND_ONLY_TABLES;
|
|
8390
|
+
const tables = SLOTLOCK_TENANT_TABLES.filter((table) => !appendOnly.includes(table))
|
|
8391
|
+
.map((table) => `slotlock.${table}`)
|
|
8392
|
+
.join(', ');
|
|
8393
|
+
const added = SLOTLOCK_APPEND_ONLY_TABLES.map((table) => `slotlock.${table}`).join(', ');
|
|
6370
8394
|
return `GRANT USAGE ON SCHEMA slotlock TO "${role}";
|
|
6371
8395
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ${tables} TO "${role}";
|
|
8396
|
+
GRANT SELECT, INSERT ON ${added} TO "${role}";
|
|
8397
|
+
REVOKE UPDATE, DELETE ON ${added} FROM "${role}";
|
|
6372
8398
|
GRANT EXECUTE ON FUNCTION ${SLOTLOCK_DEFINER_FUNCTIONS.join(', ')} TO "${role}";
|
|
6373
8399
|
`;
|
|
6374
8400
|
}
|
|
@@ -6471,6 +8497,8 @@ ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
|
|
|
6471
8497
|
ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
|
|
6472
8498
|
ALTER TABLE slotlock.assistant_chats ENABLE ROW LEVEL SECURITY;
|
|
6473
8499
|
ALTER TABLE slotlock.assistant_chats FORCE ROW LEVEL SECURITY;
|
|
8500
|
+
ALTER TABLE slotlock.audit_events ENABLE ROW LEVEL SECURITY;
|
|
8501
|
+
ALTER TABLE slotlock.audit_events FORCE ROW LEVEL SECURITY;
|
|
6474
8502
|
ALTER TABLE slotlock.locations ENABLE ROW LEVEL SECURITY;
|
|
6475
8503
|
ALTER TABLE slotlock.locations FORCE ROW LEVEL SECURITY;
|
|
6476
8504
|
ALTER TABLE slotlock.resource_groups ENABLE ROW LEVEL SECURITY;
|
|
@@ -6501,6 +8529,10 @@ ALTER TABLE slotlock.holiday_calendars ENABLE ROW LEVEL SECURITY;
|
|
|
6501
8529
|
ALTER TABLE slotlock.holiday_calendars FORCE ROW LEVEL SECURITY;
|
|
6502
8530
|
ALTER TABLE slotlock.source_busy ENABLE ROW LEVEL SECURITY;
|
|
6503
8531
|
ALTER TABLE slotlock.source_busy FORCE ROW LEVEL SECURITY;
|
|
8532
|
+
ALTER TABLE slotlock.tenant_exports ENABLE ROW LEVEL SECURITY;
|
|
8533
|
+
ALTER TABLE slotlock.tenant_exports FORCE ROW LEVEL SECURITY;
|
|
8534
|
+
ALTER TABLE slotlock.tenant_export_parts ENABLE ROW LEVEL SECURITY;
|
|
8535
|
+
ALTER TABLE slotlock.tenant_export_parts FORCE ROW LEVEL SECURITY;
|
|
6504
8536
|
|
|
6505
8537
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
6506
8538
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -6534,6 +8566,7 @@ BEGIN
|
|
|
6534
8566
|
'assistant_settings',
|
|
6535
8567
|
'assistant_conversations',
|
|
6536
8568
|
'assistant_chats',
|
|
8569
|
+
'audit_events',
|
|
6537
8570
|
'locations',
|
|
6538
8571
|
'resource_groups',
|
|
6539
8572
|
'resource_group_members',
|
|
@@ -6548,7 +8581,9 @@ BEGIN
|
|
|
6548
8581
|
'reminder_dispatches',
|
|
6549
8582
|
'reservation_reminder_dispatches',
|
|
6550
8583
|
'hold_payments',
|
|
6551
|
-
'source_busy'
|
|
8584
|
+
'source_busy',
|
|
8585
|
+
'tenant_exports',
|
|
8586
|
+
'tenant_export_parts'
|
|
6552
8587
|
)
|
|
6553
8588
|
LOOP
|
|
6554
8589
|
EXECUTE format(
|
|
@@ -6800,6 +8835,16 @@ BEGIN
|
|
|
6800
8835
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6801
8836
|
END IF;
|
|
6802
8837
|
|
|
8838
|
+
IF NOT EXISTS (
|
|
8839
|
+
SELECT 1 FROM pg_policies
|
|
8840
|
+
WHERE schemaname = 'slotlock' AND tablename = 'audit_events'
|
|
8841
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8842
|
+
) THEN
|
|
8843
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.audit_events
|
|
8844
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8845
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8846
|
+
END IF;
|
|
8847
|
+
|
|
6803
8848
|
IF NOT EXISTS (
|
|
6804
8849
|
SELECT 1 FROM pg_policies
|
|
6805
8850
|
WHERE schemaname = 'slotlock' AND tablename = 'locations'
|
|
@@ -6949,6 +8994,26 @@ BEGIN
|
|
|
6949
8994
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6950
8995
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6951
8996
|
END IF;
|
|
8997
|
+
|
|
8998
|
+
IF NOT EXISTS (
|
|
8999
|
+
SELECT 1 FROM pg_policies
|
|
9000
|
+
WHERE schemaname = 'slotlock' AND tablename = 'tenant_exports'
|
|
9001
|
+
AND policyname = 'tenant_isolation_v1'
|
|
9002
|
+
) THEN
|
|
9003
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.tenant_exports
|
|
9004
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
9005
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
9006
|
+
END IF;
|
|
9007
|
+
|
|
9008
|
+
IF NOT EXISTS (
|
|
9009
|
+
SELECT 1 FROM pg_policies
|
|
9010
|
+
WHERE schemaname = 'slotlock' AND tablename = 'tenant_export_parts'
|
|
9011
|
+
AND policyname = 'tenant_isolation_v1'
|
|
9012
|
+
) THEN
|
|
9013
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.tenant_export_parts
|
|
9014
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
9015
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
9016
|
+
END IF;
|
|
6952
9017
|
END
|
|
6953
9018
|
$slotlock_rls$;
|
|
6954
9019
|
|
|
@@ -6994,6 +9059,8 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
|
6994
9059
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6995
9060
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_chats
|
|
6996
9061
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9062
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.audit_events
|
|
9063
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6997
9064
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.locations
|
|
6998
9065
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6999
9066
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_groups
|
|
@@ -7024,6 +9091,10 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
|
|
|
7024
9091
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
7025
9092
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.source_busy
|
|
7026
9093
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9094
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.tenant_exports
|
|
9095
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9096
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.tenant_export_parts
|
|
9097
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
7027
9098
|
${SLOTLOCK_RESOURCE_SCOPE_POLICIES}
|
|
7028
9099
|
|
|
7029
9100
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
@@ -7069,6 +9140,7 @@ WHERE schemaname = 'slotlock'
|
|
|
7069
9140
|
'assistant_settings',
|
|
7070
9141
|
'assistant_conversations',
|
|
7071
9142
|
'assistant_chats',
|
|
9143
|
+
'audit_events',
|
|
7072
9144
|
'locations',
|
|
7073
9145
|
'resource_groups',
|
|
7074
9146
|
'resource_group_members',
|
|
@@ -7083,7 +9155,9 @@ WHERE schemaname = 'slotlock'
|
|
|
7083
9155
|
'reminder_dispatches',
|
|
7084
9156
|
'reservation_reminder_dispatches',
|
|
7085
9157
|
'hold_payments',
|
|
7086
|
-
'source_busy'
|
|
9158
|
+
'source_busy',
|
|
9159
|
+
'tenant_exports',
|
|
9160
|
+
'tenant_export_parts'
|
|
7087
9161
|
)
|
|
7088
9162
|
AND policyname = 'tenant_isolation_v1'
|
|
7089
9163
|
ON CONFLICT (table_name) DO UPDATE
|