slotlock 0.9.0 → 0.10.0

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