slotlock 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
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([
|
|
@@ -292,6 +372,10 @@ export const SLOTLOCK_QUOTA_FUNCTIONS = Object.freeze([
|
|
|
292
372
|
]);
|
|
293
373
|
/** What a plan's monthly quotas count (quotas.ts): operations run and bookings made. */
|
|
294
374
|
export const SLOTLOCK_QUOTA_METRICS = Object.freeze(['operations', 'bookings']);
|
|
375
|
+
/** The most conversations a member keeps with the dashboard assistant: past it, the least recent goes. */
|
|
376
|
+
export const SLOTLOCK_ASSISTANT_MAX_CHATS = 100;
|
|
377
|
+
/** How a conversation with the assistant is named: 16 URL-safe characters. */
|
|
378
|
+
export const SLOTLOCK_ASSISTANT_CHAT_ID = /^[A-Za-z0-9_-]{16}$/;
|
|
295
379
|
/** The shares of a quota, in percent, at which a tenant is told once a month (usage.threshold_reached). */
|
|
296
380
|
export const SLOTLOCK_QUOTA_NOTICE_PERCENTS = Object.freeze([80, 100]);
|
|
297
381
|
/** The most bookings past the roll-up cursor one quota read counts: more than any plan allows. */
|
|
@@ -361,6 +445,13 @@ export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
|
361
445
|
...SLOTLOCK_SCHEMA_FUNCTIONS,
|
|
362
446
|
...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
|
|
363
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']);
|
|
364
455
|
/** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
|
|
365
456
|
export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
|
|
366
457
|
...SLOTLOCK_API_KEY_TABLES,
|
|
@@ -1048,52 +1139,1403 @@ LANGUAGE sql
|
|
|
1048
1139
|
STABLE
|
|
1049
1140
|
SECURITY DEFINER
|
|
1050
1141
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1051
|
-
AS $slotlock_enabled_organizations$
|
|
1052
|
-
SELECT org.org_id FROM slotlock.dashboard_organizations org
|
|
1053
|
-
WHERE org.enabled
|
|
1054
|
-
AND org.org_id = ANY (pg_catalog.string_to_array(requested_ids, ','));
|
|
1055
|
-
$slotlock_enabled_organizations$;
|
|
1056
|
-
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[2]} FROM PUBLIC;
|
|
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
|
|
2383
|
+
SECURITY DEFINER
|
|
2384
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
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;
|
|
1057
2396
|
|
|
1058
|
-
--
|
|
1059
|
-
--
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
1062
2425
|
LANGUAGE plpgsql
|
|
1063
2426
|
VOLATILE
|
|
1064
2427
|
SECURITY DEFINER
|
|
1065
2428
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1066
|
-
AS $
|
|
2429
|
+
AS $slotlock_schedule_team_deletion$
|
|
1067
2430
|
DECLARE
|
|
1068
|
-
|
|
2431
|
+
team_handle text;
|
|
1069
2432
|
BEGIN
|
|
1070
|
-
SELECT
|
|
1071
|
-
WHERE
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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;
|
|
1079
2453
|
END
|
|
1080
|
-
$
|
|
1081
|
-
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[
|
|
2454
|
+
$slotlock_schedule_team_deletion$;
|
|
2455
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[30]} FROM PUBLIC;
|
|
1082
2456
|
|
|
1083
|
-
--
|
|
1084
|
-
CREATE OR REPLACE FUNCTION slotlock.
|
|
1085
|
-
RETURNS
|
|
1086
|
-
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
|
|
1087
2461
|
VOLATILE
|
|
1088
2462
|
SECURITY DEFINER
|
|
1089
2463
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1090
|
-
AS $
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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;
|
|
1097
2539
|
`;
|
|
1098
2540
|
// The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
|
|
1099
2541
|
// is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
|
|
@@ -1893,6 +3335,9 @@ DECLARE
|
|
|
1893
3335
|
setting text;
|
|
1894
3336
|
previous jsonb := '{}'::jsonb;
|
|
1895
3337
|
tested record;
|
|
3338
|
+
cleared_rows bigint;
|
|
3339
|
+
sso_people text[] := ARRAY[]::text[];
|
|
3340
|
+
departing text;
|
|
1896
3341
|
BEGIN
|
|
1897
3342
|
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
|
|
1898
3343
|
RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
|
|
@@ -1910,6 +3355,26 @@ BEGIN
|
|
|
1910
3355
|
RAISE EXCEPTION 'Slotlock tenant has a live subscription: cancel it before erasing the tenant'
|
|
1911
3356
|
USING ERRCODE = 'object_in_use', HINT = 'billing_active';
|
|
1912
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;
|
|
1913
3378
|
FOR setting IN
|
|
1914
3379
|
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
1915
3380
|
LOOP
|
|
@@ -1948,6 +3413,46 @@ BEGIN
|
|
|
1948
3413
|
DELETE FROM slotlock.approval_policies WHERE tenant_ref = requested_tenant_ref;
|
|
1949
3414
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
1950
3415
|
RETURN NEXT;
|
|
3416
|
+
erased_table := 'availability_exceptions';
|
|
3417
|
+
DELETE FROM slotlock.availability_exceptions WHERE tenant_ref = requested_tenant_ref;
|
|
3418
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3419
|
+
RETURN NEXT;
|
|
3420
|
+
erased_table := 'holiday_calendars';
|
|
3421
|
+
DELETE FROM slotlock.holiday_calendars WHERE tenant_ref = requested_tenant_ref;
|
|
3422
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3423
|
+
RETURN NEXT;
|
|
3424
|
+
erased_table := 'availability_defaults';
|
|
3425
|
+
DELETE FROM slotlock.availability_defaults WHERE tenant_ref = requested_tenant_ref;
|
|
3426
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3427
|
+
RETURN NEXT;
|
|
3428
|
+
erased_table := 'contacts';
|
|
3429
|
+
DELETE FROM slotlock.contacts WHERE tenant_ref = requested_tenant_ref;
|
|
3430
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3431
|
+
RETURN NEXT;
|
|
3432
|
+
erased_table := 'reminder_dispatches';
|
|
3433
|
+
DELETE FROM slotlock.reminder_dispatches WHERE tenant_ref = requested_tenant_ref;
|
|
3434
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3435
|
+
RETURN NEXT;
|
|
3436
|
+
erased_table := 'reservation_reminder_dispatches';
|
|
3437
|
+
DELETE FROM slotlock.reservation_reminder_dispatches WHERE tenant_ref = requested_tenant_ref;
|
|
3438
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3439
|
+
RETURN NEXT;
|
|
3440
|
+
erased_table := 'hold_payments';
|
|
3441
|
+
DELETE FROM slotlock.hold_payments WHERE tenant_ref = requested_tenant_ref;
|
|
3442
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
3443
|
+
RETURN NEXT;
|
|
3444
|
+
erased_table := 'source_busy';
|
|
3445
|
+
DELETE FROM slotlock.source_busy WHERE tenant_ref = requested_tenant_ref;
|
|
3446
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
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;
|
|
1951
3456
|
erased_table := 'reservations';
|
|
1952
3457
|
DELETE FROM slotlock.reservations
|
|
1953
3458
|
WHERE tenant_ref = requested_tenant_ref
|
|
@@ -2031,6 +3536,14 @@ BEGIN
|
|
|
2031
3536
|
DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
|
|
2032
3537
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2033
3538
|
RETURN NEXT;
|
|
3539
|
+
erased_table := 'assistant_chats';
|
|
3540
|
+
DELETE FROM slotlock.assistant_chats WHERE tenant_ref = requested_tenant_ref;
|
|
3541
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
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;
|
|
2034
3547
|
erased_table := 'assistant_settings';
|
|
2035
3548
|
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
|
|
2036
3549
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -2113,6 +3626,124 @@ BEGIN
|
|
|
2113
3626
|
)
|
|
2114
3627
|
SELECT count(*) INTO erased_rows FROM erased;
|
|
2115
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;
|
|
2116
3747
|
-- An organization's record of being turned on goes with its tenant (Teams on Cloud).
|
|
2117
3748
|
erased_table := 'dashboard_organizations';
|
|
2118
3749
|
DELETE FROM slotlock.dashboard_organizations org
|
|
@@ -2154,6 +3785,22 @@ BEGIN
|
|
|
2154
3785
|
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
2155
3786
|
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
2156
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;
|
|
2157
3804
|
END
|
|
2158
3805
|
$slotlock_erase_tenant$;
|
|
2159
3806
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
|
|
@@ -2199,6 +3846,9 @@ BEGIN
|
|
|
2199
3846
|
FOREACH emptied IN ARRAY ARRAY[
|
|
2200
3847
|
'calendar_event_attendees', 'calendar_event_reminders', 'calendar_event_exceptions',
|
|
2201
3848
|
'calendar_event_occurrences', 'composite_claims', 'approvals', 'approval_policies',
|
|
3849
|
+
'availability_exceptions', 'holiday_calendars', 'availability_defaults', 'contacts', 'reminder_dispatches',
|
|
3850
|
+
'reservation_reminder_dispatches',
|
|
3851
|
+
'hold_payments', 'source_busy', 'tenant_export_parts', 'tenant_exports',
|
|
2202
3852
|
'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
|
|
2203
3853
|
'reservation_tombstones', 'reservation_conflict_archive', 'calendar_coverage', 'hold_commands',
|
|
2204
3854
|
'waitlist_entries', 'booking_rule_defaults', 'pool_settings', 'resource_group_members',
|
|
@@ -2407,8 +4057,21 @@ BEGIN
|
|
|
2407
4057
|
AND gone.expires_at <= now();
|
|
2408
4058
|
GET DIAGNOSTICS step = ROW_COUNT;
|
|
2409
4059
|
deleted := deleted + step;
|
|
2410
|
-
|
|
2411
|
-
WHERE
|
|
4060
|
+
DELETE FROM slotlock.assistant_chats gone
|
|
4061
|
+
WHERE gone.tenant_ref = due.tenant_ref
|
|
4062
|
+
AND (gone.member_ref, gone.chat_id) IN (
|
|
4063
|
+
SELECT expired.member_ref, expired.chat_id FROM slotlock.assistant_chats expired
|
|
4064
|
+
WHERE expired.tenant_ref = due.tenant_ref AND expired.expires_at <= now()
|
|
4065
|
+
LIMIT greatest(max_rows - deleted, 0))
|
|
4066
|
+
AND gone.expires_at <= now();
|
|
4067
|
+
GET DIAGNOSTICS step = ROW_COUNT;
|
|
4068
|
+
deleted := deleted + step;
|
|
4069
|
+
SELECT least(
|
|
4070
|
+
(SELECT min(live.expires_at) FROM slotlock.assistant_conversations live
|
|
4071
|
+
WHERE live.tenant_ref = due.tenant_ref),
|
|
4072
|
+
(SELECT min(chat.expires_at) FROM slotlock.assistant_chats chat
|
|
4073
|
+
WHERE chat.tenant_ref = due.tenant_ref AND chat.expires_at IS NOT NULL))
|
|
4074
|
+
INTO upcoming;
|
|
2412
4075
|
IF upcoming IS NULL THEN
|
|
2413
4076
|
DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = due.tenant_ref;
|
|
2414
4077
|
ELSE
|
|
@@ -2750,7 +4413,7 @@ AS $slotlock_location_path$
|
|
|
2750
4413
|
FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
|
|
2751
4414
|
WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
2752
4415
|
)
|
|
2753
|
-
SELECT COALESCE(array_agg(up.ref), '{}'::text[]) FROM up;
|
|
4416
|
+
SELECT COALESCE(array_agg(up.ref ORDER BY up.steps), '{}'::text[]) FROM up;
|
|
2754
4417
|
$slotlock_location_path$;
|
|
2755
4418
|
-- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
|
|
2756
4419
|
-- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
|
|
@@ -2821,6 +4484,78 @@ END
|
|
|
2821
4484
|
$slotlock_waitlist_key$;
|
|
2822
4485
|
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_waitlist_entries_key_idx
|
|
2823
4486
|
ON slotlock.waitlist_entries (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
4487
|
+
-- Waitlists that match (RFC 0004 B6): an entry may ask for any slot of a length within its window
|
|
4488
|
+
-- (duration_ms; NULL is the whole window), and an offer ends accepted (its hold confirmed), declined
|
|
4489
|
+
-- (let go) or lapsed (expired), when it was decided. The wider checks admit every row the old ones
|
|
4490
|
+
-- did, so they are added NOT VALID (no scan under the lock), and the old ones dropped.
|
|
4491
|
+
ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS duration_ms bigint;
|
|
4492
|
+
ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS decided_at timestamptz;
|
|
4493
|
+
DO $slotlock_waitlist_outcomes$
|
|
4494
|
+
BEGIN
|
|
4495
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_waitlist_entries_outcome_valid'
|
|
4496
|
+
AND conrelid = 'slotlock.waitlist_entries'::regclass) THEN
|
|
4497
|
+
ALTER TABLE slotlock.waitlist_entries ADD CONSTRAINT slotlock_waitlist_entries_outcome_valid CHECK (
|
|
4498
|
+
status IN ('waiting', 'offered', 'accepted', 'declined', 'lapsed', 'expired')
|
|
4499
|
+
AND (status <> 'offered' OR (hold_id IS NOT NULL AND offered_at IS NOT NULL AND offered_resource_id IS NOT NULL))
|
|
4500
|
+
AND (status <> 'waiting' OR (hold_id IS NULL AND offered_at IS NULL))
|
|
4501
|
+
AND (duration_ms IS NULL OR (duration_ms >= 60000
|
|
4502
|
+
AND duration_ms <= extract(epoch FROM ends_at - starts_at) * 1000))
|
|
4503
|
+
) NOT VALID;
|
|
4504
|
+
END IF;
|
|
4505
|
+
ALTER TABLE slotlock.waitlist_entries DROP CONSTRAINT IF EXISTS slotlock_waitlist_entries_status_valid;
|
|
4506
|
+
ALTER TABLE slotlock.waitlist_entries DROP CONSTRAINT IF EXISTS slotlock_waitlist_entries_offer_valid;
|
|
4507
|
+
END
|
|
4508
|
+
$slotlock_waitlist_outcomes$;
|
|
4509
|
+
|
|
4510
|
+
-- How an offer ends (RFC 0004 B6), from what becomes of its hold, whatever path took it: confirmed
|
|
4511
|
+
-- (or consumed by a booking that names it, under slotlock.ending = 'confirmed') is accepted;
|
|
4512
|
+
-- expired is lapsed; let go before then is declined. The entry says so, and the feed.
|
|
4513
|
+
CREATE OR REPLACE FUNCTION slotlock.waitlist_offer_settle()
|
|
4514
|
+
RETURNS trigger
|
|
4515
|
+
LANGUAGE plpgsql
|
|
4516
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4517
|
+
AS $slotlock_waitlist_offer_settle$
|
|
4518
|
+
DECLARE
|
|
4519
|
+
outcome text;
|
|
4520
|
+
settled slotlock.waitlist_entries%ROWTYPE;
|
|
4521
|
+
BEGIN
|
|
4522
|
+
IF OLD.status <> 'held' OR OLD.source IS DISTINCT FROM 'slotlock:waitlist' THEN RETURN NULL; END IF;
|
|
4523
|
+
IF TG_OP = 'UPDATE' THEN
|
|
4524
|
+
IF NEW.status = 'held' THEN RETURN NULL; END IF;
|
|
4525
|
+
outcome := 'accepted';
|
|
4526
|
+
ELSIF current_setting('slotlock.ending', true) = 'confirmed' THEN
|
|
4527
|
+
outcome := 'accepted';
|
|
4528
|
+
ELSIF OLD.expires_at <= statement_timestamp() THEN
|
|
4529
|
+
outcome := 'lapsed';
|
|
4530
|
+
ELSE
|
|
4531
|
+
outcome := 'declined';
|
|
4532
|
+
END IF;
|
|
4533
|
+
UPDATE slotlock.waitlist_entries SET status = outcome, decided_at = statement_timestamp()
|
|
4534
|
+
WHERE hold_id = OLD.id AND status = 'offered'
|
|
4535
|
+
RETURNING * INTO settled;
|
|
4536
|
+
IF settled.id IS NULL THEN RETURN NULL; END IF;
|
|
4537
|
+
-- Erasure (erase_tenant, running as the schema's owner) is not reported.
|
|
4538
|
+
IF settled.tenant_ref = current_setting('slotlock.erasing', true)
|
|
4539
|
+
AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
|
|
4540
|
+
WHERE nspname = 'slotlock') THEN
|
|
4541
|
+
RETURN NULL;
|
|
4542
|
+
END IF;
|
|
4543
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
4544
|
+
VALUES (
|
|
4545
|
+
settled.tenant_ref, settled.offered_resource_id, 'waitlist.' || outcome, settled.id::text,
|
|
4546
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
4547
|
+
'id', settled.id, 'hold_id', settled.hold_id, 'resource_id', settled.offered_resource_id,
|
|
4548
|
+
'pool', settled.pool_ref, 'holder_ref', settled.holder_ref)),
|
|
4549
|
+
COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
|
|
4550
|
+
CASE WHEN outcome = 'lapsed' THEN 'slotlock:maintenance' END)
|
|
4551
|
+
);
|
|
4552
|
+
RETURN NULL;
|
|
4553
|
+
END
|
|
4554
|
+
$slotlock_waitlist_offer_settle$;
|
|
4555
|
+
DROP TRIGGER IF EXISTS slotlock_waitlist_offer_settle ON slotlock.reservations;
|
|
4556
|
+
CREATE TRIGGER slotlock_waitlist_offer_settle
|
|
4557
|
+
AFTER UPDATE OF status OR DELETE ON slotlock.reservations
|
|
4558
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.waitlist_offer_settle();
|
|
2824
4559
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
|
|
2825
4560
|
ON slotlock.waitlist_entries (tenant_ref, starts_at);
|
|
2826
4561
|
|
|
@@ -2929,6 +4664,357 @@ CREATE TABLE IF NOT EXISTS slotlock.assistant_conversations (
|
|
|
2929
4664
|
CREATE INDEX IF NOT EXISTS slotlock_assistant_conversations_expiry_idx
|
|
2930
4665
|
ON slotlock.assistant_conversations (tenant_ref, expires_at);
|
|
2931
4666
|
|
|
4667
|
+
-- A member's conversations with the dashboard assistant (its history), each with the writes it
|
|
4668
|
+
-- proposed. Kept until the member deletes one, or until expires_at where the deployment gives
|
|
4669
|
+
-- conversations a lifetime; at most ${SLOTLOCK_ASSISTANT_MAX_CHATS} a member, the least recent going first.
|
|
4670
|
+
CREATE TABLE IF NOT EXISTS slotlock.assistant_chats (
|
|
4671
|
+
tenant_ref text NOT NULL,
|
|
4672
|
+
member_ref text NOT NULL,
|
|
4673
|
+
chat_id text NOT NULL,
|
|
4674
|
+
title text NOT NULL,
|
|
4675
|
+
messages jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
4676
|
+
actions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4677
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4678
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4679
|
+
expires_at timestamptz,
|
|
4680
|
+
CONSTRAINT slotlock_assistant_chats_pkey PRIMARY KEY (tenant_ref, member_ref, chat_id),
|
|
4681
|
+
CONSTRAINT slotlock_assistant_chats_identity_valid CHECK (
|
|
4682
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(member_ref) BETWEEN 1 AND 500
|
|
4683
|
+
AND chat_id ~ '^[A-Za-z0-9_-]{16}$'
|
|
4684
|
+
),
|
|
4685
|
+
CONSTRAINT slotlock_assistant_chats_title_valid CHECK (char_length(title) BETWEEN 1 AND 120),
|
|
4686
|
+
CONSTRAINT slotlock_assistant_chats_messages_valid CHECK (
|
|
4687
|
+
jsonb_typeof(messages) = 'array' AND octet_length(messages::text) <= 262144
|
|
4688
|
+
),
|
|
4689
|
+
CONSTRAINT slotlock_assistant_chats_actions_valid CHECK (
|
|
4690
|
+
jsonb_typeof(actions) = 'object' AND octet_length(actions::text) <= 131072
|
|
4691
|
+
)
|
|
4692
|
+
);
|
|
4693
|
+
CREATE INDEX IF NOT EXISTS slotlock_assistant_chats_recent_idx
|
|
4694
|
+
ON slotlock.assistant_chats (tenant_ref, member_ref, updated_at DESC);
|
|
4695
|
+
CREATE INDEX IF NOT EXISTS slotlock_assistant_chats_expiry_idx
|
|
4696
|
+
ON slotlock.assistant_chats (tenant_ref, expires_at) WHERE expires_at IS NOT NULL;
|
|
4697
|
+
|
|
4698
|
+
-- The one conversation a member had before chats were kept (0.8 and earlier) becomes their first
|
|
4699
|
+
-- chat, titled by its first message, and leaves the old table: a later migration does not bring
|
|
4700
|
+
-- back a chat the member deleted. A server of 0.8 still running meanwhile keeps using the old table;
|
|
4701
|
+
-- what it writes there is moved by the next migration as a chat of its own (its id is the
|
|
4702
|
+
-- conversation's, with when it was last written), never into one already moved.
|
|
4703
|
+
WITH moved AS (
|
|
4704
|
+
DELETE FROM slotlock.assistant_conversations old
|
|
4705
|
+
WHERE old.expires_at > statement_timestamp()
|
|
4706
|
+
RETURNING old.tenant_ref, old.member_ref, old.messages, old.actions, old.updated_at
|
|
4707
|
+
)
|
|
4708
|
+
INSERT INTO slotlock.assistant_chats AS chat
|
|
4709
|
+
(tenant_ref, member_ref, chat_id, title, messages, actions, created_at, updated_at, expires_at)
|
|
4710
|
+
SELECT moved.tenant_ref, moved.member_ref,
|
|
4711
|
+
pg_catalog.translate(pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
|
|
4712
|
+
moved.tenant_ref || pg_catalog.chr(10) || moved.member_ref || pg_catalog.chr(10)
|
|
4713
|
+
|| (extract(epoch FROM moved.updated_at) * 1000000)::bigint::text, 'UTF8')), 'base64'), 16), '+/', '-_'),
|
|
4714
|
+
coalesce(nullif(pg_catalog.left(pg_catalog.regexp_replace(pg_catalog.btrim(first_user.text), '\\s+', ' ', 'g'), 80), ''),
|
|
4715
|
+
'Conversation'),
|
|
4716
|
+
moved.messages, moved.actions, moved.updated_at, moved.updated_at, NULL
|
|
4717
|
+
FROM moved
|
|
4718
|
+
LEFT JOIN LATERAL (
|
|
4719
|
+
SELECT entry ->> 'text' AS text FROM pg_catalog.jsonb_array_elements(moved.messages) AS entry
|
|
4720
|
+
WHERE entry ->> 'role' = 'user' LIMIT 1
|
|
4721
|
+
) first_user ON true
|
|
4722
|
+
ON CONFLICT DO NOTHING;
|
|
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
|
+
|
|
2932
5018
|
-- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
|
|
2933
5019
|
CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
|
|
2934
5020
|
RETURNS trigger
|
|
@@ -2980,8 +5066,8 @@ BEGIN
|
|
|
2980
5066
|
END;
|
|
2981
5067
|
END IF;
|
|
2982
5068
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
2983
|
-
|
|
2984
|
-
owner, changed.resource_id,
|
|
5069
|
+
SELECT
|
|
5070
|
+
owner, changed.resource_id, written.change_type,
|
|
2985
5071
|
COALESCE(changed.external_ref, changed.id::text),
|
|
2986
5072
|
jsonb_strip_nulls(jsonb_build_object(
|
|
2987
5073
|
'id', changed.id,
|
|
@@ -2990,6 +5076,23 @@ BEGIN
|
|
|
2990
5076
|
'start', changed.starts_at,
|
|
2991
5077
|
'end', changed.ends_at,
|
|
2992
5078
|
'buffer_after_ms', changed.buffer_after_ms,
|
|
5079
|
+
-- Whom to tell and whether (RFC 0004 P3): a reference, never the person; notify only when off.
|
|
5080
|
+
'contact_ref', changed.contact_ref,
|
|
5081
|
+
'notify', CASE WHEN NOT changed.notify THEN false END,
|
|
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,
|
|
5092
|
+
-- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
|
|
5093
|
+
'cancellation_fee', CASE WHEN kind = 'reservation.cancelled'
|
|
5094
|
+
THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
|
|
5095
|
+
'override_reason', NULLIF(current_setting('slotlock.override_reason', true), ''),
|
|
2993
5096
|
-- A no-show carries the revision its reference is retired at, and the check-in window.
|
|
2994
5097
|
'revision', CASE WHEN changed.status = 'held' THEN NULL
|
|
2995
5098
|
WHEN kind = 'reservation.no_show' THEN changed.revision + 1
|
|
@@ -3003,7 +5106,13 @@ BEGIN
|
|
|
3003
5106
|
'revision', OLD.revision) END
|
|
3004
5107
|
)),
|
|
3005
5108
|
NULLIF(current_setting('slotlock.actor', true), '')
|
|
3006
|
-
|
|
5109
|
+
-- A booking moved in time or to another resource is also reservation.rescheduled (RFC 0004
|
|
5110
|
+
-- B4); reservation.updated stays for those who read it.
|
|
5111
|
+
FROM (VALUES (kind), (CASE WHEN kind = 'reservation.updated'
|
|
5112
|
+
AND (OLD.starts_at, OLD.ends_at, OLD.resource_id)
|
|
5113
|
+
IS DISTINCT FROM (NEW.starts_at, NEW.ends_at, NEW.resource_id)
|
|
5114
|
+
THEN 'reservation.rescheduled' END)) AS written(change_type)
|
|
5115
|
+
WHERE written.change_type IS NOT NULL;
|
|
3007
5116
|
RETURN NULL;
|
|
3008
5117
|
END
|
|
3009
5118
|
$slotlock_outbox_reservation$;
|
|
@@ -3047,6 +5156,11 @@ BEGIN
|
|
|
3047
5156
|
'transparency', changed.transparency,
|
|
3048
5157
|
'recurring', changed.recurrence_rule IS NOT NULL,
|
|
3049
5158
|
'revision', changed.revision,
|
|
5159
|
+
'notify', CASE WHEN NOT changed.notify THEN false END,
|
|
5160
|
+
-- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
|
|
5161
|
+
'cancellation_fee', CASE WHEN kind = 'event.cancelled'
|
|
5162
|
+
THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
|
|
5163
|
+
'override_reason', NULLIF(current_setting('slotlock.override_reason', true), ''),
|
|
3050
5164
|
'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
|
|
3051
5165
|
'resource_id', OLD.resource_id, 'start', OLD.starts_at, 'end', OLD.ends_at,
|
|
3052
5166
|
'status', OLD.status, 'transparency', OLD.transparency, 'revision', OLD.revision) END
|
|
@@ -3983,6 +6097,12 @@ CREATE INDEX IF NOT EXISTS slotlock_resources_labels_idx ON slotlock.resources U
|
|
|
3983
6097
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
3984
6098
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
3985
6099
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
6100
|
+
-- When maintenance said the booking ended (reservation.completed, RFC 0004 B4): it changes nothing
|
|
6101
|
+
-- anyone wrote, so the feed's trigger ignores it.
|
|
6102
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS completed_at timestamptz;
|
|
6103
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservations_uncompleted_idx
|
|
6104
|
+
ON slotlock.reservations (tenant_ref, ends_at)
|
|
6105
|
+
WHERE status = 'confirmed' AND completed_at IS NULL;
|
|
3986
6106
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
|
|
3987
6107
|
-- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
|
|
3988
6108
|
-- was made), and where its occupied time starts: its start less that buffer. A row written before
|
|
@@ -4078,6 +6198,31 @@ ALTER TABLE slotlock.reservation_tombstones ALTER COLUMN cancelled_at SET DEFAUL
|
|
|
4078
6198
|
-- are kept too.
|
|
4079
6199
|
ALTER TABLE slotlock.reservation_tombstones
|
|
4080
6200
|
ADD COLUMN IF NOT EXISTS stray boolean NOT NULL DEFAULT false;
|
|
6201
|
+
-- Why the booking ended (RFC 0004 B4): cancelled or no_show (a hold leaves no tombstone; released
|
|
6202
|
+
-- and expired are on the feed). Rows from before keep 'cancelled', as every one of them was. A late
|
|
6203
|
+
-- cancellation's fee (B5), recorded for the integrator to charge, and an admin's reason for going
|
|
6204
|
+
-- past a cutoff.
|
|
6205
|
+
ALTER TABLE slotlock.reservation_tombstones
|
|
6206
|
+
ADD COLUMN IF NOT EXISTS end_reason text NOT NULL DEFAULT 'cancelled';
|
|
6207
|
+
ALTER TABLE slotlock.reservation_tombstones ADD COLUMN IF NOT EXISTS cancellation_fee jsonb;
|
|
6208
|
+
ALTER TABLE slotlock.reservation_tombstones ADD COLUMN IF NOT EXISTS override_reason text;
|
|
6209
|
+
DO $slotlock_tombstone_end_reason$
|
|
6210
|
+
BEGIN
|
|
6211
|
+
IF NOT EXISTS (
|
|
6212
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
6213
|
+
WHERE conrelid = 'slotlock.reservation_tombstones'::regclass
|
|
6214
|
+
AND conname = 'slotlock_reservation_tombstones_end_valid'
|
|
6215
|
+
) THEN
|
|
6216
|
+
ALTER TABLE slotlock.reservation_tombstones
|
|
6217
|
+
ADD CONSTRAINT slotlock_reservation_tombstones_end_valid CHECK (
|
|
6218
|
+
end_reason IN ('cancelled', 'no_show', 'released', 'expired', 'erased')
|
|
6219
|
+
AND (cancellation_fee IS NULL OR (jsonb_typeof(cancellation_fee) = 'object'
|
|
6220
|
+
AND octet_length(cancellation_fee::text) <= 200))
|
|
6221
|
+
AND (override_reason IS NULL OR char_length(override_reason) BETWEEN 1 AND 500)
|
|
6222
|
+
);
|
|
6223
|
+
END IF;
|
|
6224
|
+
END
|
|
6225
|
+
$slotlock_tombstone_end_reason$;
|
|
4081
6226
|
-- Resource identity: one resource per tenant/external_ref namespace (plus one isolated NULL-tenant
|
|
4082
6227
|
-- legacy namespace). Dedupe first (repoint reservations to the keeper, drop the rest) so the
|
|
4083
6228
|
-- unique indexes also bind on DBs poisoned by the pre-index
|
|
@@ -4755,6 +6900,9 @@ CREATE TABLE IF NOT EXISTS slotlock.approval_policies (
|
|
|
4755
6900
|
AND lead_minutes BETWEEN 0 AND 43200
|
|
4756
6901
|
)
|
|
4757
6902
|
);
|
|
6903
|
+
-- A policy holds agents' bookings, and the tenant's own through the application API (bundles and
|
|
6904
|
+
-- pool bookings) only when it includes them (RFC 0004 B7).
|
|
6905
|
+
ALTER TABLE slotlock.approval_policies ADD COLUMN IF NOT EXISTS include_application boolean NOT NULL DEFAULT false;
|
|
4758
6906
|
|
|
4759
6907
|
-- A write waiting for a person: its time held by holds whose holder is the approval (never the
|
|
4760
6908
|
-- agent, so it cannot release them and they do not count against its caps), and the request to run
|
|
@@ -4799,6 +6947,147 @@ CREATE INDEX IF NOT EXISTS slotlock_approvals_pending_idx
|
|
|
4799
6947
|
CREATE INDEX IF NOT EXISTS slotlock_approvals_expiry_idx
|
|
4800
6948
|
ON slotlock.approvals (expires_at) WHERE status = 'pending';
|
|
4801
6949
|
|
|
6950
|
+
-- Stored hours (RFC 0004 B2): the tenant's (no location) and each location's. A resource without
|
|
6951
|
+
-- its own inherits its location's, the nearest up the chain, then its tenant's, then the server's.
|
|
6952
|
+
-- Rules are written validated (weekly rules, at most 50).
|
|
6953
|
+
CREATE TABLE IF NOT EXISTS slotlock.availability_defaults (
|
|
6954
|
+
tenant_ref text NOT NULL,
|
|
6955
|
+
location_ref text,
|
|
6956
|
+
rules jsonb NOT NULL,
|
|
6957
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
6958
|
+
CONSTRAINT slotlock_availability_defaults_location_fkey FOREIGN KEY (tenant_ref, location_ref)
|
|
6959
|
+
REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
|
|
6960
|
+
CONSTRAINT slotlock_availability_defaults_valid CHECK (
|
|
6961
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
6962
|
+
AND jsonb_typeof(rules) = 'array' AND jsonb_array_length(rules) <= 50
|
|
6963
|
+
AND octet_length(rules::text) <= 65536
|
|
6964
|
+
)
|
|
6965
|
+
);
|
|
6966
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_availability_defaults_scope_idx
|
|
6967
|
+
ON slotlock.availability_defaults (tenant_ref, (COALESCE(location_ref, '')));
|
|
6968
|
+
|
|
6969
|
+
-- Exceptions to the weekly hours (RFC 0004 B1): whole local days, closed or open with hours of
|
|
6970
|
+
-- their own, for the tenant, a location (and every resource inside it) or one resource. An import
|
|
6971
|
+
-- names its source's id, so importing again updates the same row.
|
|
6972
|
+
CREATE TABLE IF NOT EXISTS slotlock.availability_exceptions (
|
|
6973
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
6974
|
+
tenant_ref text NOT NULL,
|
|
6975
|
+
location_ref text,
|
|
6976
|
+
resource_id uuid,
|
|
6977
|
+
starts_on date NOT NULL,
|
|
6978
|
+
ends_on date NOT NULL,
|
|
6979
|
+
outcome text NOT NULL,
|
|
6980
|
+
hours jsonb,
|
|
6981
|
+
label text NOT NULL,
|
|
6982
|
+
source text NOT NULL DEFAULT 'manual',
|
|
6983
|
+
source_uid text,
|
|
6984
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
6985
|
+
CONSTRAINT slotlock_availability_exceptions_location_fkey FOREIGN KEY (tenant_ref, location_ref)
|
|
6986
|
+
REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
|
|
6987
|
+
CONSTRAINT slotlock_availability_exceptions_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
|
|
6988
|
+
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
|
|
6989
|
+
CONSTRAINT slotlock_availability_exceptions_valid CHECK (
|
|
6990
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
6991
|
+
AND (location_ref IS NULL OR resource_id IS NULL)
|
|
6992
|
+
AND ends_on >= starts_on AND ends_on - starts_on < 367
|
|
6993
|
+
AND outcome IN ('closed', 'open')
|
|
6994
|
+
AND ((outcome = 'open') = (hours IS NOT NULL))
|
|
6995
|
+
AND (hours IS NULL OR (jsonb_typeof(hours) = 'array' AND jsonb_array_length(hours) BETWEEN 1 AND 4))
|
|
6996
|
+
AND char_length(label) BETWEEN 1 AND 200 AND btrim(label) <> ''
|
|
6997
|
+
AND source IN ('manual', 'ics', 'vavailability')
|
|
6998
|
+
AND (source_uid IS NULL OR octet_length(source_uid) BETWEEN 1 AND 600)
|
|
6999
|
+
)
|
|
7000
|
+
);
|
|
7001
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_availability_exceptions_source_idx
|
|
7002
|
+
ON slotlock.availability_exceptions
|
|
7003
|
+
(tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), source_uid)
|
|
7004
|
+
WHERE source_uid IS NOT NULL;
|
|
7005
|
+
CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_days_idx
|
|
7006
|
+
ON slotlock.availability_exceptions (tenant_ref, ends_on, starts_on);
|
|
7007
|
+
CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_resource_idx
|
|
7008
|
+
ON slotlock.availability_exceptions (resource_id) WHERE resource_id IS NOT NULL;
|
|
7009
|
+
|
|
7010
|
+
-- A holiday calendar imported into closures (RFC 0004 B1), for the tenant, a location or a
|
|
7011
|
+
-- resource, by name: its closures name it, so importing it again removes the holidays it has
|
|
7012
|
+
-- dropped. With a URL it is a subscription, which maintenance imports again by next_import_at.
|
|
7013
|
+
-- Deleting it deletes its closures.
|
|
7014
|
+
CREATE TABLE IF NOT EXISTS slotlock.holiday_calendars (
|
|
7015
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7016
|
+
tenant_ref text NOT NULL,
|
|
7017
|
+
location_ref text,
|
|
7018
|
+
resource_id uuid,
|
|
7019
|
+
name text NOT NULL,
|
|
7020
|
+
url text,
|
|
7021
|
+
imported_at timestamptz,
|
|
7022
|
+
next_import_at timestamptz,
|
|
7023
|
+
last_error text,
|
|
7024
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7025
|
+
CONSTRAINT slotlock_holiday_calendars_tenant_key UNIQUE (id, tenant_ref),
|
|
7026
|
+
CONSTRAINT slotlock_holiday_calendars_location_fkey FOREIGN KEY (tenant_ref, location_ref)
|
|
7027
|
+
REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
|
|
7028
|
+
CONSTRAINT slotlock_holiday_calendars_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
|
|
7029
|
+
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
|
|
7030
|
+
CONSTRAINT slotlock_holiday_calendars_valid CHECK (
|
|
7031
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
7032
|
+
AND (location_ref IS NULL OR resource_id IS NULL)
|
|
7033
|
+
AND char_length(name) BETWEEN 1 AND 200 AND btrim(name) <> ''
|
|
7034
|
+
AND (url IS NULL OR (url ~ '^https://' AND octet_length(url) <= 2048))
|
|
7035
|
+
AND (next_import_at IS NULL OR url IS NOT NULL)
|
|
7036
|
+
AND (last_error IS NULL OR char_length(last_error) <= 500)
|
|
7037
|
+
)
|
|
7038
|
+
);
|
|
7039
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_holiday_calendars_name_idx
|
|
7040
|
+
ON slotlock.holiday_calendars (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), name);
|
|
7041
|
+
CREATE INDEX IF NOT EXISTS slotlock_holiday_calendars_due_idx
|
|
7042
|
+
ON slotlock.holiday_calendars (tenant_ref, next_import_at) WHERE next_import_at IS NOT NULL;
|
|
7043
|
+
-- The calendar an imported closure came from: NULL for one added by hand, or imported before
|
|
7044
|
+
-- calendars were kept (claimed by the next import of a calendar that names it).
|
|
7045
|
+
ALTER TABLE slotlock.availability_exceptions ADD COLUMN IF NOT EXISTS calendar_id uuid;
|
|
7046
|
+
DO $slotlock_exception_calendar$
|
|
7047
|
+
BEGIN
|
|
7048
|
+
IF NOT EXISTS (
|
|
7049
|
+
SELECT 1 FROM pg_constraint
|
|
7050
|
+
WHERE conname = 'slotlock_availability_exceptions_calendar_fkey'
|
|
7051
|
+
AND conrelid = 'slotlock.availability_exceptions'::regclass
|
|
7052
|
+
) THEN
|
|
7053
|
+
ALTER TABLE slotlock.availability_exceptions
|
|
7054
|
+
ADD CONSTRAINT slotlock_availability_exceptions_calendar_fkey FOREIGN KEY (calendar_id, tenant_ref)
|
|
7055
|
+
REFERENCES slotlock.holiday_calendars (id, tenant_ref) ON DELETE CASCADE;
|
|
7056
|
+
END IF;
|
|
7057
|
+
END
|
|
7058
|
+
$slotlock_exception_calendar$;
|
|
7059
|
+
CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_calendar_idx
|
|
7060
|
+
ON slotlock.availability_exceptions (calendar_id) WHERE calendar_id IS NOT NULL;
|
|
7061
|
+
|
|
7062
|
+
-- The hours a resource without its own inherits: its location's, the nearest up the chain, else its
|
|
7063
|
+
-- tenant's, as {source, location?, rules}; NULL leaves the server's default. Runs as the caller, so
|
|
7064
|
+
-- row-level security keeps it inside the tenant.
|
|
7065
|
+
CREATE OR REPLACE FUNCTION slotlock.inherited_availability(requested_tenant_ref text, requested_location_ref text)
|
|
7066
|
+
RETURNS jsonb
|
|
7067
|
+
LANGUAGE sql
|
|
7068
|
+
STABLE
|
|
7069
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
7070
|
+
AS $slotlock_inherited_availability$
|
|
7071
|
+
WITH RECURSIVE up(ref, parent_ref, steps) AS (
|
|
7072
|
+
SELECT place.ref, place.parent_ref, 1 FROM slotlock.locations place
|
|
7073
|
+
WHERE requested_location_ref IS NOT NULL
|
|
7074
|
+
AND place.tenant_ref = requested_tenant_ref AND place.ref = requested_location_ref
|
|
7075
|
+
UNION ALL
|
|
7076
|
+
SELECT place.ref, place.parent_ref, up.steps + 1
|
|
7077
|
+
FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
|
|
7078
|
+
WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
7079
|
+
)
|
|
7080
|
+
SELECT COALESCE(
|
|
7081
|
+
(SELECT jsonb_build_object('source', 'location', 'location', up.ref, 'rules', defaults.rules)
|
|
7082
|
+
FROM up JOIN slotlock.availability_defaults defaults
|
|
7083
|
+
ON defaults.tenant_ref = requested_tenant_ref AND defaults.location_ref = up.ref
|
|
7084
|
+
ORDER BY up.steps LIMIT 1),
|
|
7085
|
+
(SELECT jsonb_build_object('source', 'tenant', 'rules', defaults.rules)
|
|
7086
|
+
FROM slotlock.availability_defaults defaults
|
|
7087
|
+
WHERE defaults.tenant_ref = requested_tenant_ref AND defaults.location_ref IS NULL)
|
|
7088
|
+
);
|
|
7089
|
+
$slotlock_inherited_availability$;
|
|
7090
|
+
|
|
4802
7091
|
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
4803
7092
|
-- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
|
|
4804
7093
|
-- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
|
|
@@ -5075,6 +7364,22 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
|
|
|
5075
7364
|
ALTER TABLE slotlock.calendar_events
|
|
5076
7365
|
ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
|
|
5077
7366
|
CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
|
|
7367
|
+
-- A turnaround may run to a week (RFC 0004 B3): the wider check admits every row the old one did,
|
|
7368
|
+
-- so it is added NOT VALID (no scan under the lock), and the old one dropped.
|
|
7369
|
+
DO $slotlock_calendar_event_week_turnaround$
|
|
7370
|
+
BEGIN
|
|
7371
|
+
IF NOT EXISTS (
|
|
7372
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
7373
|
+
WHERE conrelid = 'slotlock.calendar_events'::regclass
|
|
7374
|
+
AND conname = 'slotlock_calendar_events_buffer_week_valid'
|
|
7375
|
+
) THEN
|
|
7376
|
+
ALTER TABLE slotlock.calendar_events
|
|
7377
|
+
ADD CONSTRAINT slotlock_calendar_events_buffer_week_valid
|
|
7378
|
+
CHECK (buffer_after_ms BETWEEN 0 AND 604800000) NOT VALID;
|
|
7379
|
+
END IF;
|
|
7380
|
+
ALTER TABLE slotlock.calendar_events DROP CONSTRAINT IF EXISTS slotlock_calendar_events_buffer_valid;
|
|
7381
|
+
END
|
|
7382
|
+
$slotlock_calendar_event_week_turnaround$;
|
|
5078
7383
|
-- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
|
|
5079
7384
|
ALTER TABLE slotlock.calendar_events
|
|
5080
7385
|
ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
|
|
@@ -5398,6 +7703,311 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
|
|
|
5398
7703
|
)
|
|
5399
7704
|
);
|
|
5400
7705
|
|
|
7706
|
+
-- Zone rule changes reach everything (RFC 0004 B9): a series' occurrences, and reservations that
|
|
7707
|
+
-- carry the wall time they were booked for, keep it, so maintenance finds those whose wall time now
|
|
7708
|
+
-- falls at another instant. An occurrence's is its series' zone's wall time when written; a
|
|
7709
|
+
-- reservation's is what the writer gave (local_start), in its resource's zone.
|
|
7710
|
+
ALTER TABLE slotlock.calendar_event_occurrences ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
|
|
7711
|
+
ALTER TABLE slotlock.calendar_event_occurrences ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
|
|
7712
|
+
CREATE OR REPLACE FUNCTION slotlock.calendar_event_occurrence_wall_time()
|
|
7713
|
+
RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, slotlock, pg_temp
|
|
7714
|
+
AS $slotlock_occurrence_wall_time$
|
|
7715
|
+
BEGIN
|
|
7716
|
+
IF TG_OP = 'INSERT' OR NEW.starts_at IS DISTINCT FROM OLD.starts_at THEN
|
|
7717
|
+
NEW.wall_starts_at := slotlock.zoned_wall(
|
|
7718
|
+
NEW.starts_at, (SELECT event.timezone FROM slotlock.calendar_events event WHERE event.id = NEW.event_id));
|
|
7719
|
+
NEW.zone_shift_reported_at := NULL;
|
|
7720
|
+
END IF;
|
|
7721
|
+
RETURN NEW;
|
|
7722
|
+
END
|
|
7723
|
+
$slotlock_occurrence_wall_time$;
|
|
7724
|
+
DROP TRIGGER IF EXISTS slotlock_calendar_event_occurrence_wall_time ON slotlock.calendar_event_occurrences;
|
|
7725
|
+
CREATE TRIGGER slotlock_calendar_event_occurrence_wall_time
|
|
7726
|
+
BEFORE INSERT OR UPDATE ON slotlock.calendar_event_occurrences
|
|
7727
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.calendar_event_occurrence_wall_time();
|
|
7728
|
+
-- Occurrences to come written before this release: their wall time as the rules read now. A new
|
|
7729
|
+
-- database has none, and may not have slotlock.zoned_wall yet at this point.
|
|
7730
|
+
DO $slotlock_occurrence_wall_backfill$
|
|
7731
|
+
BEGIN
|
|
7732
|
+
IF to_regprocedure('slotlock.zoned_wall(timestamptz, text)') IS NOT NULL THEN
|
|
7733
|
+
EXECUTE $backfill$
|
|
7734
|
+
UPDATE slotlock.calendar_event_occurrences occurrence
|
|
7735
|
+
SET wall_starts_at = slotlock.zoned_wall(occurrence.starts_at, event.timezone)
|
|
7736
|
+
FROM slotlock.calendar_events event
|
|
7737
|
+
WHERE event.id = occurrence.event_id AND occurrence.wall_starts_at IS NULL
|
|
7738
|
+
AND occurrence.starts_at > now()
|
|
7739
|
+
$backfill$;
|
|
7740
|
+
END IF;
|
|
7741
|
+
END
|
|
7742
|
+
$slotlock_occurrence_wall_backfill$;
|
|
7743
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
|
|
7744
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
|
|
7745
|
+
|
|
7746
|
+
-- Integrations (RFC 0004 P3). Core never sends a message and never holds a provider token; it keeps
|
|
7747
|
+
-- what the packages that do need. A booking may name the person to tell (contact_ref) and say not
|
|
7748
|
+
-- to tell anyone (notify); a hold may wait for the integrator's payment before it is confirmed.
|
|
7749
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS contact_ref text;
|
|
7750
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT true;
|
|
7751
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_required boolean NOT NULL DEFAULT false;
|
|
7752
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_ref text;
|
|
7753
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_at timestamptz;
|
|
7754
|
+
-- What a hold's payment must be, and what was received (RFC 0004 C3), in the currency's minor unit.
|
|
7755
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_amount_minor bigint;
|
|
7756
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_currency text;
|
|
7757
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_amount_minor bigint;
|
|
7758
|
+
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_currency text;
|
|
7759
|
+
ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT true;
|
|
7760
|
+
DO $slotlock_integration_columns$
|
|
7761
|
+
BEGIN
|
|
7762
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_integration_valid'
|
|
7763
|
+
AND conrelid = 'slotlock.reservations'::regclass) THEN
|
|
7764
|
+
ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_integration_valid CHECK (
|
|
7765
|
+
(contact_ref IS NULL OR contact_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$')
|
|
7766
|
+
AND (payment_ref IS NULL OR octet_length(payment_ref) BETWEEN 1 AND 255)
|
|
7767
|
+
AND (paid_at IS NULL OR payment_ref IS NOT NULL)
|
|
7768
|
+
) NOT VALID;
|
|
7769
|
+
END IF;
|
|
7770
|
+
END
|
|
7771
|
+
$slotlock_integration_columns$;
|
|
7772
|
+
DO $slotlock_payment_amounts$
|
|
7773
|
+
BEGIN
|
|
7774
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_payment_amounts_valid'
|
|
7775
|
+
AND conrelid = 'slotlock.reservations'::regclass) THEN
|
|
7776
|
+
ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_payment_amounts_valid CHECK (
|
|
7777
|
+
(payment_amount_minor IS NULL) = (payment_currency IS NULL)
|
|
7778
|
+
AND (paid_amount_minor IS NULL) = (paid_currency IS NULL)
|
|
7779
|
+
AND (payment_amount_minor IS NULL OR payment_amount_minor BETWEEN 0 AND 1000000000000)
|
|
7780
|
+
AND (paid_amount_minor IS NULL OR paid_amount_minor BETWEEN 0 AND 1000000000000)
|
|
7781
|
+
AND (payment_currency IS NULL OR payment_currency ~ '^[A-Z]{3}$')
|
|
7782
|
+
AND (paid_currency IS NULL OR paid_currency ~ '^[A-Z]{3}$')
|
|
7783
|
+
) NOT VALID;
|
|
7784
|
+
END IF;
|
|
7785
|
+
END
|
|
7786
|
+
$slotlock_payment_amounts$;
|
|
7787
|
+
-- Forgetting a person finds the bookings that name them.
|
|
7788
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservations_contact_idx
|
|
7789
|
+
ON slotlock.reservations (tenant_ref, contact_ref) WHERE contact_ref IS NOT NULL;
|
|
7790
|
+
|
|
7791
|
+
-- The people a tenant's bookings may tell (RFC 0004 P3): how to reach each, and what each agreed
|
|
7792
|
+
-- to. A person is erased by deleting their row; bookings keep no copy of what it held.
|
|
7793
|
+
CREATE TABLE IF NOT EXISTS slotlock.contacts (
|
|
7794
|
+
tenant_ref text NOT NULL,
|
|
7795
|
+
ref text NOT NULL,
|
|
7796
|
+
name text,
|
|
7797
|
+
email text,
|
|
7798
|
+
phone text,
|
|
7799
|
+
whatsapp text,
|
|
7800
|
+
consent jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
7801
|
+
locale text,
|
|
7802
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7803
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7804
|
+
PRIMARY KEY (tenant_ref, ref),
|
|
7805
|
+
CONSTRAINT slotlock_contacts_valid CHECK (
|
|
7806
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
7807
|
+
AND ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$'
|
|
7808
|
+
AND (name IS NULL OR char_length(name) BETWEEN 1 AND 200)
|
|
7809
|
+
AND (email IS NULL OR (char_length(email) BETWEEN 3 AND 320 AND email LIKE '%_@_%'))
|
|
7810
|
+
AND (phone IS NULL OR phone ~ '^[+][1-9][0-9]{6,14}$')
|
|
7811
|
+
AND (whatsapp IS NULL OR whatsapp ~ '^[+][1-9][0-9]{6,14}$')
|
|
7812
|
+
AND jsonb_typeof(consent) = 'object' AND octet_length(consent::text) <= 512
|
|
7813
|
+
AND (locale IS NULL OR locale ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$')
|
|
7814
|
+
)
|
|
7815
|
+
);
|
|
7816
|
+
|
|
7817
|
+
-- Each reminder once (RFC 0004 P3): when maintenance put reminder.due on the feed for an
|
|
7818
|
+
-- occurrence, so a later pass never says it again.
|
|
7819
|
+
CREATE TABLE IF NOT EXISTS slotlock.reminder_dispatches (
|
|
7820
|
+
tenant_ref text NOT NULL,
|
|
7821
|
+
event_id uuid NOT NULL,
|
|
7822
|
+
recurrence_id text NOT NULL,
|
|
7823
|
+
ordinal integer NOT NULL,
|
|
7824
|
+
due_at timestamptz NOT NULL,
|
|
7825
|
+
dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7826
|
+
PRIMARY KEY (event_id, recurrence_id, due_at),
|
|
7827
|
+
CONSTRAINT slotlock_reminder_dispatches_event_fkey FOREIGN KEY (event_id, tenant_ref)
|
|
7828
|
+
REFERENCES slotlock.calendar_events (id, tenant_ref) ON DELETE CASCADE
|
|
7829
|
+
);
|
|
7830
|
+
CREATE INDEX IF NOT EXISTS slotlock_reminder_dispatches_tenant_idx
|
|
7831
|
+
ON slotlock.reminder_dispatches (tenant_ref, due_at);
|
|
7832
|
+
|
|
7833
|
+
-- A booking's reminders (RFC 0004 P3): minutes before its start, at most five; each said once on
|
|
7834
|
+
-- the feed as reminder.due, and again if the booking moves.
|
|
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;
|
|
7841
|
+
DO $slotlock_reservation_reminders$
|
|
7842
|
+
BEGIN
|
|
7843
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_reminders_valid'
|
|
7844
|
+
AND conrelid = 'slotlock.reservations'::regclass) THEN
|
|
7845
|
+
ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_reminders_valid CHECK (
|
|
7846
|
+
reminder_minutes IS NULL
|
|
7847
|
+
OR (cardinality(reminder_minutes) BETWEEN 1 AND 5
|
|
7848
|
+
AND 0 <= ALL (reminder_minutes) AND 527040 >= ALL (reminder_minutes))
|
|
7849
|
+
) NOT VALID;
|
|
7850
|
+
END IF;
|
|
7851
|
+
END
|
|
7852
|
+
$slotlock_reservation_reminders$;
|
|
7853
|
+
CREATE TABLE IF NOT EXISTS slotlock.reservation_reminder_dispatches (
|
|
7854
|
+
tenant_ref text NOT NULL,
|
|
7855
|
+
reservation_id uuid NOT NULL,
|
|
7856
|
+
ordinal integer NOT NULL CHECK (ordinal BETWEEN 0 AND 4),
|
|
7857
|
+
due_at timestamptz NOT NULL,
|
|
7858
|
+
dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7859
|
+
PRIMARY KEY (reservation_id, due_at),
|
|
7860
|
+
CONSTRAINT slotlock_reservation_reminder_dispatches_fkey FOREIGN KEY (reservation_id, tenant_ref)
|
|
7861
|
+
REFERENCES slotlock.reservations (id, tenant_ref) ON DELETE CASCADE
|
|
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$;
|
|
7896
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservation_reminder_dispatches_tenant_idx
|
|
7897
|
+
ON slotlock.reservation_reminder_dispatches (tenant_ref, due_at);
|
|
7898
|
+
|
|
7899
|
+
-- Each hold's payment, kept once recorded (RFC 0004 C3), however the hold ends: confirmed in place,
|
|
7900
|
+
-- booked as an event (its row deleted), released or expired. So the same payment, told again later,
|
|
7901
|
+
-- is answered as the payment already recorded, never as one to refund.
|
|
7902
|
+
CREATE TABLE IF NOT EXISTS slotlock.hold_payments (
|
|
7903
|
+
tenant_ref text NOT NULL,
|
|
7904
|
+
hold_id uuid PRIMARY KEY,
|
|
7905
|
+
resource_id uuid NOT NULL,
|
|
7906
|
+
starts_at timestamptz NOT NULL,
|
|
7907
|
+
ends_at timestamptz NOT NULL,
|
|
7908
|
+
buffer_after_ms bigint NOT NULL DEFAULT 0,
|
|
7909
|
+
expires_at timestamptz NOT NULL,
|
|
7910
|
+
payment_ref text NOT NULL CHECK (octet_length(payment_ref) BETWEEN 1 AND 255),
|
|
7911
|
+
paid_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
7912
|
+
payment_amount_minor bigint,
|
|
7913
|
+
payment_currency text,
|
|
7914
|
+
paid_amount_minor bigint,
|
|
7915
|
+
paid_currency text,
|
|
7916
|
+
CONSTRAINT slotlock_hold_payments_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
|
|
7917
|
+
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE
|
|
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$;
|
|
7937
|
+
-- When each channel's consent was last given or withdrawn, as proof of it.
|
|
7938
|
+
ALTER TABLE slotlock.contacts ADD COLUMN IF NOT EXISTS consent_at jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
7939
|
+
|
|
7940
|
+
-- Busy time a calendar reports without its events (Google freeBusy, Microsoft getSchedule), for one
|
|
7941
|
+
-- resource and source (RFC 0004 C1): replaced window by window with the source's coverage, counted
|
|
7942
|
+
-- in free/busy and refused to every booking Slotlock makes, as an event's time would be.
|
|
7943
|
+
CREATE TABLE IF NOT EXISTS slotlock.source_busy (
|
|
7944
|
+
tenant_ref text NOT NULL,
|
|
7945
|
+
resource_id uuid NOT NULL,
|
|
7946
|
+
source text NOT NULL,
|
|
7947
|
+
starts_at timestamptz NOT NULL,
|
|
7948
|
+
ends_at timestamptz NOT NULL,
|
|
7949
|
+
CONSTRAINT slotlock_source_busy_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
|
|
7950
|
+
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
|
|
7951
|
+
CONSTRAINT slotlock_source_busy_valid CHECK (
|
|
7952
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(source) BETWEEN 1 AND 500
|
|
7953
|
+
AND ends_at > starts_at
|
|
7954
|
+
)
|
|
7955
|
+
);
|
|
7956
|
+
CREATE INDEX IF NOT EXISTS slotlock_source_busy_resource_idx
|
|
7957
|
+
ON slotlock.source_busy (resource_id, starts_at, ends_at);
|
|
7958
|
+
CREATE INDEX IF NOT EXISTS slotlock_source_busy_source_idx
|
|
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
|
+
);
|
|
8010
|
+
|
|
5401
8011
|
-- A cancelled UID remains terminal. This blocks delayed create delivery after the event row and
|
|
5402
8012
|
-- its derived reservations have been removed.
|
|
5403
8013
|
CREATE TABLE IF NOT EXISTS slotlock.calendar_event_tombstones (
|
|
@@ -5612,7 +8222,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
5612
8222
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
5613
8223
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
5614
8224
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
5615
|
-
${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}`;
|
|
5616
8226
|
/**
|
|
5617
8227
|
* Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
|
|
5618
8228
|
* or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
|
|
@@ -5742,6 +8352,8 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
5742
8352
|
'booking_rule_defaults',
|
|
5743
8353
|
'assistant_settings',
|
|
5744
8354
|
'assistant_conversations',
|
|
8355
|
+
'assistant_chats',
|
|
8356
|
+
'audit_events',
|
|
5745
8357
|
'locations',
|
|
5746
8358
|
'resource_groups',
|
|
5747
8359
|
'resource_group_members',
|
|
@@ -5749,6 +8361,16 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
5749
8361
|
'composite_claims',
|
|
5750
8362
|
'approval_policies',
|
|
5751
8363
|
'approvals',
|
|
8364
|
+
'availability_defaults',
|
|
8365
|
+
'availability_exceptions',
|
|
8366
|
+
'holiday_calendars',
|
|
8367
|
+
'contacts',
|
|
8368
|
+
'reminder_dispatches',
|
|
8369
|
+
'reservation_reminder_dispatches',
|
|
8370
|
+
'hold_payments',
|
|
8371
|
+
'source_busy',
|
|
8372
|
+
'tenant_exports',
|
|
8373
|
+
'tenant_export_parts',
|
|
5752
8374
|
]);
|
|
5753
8375
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
5754
8376
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -5764,9 +8386,15 @@ export function createSlotlockApplicationRoleGrantsDdl(role) {
|
|
|
5764
8386
|
if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
|
|
5765
8387
|
throw Object.assign(new Error('Slotlock application role must be a lowercase, non-reserved PostgreSQL role name'), { code: 'invalid_role_name' });
|
|
5766
8388
|
}
|
|
5767
|
-
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(', ');
|
|
5768
8394
|
return `GRANT USAGE ON SCHEMA slotlock TO "${role}";
|
|
5769
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}";
|
|
5770
8398
|
GRANT EXECUTE ON FUNCTION ${SLOTLOCK_DEFINER_FUNCTIONS.join(', ')} TO "${role}";
|
|
5771
8399
|
`;
|
|
5772
8400
|
}
|
|
@@ -5797,6 +8425,11 @@ export const SLOTLOCK_RESOURCE_SCOPED_TABLES = Object.freeze({
|
|
|
5797
8425
|
// A change or a waitlist entry about no one resource (a key, a pool) is not the key's.
|
|
5798
8426
|
outbox: resourceScopeAllows('resource_id'),
|
|
5799
8427
|
waitlist_entries: resourceScopeAllows('resource_id'),
|
|
8428
|
+
// The tenant's and locations' exceptions shape every resource's hours, so every key reads them.
|
|
8429
|
+
availability_exceptions: `(resource_id IS NULL OR ${resourceScopeAllows('resource_id')})`,
|
|
8430
|
+
holiday_calendars: `(resource_id IS NULL OR ${resourceScopeAllows('resource_id')})`,
|
|
8431
|
+
source_busy: resourceScopeAllows('resource_id'),
|
|
8432
|
+
hold_payments: resourceScopeAllows('resource_id'),
|
|
5800
8433
|
...Object.fromEntries([
|
|
5801
8434
|
'calendar_event_attendees',
|
|
5802
8435
|
'calendar_event_reminders',
|
|
@@ -5862,6 +8495,10 @@ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
|
|
|
5862
8495
|
ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
|
|
5863
8496
|
ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
|
|
5864
8497
|
ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
|
|
8498
|
+
ALTER TABLE slotlock.assistant_chats ENABLE ROW LEVEL SECURITY;
|
|
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;
|
|
5865
8502
|
ALTER TABLE slotlock.locations ENABLE ROW LEVEL SECURITY;
|
|
5866
8503
|
ALTER TABLE slotlock.locations FORCE ROW LEVEL SECURITY;
|
|
5867
8504
|
ALTER TABLE slotlock.resource_groups ENABLE ROW LEVEL SECURITY;
|
|
@@ -5876,6 +8513,26 @@ ALTER TABLE slotlock.approval_policies ENABLE ROW LEVEL SECURITY;
|
|
|
5876
8513
|
ALTER TABLE slotlock.approval_policies FORCE ROW LEVEL SECURITY;
|
|
5877
8514
|
ALTER TABLE slotlock.approvals ENABLE ROW LEVEL SECURITY;
|
|
5878
8515
|
ALTER TABLE slotlock.approvals FORCE ROW LEVEL SECURITY;
|
|
8516
|
+
ALTER TABLE slotlock.availability_defaults ENABLE ROW LEVEL SECURITY;
|
|
8517
|
+
ALTER TABLE slotlock.availability_defaults FORCE ROW LEVEL SECURITY;
|
|
8518
|
+
ALTER TABLE slotlock.availability_exceptions ENABLE ROW LEVEL SECURITY;
|
|
8519
|
+
ALTER TABLE slotlock.availability_exceptions FORCE ROW LEVEL SECURITY;
|
|
8520
|
+
ALTER TABLE slotlock.contacts ENABLE ROW LEVEL SECURITY;
|
|
8521
|
+
ALTER TABLE slotlock.contacts FORCE ROW LEVEL SECURITY;
|
|
8522
|
+
ALTER TABLE slotlock.reminder_dispatches ENABLE ROW LEVEL SECURITY;
|
|
8523
|
+
ALTER TABLE slotlock.reminder_dispatches FORCE ROW LEVEL SECURITY;
|
|
8524
|
+
ALTER TABLE slotlock.reservation_reminder_dispatches ENABLE ROW LEVEL SECURITY;
|
|
8525
|
+
ALTER TABLE slotlock.reservation_reminder_dispatches FORCE ROW LEVEL SECURITY;
|
|
8526
|
+
ALTER TABLE slotlock.hold_payments ENABLE ROW LEVEL SECURITY;
|
|
8527
|
+
ALTER TABLE slotlock.hold_payments FORCE ROW LEVEL SECURITY;
|
|
8528
|
+
ALTER TABLE slotlock.holiday_calendars ENABLE ROW LEVEL SECURITY;
|
|
8529
|
+
ALTER TABLE slotlock.holiday_calendars FORCE ROW LEVEL SECURITY;
|
|
8530
|
+
ALTER TABLE slotlock.source_busy ENABLE ROW LEVEL SECURITY;
|
|
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;
|
|
5879
8536
|
|
|
5880
8537
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
5881
8538
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -5908,13 +8565,25 @@ BEGIN
|
|
|
5908
8565
|
'booking_rule_defaults',
|
|
5909
8566
|
'assistant_settings',
|
|
5910
8567
|
'assistant_conversations',
|
|
8568
|
+
'assistant_chats',
|
|
8569
|
+
'audit_events',
|
|
5911
8570
|
'locations',
|
|
5912
8571
|
'resource_groups',
|
|
5913
8572
|
'resource_group_members',
|
|
5914
8573
|
'pool_settings',
|
|
5915
8574
|
'composite_claims',
|
|
5916
8575
|
'approval_policies',
|
|
5917
|
-
'approvals'
|
|
8576
|
+
'approvals',
|
|
8577
|
+
'availability_defaults',
|
|
8578
|
+
'availability_exceptions',
|
|
8579
|
+
'holiday_calendars',
|
|
8580
|
+
'contacts',
|
|
8581
|
+
'reminder_dispatches',
|
|
8582
|
+
'reservation_reminder_dispatches',
|
|
8583
|
+
'hold_payments',
|
|
8584
|
+
'source_busy',
|
|
8585
|
+
'tenant_exports',
|
|
8586
|
+
'tenant_export_parts'
|
|
5918
8587
|
)
|
|
5919
8588
|
LOOP
|
|
5920
8589
|
EXECUTE format(
|
|
@@ -6156,6 +8825,26 @@ BEGIN
|
|
|
6156
8825
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6157
8826
|
END IF;
|
|
6158
8827
|
|
|
8828
|
+
IF NOT EXISTS (
|
|
8829
|
+
SELECT 1 FROM pg_policies
|
|
8830
|
+
WHERE schemaname = 'slotlock' AND tablename = 'assistant_chats'
|
|
8831
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8832
|
+
) THEN
|
|
8833
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_chats
|
|
8834
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8835
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8836
|
+
END IF;
|
|
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
|
+
|
|
6159
8848
|
IF NOT EXISTS (
|
|
6160
8849
|
SELECT 1 FROM pg_policies
|
|
6161
8850
|
WHERE schemaname = 'slotlock' AND tablename = 'locations'
|
|
@@ -6225,6 +8914,106 @@ BEGIN
|
|
|
6225
8914
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6226
8915
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6227
8916
|
END IF;
|
|
8917
|
+
|
|
8918
|
+
IF NOT EXISTS (
|
|
8919
|
+
SELECT 1 FROM pg_policies
|
|
8920
|
+
WHERE schemaname = 'slotlock' AND tablename = 'availability_defaults'
|
|
8921
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8922
|
+
) THEN
|
|
8923
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.availability_defaults
|
|
8924
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8925
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8926
|
+
END IF;
|
|
8927
|
+
|
|
8928
|
+
IF NOT EXISTS (
|
|
8929
|
+
SELECT 1 FROM pg_policies
|
|
8930
|
+
WHERE schemaname = 'slotlock' AND tablename = 'availability_exceptions'
|
|
8931
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8932
|
+
) THEN
|
|
8933
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.availability_exceptions
|
|
8934
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8935
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8936
|
+
END IF;
|
|
8937
|
+
|
|
8938
|
+
IF NOT EXISTS (
|
|
8939
|
+
SELECT 1 FROM pg_policies
|
|
8940
|
+
WHERE schemaname = 'slotlock' AND tablename = 'contacts'
|
|
8941
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8942
|
+
) THEN
|
|
8943
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.contacts
|
|
8944
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8945
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8946
|
+
END IF;
|
|
8947
|
+
|
|
8948
|
+
IF NOT EXISTS (
|
|
8949
|
+
SELECT 1 FROM pg_policies
|
|
8950
|
+
WHERE schemaname = 'slotlock' AND tablename = 'reminder_dispatches'
|
|
8951
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8952
|
+
) THEN
|
|
8953
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.reminder_dispatches
|
|
8954
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8955
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8956
|
+
END IF;
|
|
8957
|
+
|
|
8958
|
+
IF NOT EXISTS (
|
|
8959
|
+
SELECT 1 FROM pg_policies
|
|
8960
|
+
WHERE schemaname = 'slotlock' AND tablename = 'reservation_reminder_dispatches'
|
|
8961
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8962
|
+
) THEN
|
|
8963
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.reservation_reminder_dispatches
|
|
8964
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8965
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8966
|
+
END IF;
|
|
8967
|
+
|
|
8968
|
+
IF NOT EXISTS (
|
|
8969
|
+
SELECT 1 FROM pg_policies
|
|
8970
|
+
WHERE schemaname = 'slotlock' AND tablename = 'hold_payments'
|
|
8971
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8972
|
+
) THEN
|
|
8973
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.hold_payments
|
|
8974
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8975
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8976
|
+
END IF;
|
|
8977
|
+
|
|
8978
|
+
IF NOT EXISTS (
|
|
8979
|
+
SELECT 1 FROM pg_policies
|
|
8980
|
+
WHERE schemaname = 'slotlock' AND tablename = 'holiday_calendars'
|
|
8981
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8982
|
+
) THEN
|
|
8983
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
|
|
8984
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8985
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
8986
|
+
END IF;
|
|
8987
|
+
|
|
8988
|
+
IF NOT EXISTS (
|
|
8989
|
+
SELECT 1 FROM pg_policies
|
|
8990
|
+
WHERE schemaname = 'slotlock' AND tablename = 'source_busy'
|
|
8991
|
+
AND policyname = 'tenant_isolation_v1'
|
|
8992
|
+
) THEN
|
|
8993
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.source_busy
|
|
8994
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
8995
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
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;
|
|
6228
9017
|
END
|
|
6229
9018
|
$slotlock_rls$;
|
|
6230
9019
|
|
|
@@ -6268,6 +9057,10 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
|
|
|
6268
9057
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6269
9058
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
6270
9059
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9060
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_chats
|
|
9061
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9062
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.audit_events
|
|
9063
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6271
9064
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.locations
|
|
6272
9065
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6273
9066
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_groups
|
|
@@ -6282,6 +9075,26 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approval_policies
|
|
|
6282
9075
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6283
9076
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approvals
|
|
6284
9077
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9078
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.availability_defaults
|
|
9079
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9080
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.availability_exceptions
|
|
9081
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9082
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.contacts
|
|
9083
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9084
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.reminder_dispatches
|
|
9085
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9086
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.reservation_reminder_dispatches
|
|
9087
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9088
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_payments
|
|
9089
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9090
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
|
|
9091
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
9092
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.source_busy
|
|
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}';
|
|
6285
9098
|
${SLOTLOCK_RESOURCE_SCOPE_POLICIES}
|
|
6286
9099
|
|
|
6287
9100
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
@@ -6326,13 +9139,25 @@ WHERE schemaname = 'slotlock'
|
|
|
6326
9139
|
'booking_rule_defaults',
|
|
6327
9140
|
'assistant_settings',
|
|
6328
9141
|
'assistant_conversations',
|
|
9142
|
+
'assistant_chats',
|
|
9143
|
+
'audit_events',
|
|
6329
9144
|
'locations',
|
|
6330
9145
|
'resource_groups',
|
|
6331
9146
|
'resource_group_members',
|
|
6332
9147
|
'pool_settings',
|
|
6333
9148
|
'composite_claims',
|
|
6334
9149
|
'approval_policies',
|
|
6335
|
-
'approvals'
|
|
9150
|
+
'approvals',
|
|
9151
|
+
'availability_defaults',
|
|
9152
|
+
'availability_exceptions',
|
|
9153
|
+
'holiday_calendars',
|
|
9154
|
+
'contacts',
|
|
9155
|
+
'reminder_dispatches',
|
|
9156
|
+
'reservation_reminder_dispatches',
|
|
9157
|
+
'hold_payments',
|
|
9158
|
+
'source_busy',
|
|
9159
|
+
'tenant_exports',
|
|
9160
|
+
'tenant_export_parts'
|
|
6336
9161
|
)
|
|
6337
9162
|
AND policyname = 'tenant_isolation_v1'
|
|
6338
9163
|
ON CONFLICT (table_name) DO UPDATE
|