slotlock 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/ddl.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// Slotlock owns its schema
|
|
2
|
-
//
|
|
1
|
+
// Slotlock owns its schema: this DDL lives in the `slotlock` Postgres schema, is applied by
|
|
2
|
+
// store.applySchema, and is idempotent.
|
|
3
3
|
//
|
|
4
|
-
// The EXCLUDE constraint is the load-bearing piece
|
|
5
|
-
//
|
|
6
|
-
// The same pattern scales from an integration's booking tables to a first-class reservation store.
|
|
4
|
+
// The EXCLUDE constraint is the load-bearing piece: the database, not application code, arbitrates
|
|
5
|
+
// double-booking per resource on the half-open `[)` range.
|
|
7
6
|
//
|
|
8
|
-
// The deployment role owns every table, so
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
|
|
7
|
+
// The deployment role owns every table, so a function or operator resolved through a schema another
|
|
8
|
+
// role can write to ("$user", public) would run with that ownership. The DDL pins search_path to
|
|
9
|
+
// pg_catalog and pg_temp while it runs and restores the caller's at the end. Apply it in one
|
|
10
|
+
// transaction: under autocommit the pin ends with its own statement.
|
|
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
|
+
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_WEBHOOK_FILTER_REFS, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, legacyCoverageSourceDigestSql, } from './event-feed.js';
|
|
15
13
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
16
14
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
17
15
|
const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
|
|
@@ -19,17 +17,11 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
|
|
|
19
17
|
`;
|
|
20
18
|
const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
|
|
21
19
|
`;
|
|
22
|
-
// applyTenantRls forces
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// after its last, on those tables only. applySchema runs both in one transaction, which holds the
|
|
28
|
-
// tables locked in between; a failure rolls the lift back with everything else. Run statement by
|
|
29
|
-
// statement, the lift commits on its own and a refused data step stops the run before the restore,
|
|
30
|
-
// so the tables lifted are recorded in a table, not the session: the next deployment, from any
|
|
31
|
-
// session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
|
|
32
|
-
// start while a tenant table is left without it.
|
|
20
|
+
// applyTenantRls forces RLS, which binds the owner too, and the deployment sets no tenant, so its
|
|
21
|
+
// data steps would see no rows. FORCE is lifted from the tables that have it before the first data
|
|
22
|
+
// step and restored after the last. The lifted tables are recorded in a table, not the session, so
|
|
23
|
+
// if a statement-by-statement run stops before the restore, the next deployment from any session
|
|
24
|
+
// restores them, and `slotlock serve` refuses to start meanwhile.
|
|
33
25
|
const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
|
|
34
26
|
table_name text PRIMARY KEY
|
|
35
27
|
);
|
|
@@ -74,19 +66,18 @@ $slotlock_restore_forced_row_security$;
|
|
|
74
66
|
/** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
|
|
75
67
|
export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
|
|
76
68
|
/**
|
|
77
|
-
* The version
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
69
|
+
* The schema version this release creates, recorded in `slotlock.schema_version`. Up to 0.2
|
|
70
|
+
* 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
|
+
* refuses it rather than restoring the trigger that names a coverage source by its guessable
|
|
72
|
+
* digest.
|
|
81
73
|
*/
|
|
82
74
|
export const SLOTLOCK_SCHEMA_VERSION = 4;
|
|
83
75
|
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
84
76
|
export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
|
|
85
77
|
/** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
|
|
86
78
|
const MIN_SERVER_VERSION_NUM = 160000;
|
|
87
|
-
// Before anything changes:
|
|
88
|
-
//
|
|
89
|
-
// migrated is refused, since this release's DDL would put back what that release replaced.
|
|
79
|
+
// Before anything changes: an old server fails here with a message naming the version rather than
|
|
80
|
+
// midway, and a schema a newer release migrated is refused.
|
|
90
81
|
const SLOTLOCK_SCHEMA_PRECONDITIONS = `DO $slotlock_schema_preconditions$
|
|
91
82
|
DECLARE
|
|
92
83
|
installed integer;
|
|
@@ -107,14 +98,11 @@ BEGIN
|
|
|
107
98
|
END
|
|
108
99
|
$slotlock_schema_preconditions$;
|
|
109
100
|
`;
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// there can DROP EXTENSION ... CASCADE and take the exclusion constraints with it. The deployment
|
|
116
|
-
// refuses before it touches anything there, listing each finding as a JSON array in the error's
|
|
117
|
-
// DETAIL. Superusers are trusted: they can do all of this anyway.
|
|
101
|
+
// The deployment writes rows as the owner of every table, firing their triggers. A role that owns
|
|
102
|
+
// or can create objects in the schema, owns an object or trigger function there, or may create a
|
|
103
|
+
// trigger could therefore run code as the deployment role; one owning an extension there could DROP
|
|
104
|
+
// EXTENSION ... CASCADE and take the exclusion constraints. The deployment refuses before touching
|
|
105
|
+
// anything, with findings as a JSON array in DETAIL. Superusers are trusted.
|
|
118
106
|
const SLOTLOCK_SCHEMA_CONTROL_CHECK = `DO $slotlock_schema_control$
|
|
119
107
|
DECLARE
|
|
120
108
|
refusals text[];
|
|
@@ -190,9 +178,8 @@ END
|
|
|
190
178
|
$slotlock_schema_control$;
|
|
191
179
|
`;
|
|
192
180
|
/**
|
|
193
|
-
* What every migration checks before
|
|
194
|
-
*
|
|
195
|
-
* and the schema version. Run with search_path pinned, as the DDL pins it.
|
|
181
|
+
* What every migration checks before changing anything, even when it finds nothing to change: no
|
|
182
|
+
* other role controls the schema, then server and schema version. Run with search_path pinned.
|
|
196
183
|
*/
|
|
197
184
|
export const SLOTLOCK_DEPLOYMENT_CHECKS = `${SLOTLOCK_SCHEMA_CONTROL_CHECK}${SLOTLOCK_SCHEMA_PRECONDITIONS}`;
|
|
198
185
|
/** Active (unrevoked, unexpired) API keys one tenant may hold. */
|
|
@@ -206,6 +193,8 @@ export const SLOTLOCK_API_KEY_RETAINED_LIMIT = 1_000;
|
|
|
206
193
|
export const SLOTLOCK_API_KEY_TABLES = Object.freeze([
|
|
207
194
|
'api_keys',
|
|
208
195
|
'api_key_retired_digests',
|
|
196
|
+
'api_key_resource_scopes',
|
|
197
|
+
'api_key_limits',
|
|
209
198
|
]);
|
|
210
199
|
/**
|
|
211
200
|
* The SECURITY DEFINER functions through which the serving role reaches API keys, by signature.
|
|
@@ -218,16 +207,25 @@ export const SLOTLOCK_API_KEY_FUNCTIONS = Object.freeze([
|
|
|
218
207
|
'slotlock.revoke_api_key(text, uuid)',
|
|
219
208
|
'slotlock.authenticate_api_key(bytea)',
|
|
220
209
|
'slotlock.erase_api_keys(text)',
|
|
210
|
+
'slotlock.set_api_key_resource_scope(text, uuid, text[], text[])',
|
|
211
|
+
'slotlock.authenticate_scoped_api_key(bytea)',
|
|
212
|
+
'slotlock.list_api_key_resource_scopes(text)',
|
|
213
|
+
'slotlock.set_api_key_limits(text, uuid, integer, integer, integer, boolean)',
|
|
214
|
+
'slotlock.list_api_key_limits(text)',
|
|
215
|
+
'slotlock.authenticate_limited_api_key(bytea)',
|
|
221
216
|
]);
|
|
222
217
|
/**
|
|
223
218
|
* The table behind the dashboard's shared records (dashboard.ts). No role but its owner holds a
|
|
224
219
|
* right on it; it is reached only through SLOTLOCK_DASHBOARD_FUNCTIONS.
|
|
225
220
|
*/
|
|
226
|
-
export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens']);
|
|
221
|
+
export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens', 'dashboard_organizations']);
|
|
227
222
|
/** The SECURITY DEFINER functions through which the serving role reaches the dashboard's records. */
|
|
228
223
|
export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
|
|
229
224
|
'slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)',
|
|
230
225
|
'slotlock.dashboard_session_ended(bytea, timestamptz)',
|
|
226
|
+
'slotlock.enabled_organizations(text)',
|
|
227
|
+
'slotlock.enable_organization(text, text, text)',
|
|
228
|
+
'slotlock.disable_organization(text, text)',
|
|
231
229
|
]);
|
|
232
230
|
/** Live form records one person may leave in the dashboard's records (12 hours of forms). */
|
|
233
231
|
export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
|
|
@@ -240,9 +238,18 @@ export const SLOTLOCK_SCHEMA_FUNCTIONS = Object.freeze([
|
|
|
240
238
|
/** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
|
|
241
239
|
export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
|
|
242
240
|
'tenant_suspensions',
|
|
241
|
+
'tenant_plans',
|
|
242
|
+
'plan_defaults',
|
|
243
|
+
'usage_daily',
|
|
244
|
+
'usage_flushes',
|
|
245
|
+
'usage_cursors',
|
|
246
|
+
'usage_meter_start',
|
|
247
|
+
'usage_notices',
|
|
248
|
+
'overage_reports',
|
|
243
249
|
'webhook_tenants',
|
|
244
250
|
'rate_buckets',
|
|
245
251
|
'assistant_tenants',
|
|
252
|
+
'maintenance_tenants',
|
|
246
253
|
]);
|
|
247
254
|
/**
|
|
248
255
|
* The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
|
|
@@ -257,18 +264,99 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
|
257
264
|
'slotlock.take_rate_token(text, double precision, double precision)',
|
|
258
265
|
'slotlock.register_assistant_tenant(text, timestamptz)',
|
|
259
266
|
'slotlock.sweep_assistant_conversations(integer)',
|
|
267
|
+
'slotlock.list_maintenance_tenant()',
|
|
268
|
+
'slotlock.maintenance_tenants(text)',
|
|
269
|
+
'slotlock.tenant_plan(text)',
|
|
270
|
+
'slotlock.set_tenant_plan(text, text, text, integer, integer, integer, text, text, timestamptz)',
|
|
271
|
+
'slotlock.plan_default()',
|
|
272
|
+
'slotlock.enrol_tenant_plan(text)',
|
|
273
|
+
'slotlock.record_usage(uuid, jsonb)',
|
|
274
|
+
'slotlock.roll_up_usage(text, integer)',
|
|
275
|
+
'slotlock.tenant_usage(text, date, date)',
|
|
276
|
+
'slotlock.tenant_quota_usage(text, date)',
|
|
277
|
+
'slotlock.note_usage_threshold(text, date, text, integer, bigint, bigint)',
|
|
278
|
+
'slotlock.tenant_plan_quotas(text)',
|
|
279
|
+
'slotlock.reset_test_tenant(text)',
|
|
260
280
|
]);
|
|
281
|
+
/** The functions usage metering runs (SLOTLOCK_USAGE=on): serve checks it may, before it starts. */
|
|
282
|
+
export const SLOTLOCK_USAGE_FUNCTIONS = Object.freeze([
|
|
283
|
+
'slotlock.record_usage(uuid, jsonb)',
|
|
284
|
+
'slotlock.roll_up_usage(text, integer)',
|
|
285
|
+
'slotlock.tenant_usage(text, date, date)',
|
|
286
|
+
]);
|
|
287
|
+
/** The functions quotas run (SLOTLOCK_QUOTAS=on, quotas.ts): serve checks it may, before it starts. */
|
|
288
|
+
export const SLOTLOCK_QUOTA_FUNCTIONS = Object.freeze([
|
|
289
|
+
'slotlock.tenant_plan(text)',
|
|
290
|
+
'slotlock.tenant_quota_usage(text, date)',
|
|
291
|
+
'slotlock.note_usage_threshold(text, date, text, integer, bigint, bigint)',
|
|
292
|
+
]);
|
|
293
|
+
/** What a plan's monthly quotas count (quotas.ts): operations run and bookings made. */
|
|
294
|
+
export const SLOTLOCK_QUOTA_METRICS = Object.freeze(['operations', 'bookings']);
|
|
295
|
+
/** The shares of a quota, in percent, at which a tenant is told once a month (usage.threshold_reached). */
|
|
296
|
+
export const SLOTLOCK_QUOTA_NOTICE_PERCENTS = Object.freeze([80, 100]);
|
|
297
|
+
/** The most bookings past the roll-up cursor one quota read counts: more than any plan allows. */
|
|
298
|
+
export const SLOTLOCK_QUOTA_UNROLLED_LIMIT = 100_000;
|
|
261
299
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
300
|
+
* What usage.ts meters, per tenant and day: operations run, bookings and holds committed, webhook
|
|
301
|
+
* deliveries accepted, messages to the dashboard assistant, and resources held (a count each day, not
|
|
302
|
+
* a sum).
|
|
303
|
+
*/
|
|
304
|
+
export const SLOTLOCK_USAGE_METRICS = Object.freeze([
|
|
305
|
+
'operations',
|
|
306
|
+
'bookings',
|
|
307
|
+
'holds',
|
|
308
|
+
'webhook_deliveries',
|
|
309
|
+
'assistant_messages',
|
|
310
|
+
'resources',
|
|
311
|
+
]);
|
|
312
|
+
/** The metrics a server counts and records (slotlock.record_usage); the rest the database counts. */
|
|
313
|
+
export const SLOTLOCK_RECORDED_USAGE_METRICS = Object.freeze([
|
|
314
|
+
'operations',
|
|
315
|
+
'webhook_deliveries',
|
|
316
|
+
'assistant_messages',
|
|
317
|
+
]);
|
|
318
|
+
/** Days a tenant's daily usage is kept: a yearly plan, and a month for disputes after it. */
|
|
319
|
+
export const SLOTLOCK_USAGE_RETENTION_DAYS = 400;
|
|
320
|
+
/**
|
|
321
|
+
* Functions only the deployment role runs: suspending and lifting a suspension, and setting the
|
|
322
|
+
* hosted plan every tenant without one of its own is on. No other role is granted them, so a serving
|
|
323
|
+
* role cannot lift a suspension or change the default plan.
|
|
264
324
|
*/
|
|
265
325
|
export const SLOTLOCK_OPERATOR_FUNCTIONS = Object.freeze([
|
|
266
326
|
'slotlock.suspend_tenant(text, text)',
|
|
267
327
|
'slotlock.unsuspend_tenant(text)',
|
|
328
|
+
'slotlock.set_plan_default(text, integer, integer, integer, timestamptz)',
|
|
268
329
|
]);
|
|
269
330
|
/** Every SECURITY DEFINER function the serving role may execute, by signature. */
|
|
331
|
+
/**
|
|
332
|
+
* Partner accounts (RFC 0003 part 4): what the serving role may call for a partner key and the
|
|
333
|
+
* partner API. A partner, its keys and its contract are made by the schema owner (`slotlock partner`).
|
|
334
|
+
*/
|
|
335
|
+
export const SLOTLOCK_PARTNER_FUNCTIONS = Object.freeze([
|
|
336
|
+
'slotlock.partner_tenant_blocked(text)',
|
|
337
|
+
'slotlock.authenticate_partner_key(bytea)',
|
|
338
|
+
'slotlock.partner_contract(text)',
|
|
339
|
+
'slotlock.put_partner_account(text, text, text)',
|
|
340
|
+
'slotlock.list_partner_accounts(text)',
|
|
341
|
+
'slotlock.get_partner_account(text, text)',
|
|
342
|
+
'slotlock.set_partner_account_suspended(text, text, boolean, text)',
|
|
343
|
+
'slotlock.partner_usage(text, date, date, text)',
|
|
344
|
+
'slotlock.partner_account_tenant(text, text)',
|
|
345
|
+
'slotlock.record_partner_reconciliation(text, date, integer, integer, numeric)',
|
|
346
|
+
]);
|
|
347
|
+
/** The partner tables: owner-only, reached through SLOTLOCK_PARTNER_FUNCTIONS. */
|
|
348
|
+
export const SLOTLOCK_PARTNER_TABLES = Object.freeze([
|
|
349
|
+
'partners',
|
|
350
|
+
'partner_keys',
|
|
351
|
+
'partner_accounts',
|
|
352
|
+
'partner_invoices',
|
|
353
|
+
'partner_month_closes',
|
|
354
|
+
'partner_erased_usage',
|
|
355
|
+
'partner_reconciliations',
|
|
356
|
+
]);
|
|
270
357
|
export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
271
358
|
...SLOTLOCK_API_KEY_FUNCTIONS,
|
|
359
|
+
...SLOTLOCK_PARTNER_FUNCTIONS,
|
|
272
360
|
...SLOTLOCK_DASHBOARD_FUNCTIONS,
|
|
273
361
|
...SLOTLOCK_SCHEMA_FUNCTIONS,
|
|
274
362
|
...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
|
|
@@ -276,6 +364,7 @@ export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
|
276
364
|
/** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
|
|
277
365
|
export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
|
|
278
366
|
...SLOTLOCK_API_KEY_TABLES,
|
|
367
|
+
...SLOTLOCK_PARTNER_TABLES,
|
|
279
368
|
...SLOTLOCK_DASHBOARD_TABLES,
|
|
280
369
|
...SLOTLOCK_SCHEMA_TABLES,
|
|
281
370
|
...SLOTLOCK_TENANT_ADMIN_TABLES,
|
|
@@ -301,15 +390,13 @@ const apiKeySelect = (alias) => ['id', 'tenant_ref', 'name', 'prefix', 'scopes',
|
|
|
301
390
|
.concat(['last_used_at', 'revoked_at'])
|
|
302
391
|
.map((column) => `${alias}.${column}`)
|
|
303
392
|
.join(', ');
|
|
304
|
-
// API keys (api-keys.ts)
|
|
305
|
-
//
|
|
306
|
-
//
|
|
393
|
+
// API keys (api-keys.ts): SHA-256 digests only, never the key. RLS is enabled with no policy and no
|
|
394
|
+
// grants, so only the owner reaches the tables, through the functions below, which the serving role
|
|
395
|
+
// may execute.
|
|
307
396
|
//
|
|
308
|
-
// Each function takes its tenant as an argument
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// stay in api_key_retired_digests (digests only, nothing about the tenant), and a revoked key's row
|
|
312
|
-
// stays in api_keys until erasure, so the unique digest refuses it too.
|
|
397
|
+
// Each function takes its tenant as an argument. A call cannot reach another tenant's keys, read a
|
|
398
|
+
// digest, or reuse a digest rotated out or erased: those stay in api_key_retired_digests (digests
|
|
399
|
+
// only), and a revoked key's row stays until erasure.
|
|
313
400
|
const SLOTLOCK_API_KEYS_DDL = `
|
|
314
401
|
CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
315
402
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -329,7 +416,8 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
329
416
|
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500 AND tenant_ref !~ '[[:cntrl:]]'),
|
|
330
417
|
CONSTRAINT slotlock_api_keys_name_valid
|
|
331
418
|
CHECK (char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'),
|
|
332
|
-
|
|
419
|
+
-- A test environment's key (RFC 0003 G9) says so: slk_test_, and only there (the DO block below).
|
|
420
|
+
CONSTRAINT slotlock_api_keys_prefix_valid CHECK (prefix ~ '^slk_(test_)?[0-9A-Za-z]{8}$'),
|
|
333
421
|
-- A non-empty list of read, write and admin, each once, in that order (api-keys.ts).
|
|
334
422
|
CONSTRAINT slotlock_api_keys_scopes_known
|
|
335
423
|
CHECK (scopes IN (
|
|
@@ -348,6 +436,23 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
348
436
|
CREATE INDEX IF NOT EXISTS slotlock_api_keys_tenant_idx
|
|
349
437
|
ON slotlock.api_keys (tenant_ref, created_at DESC, id DESC);
|
|
350
438
|
ALTER TABLE slotlock.api_keys ENABLE ROW LEVEL SECURITY;
|
|
439
|
+
-- A tenant reference ending in ~test is the test environment of the one before it (RFC 0003 G9):
|
|
440
|
+
-- its keys start slk_test_, and no other tenant's do, so a key says where it acts. Added NOT VALID
|
|
441
|
+
-- (DEPLOYMENT_VALIDATE_CONSTRAINTS); the prefix check it widens is replaced with it.
|
|
442
|
+
DO $slotlock_api_keys_environment$
|
|
443
|
+
BEGIN
|
|
444
|
+
IF NOT EXISTS (
|
|
445
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
446
|
+
WHERE conrelid = 'slotlock.api_keys'::regclass AND conname = 'slotlock_api_keys_environment_valid'
|
|
447
|
+
) THEN
|
|
448
|
+
ALTER TABLE slotlock.api_keys DROP CONSTRAINT IF EXISTS slotlock_api_keys_prefix_valid;
|
|
449
|
+
ALTER TABLE slotlock.api_keys ADD CONSTRAINT slotlock_api_keys_prefix_valid
|
|
450
|
+
CHECK (prefix ~ '^slk_(test_)?[0-9A-Za-z]{8}$') NOT VALID;
|
|
451
|
+
ALTER TABLE slotlock.api_keys ADD CONSTRAINT slotlock_api_keys_environment_valid
|
|
452
|
+
CHECK ((prefix ~ '^slk_test_') = (tenant_ref ~ '~test$')) NOT VALID;
|
|
453
|
+
END IF;
|
|
454
|
+
END
|
|
455
|
+
$slotlock_api_keys_environment$;
|
|
351
456
|
|
|
352
457
|
-- Schemas from 0.1 allow only read and write. The CHECK with admin admits every row the old one
|
|
353
458
|
-- did, so it is added NOT VALID (no table scan under the lock) and the old one dropped in the same
|
|
@@ -423,7 +528,9 @@ BEGIN
|
|
|
423
528
|
WHERE active.tenant_ref = requested_tenant_ref
|
|
424
529
|
AND active.revoked_at IS NULL
|
|
425
530
|
AND (active.expires_at IS NULL OR active.expires_at > now())
|
|
426
|
-
) < ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}
|
|
531
|
+
) < LEAST(${SLOTLOCK_API_KEY_ACTIVE_LIMIT}, COALESCE(
|
|
532
|
+
(SELECT plan.max_api_keys FROM slotlock.tenant_plan(requested_tenant_ref) plan),
|
|
533
|
+
${SLOTLOCK_API_KEY_ACTIVE_LIMIT}))
|
|
427
534
|
AND (SELECT count(*) FROM slotlock.api_keys kept
|
|
428
535
|
WHERE kept.tenant_ref = requested_tenant_ref
|
|
429
536
|
) < ${SLOTLOCK_API_KEY_RETAINED_LIMIT}
|
|
@@ -554,6 +661,190 @@ AS $slotlock_erase_api_keys$
|
|
|
554
661
|
$slotlock_erase_api_keys$;
|
|
555
662
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
|
|
556
663
|
|
|
664
|
+
-- Keys limited to part of their tenant (RFC 0003 G6): the locations (each with every location in
|
|
665
|
+
-- it) and groups whose resources the key reaches, and no other. Set when the key is made and kept
|
|
666
|
+
-- through rotation; gone with the key. Owner-only, reached through the functions below; the
|
|
667
|
+
-- database enforces it (withTenant's slotlock.resource_scope and the resource_scope_v1 policies).
|
|
668
|
+
CREATE TABLE IF NOT EXISTS slotlock.api_key_resource_scopes (
|
|
669
|
+
key_id uuid PRIMARY KEY REFERENCES slotlock.api_keys (id) ON DELETE CASCADE,
|
|
670
|
+
tenant_ref text NOT NULL,
|
|
671
|
+
locations text[] NOT NULL DEFAULT '{}',
|
|
672
|
+
groups text[] NOT NULL DEFAULT '{}',
|
|
673
|
+
CONSTRAINT slotlock_api_key_resource_scopes_valid CHECK (
|
|
674
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
675
|
+
AND cardinality(locations) + cardinality(groups) >= 1
|
|
676
|
+
AND cardinality(locations) <= ${SLOTLOCK_MAX_KEY_SCOPE_REFS}
|
|
677
|
+
AND cardinality(groups) <= ${SLOTLOCK_MAX_KEY_SCOPE_REFS}
|
|
678
|
+
AND array_position(locations, NULL) IS NULL AND array_position(groups, NULL) IS NULL
|
|
679
|
+
)
|
|
680
|
+
);
|
|
681
|
+
ALTER TABLE slotlock.api_key_resource_scopes ENABLE ROW LEVEL SECURITY;
|
|
682
|
+
REVOKE ALL ON slotlock.api_key_resource_scopes FROM PUBLIC;
|
|
683
|
+
|
|
684
|
+
-- Limit one of the tenant's active keys, once: true when set, false when the tenant has no such
|
|
685
|
+
-- active key or it is limited already. Each reference as locations and groups take them.
|
|
686
|
+
CREATE OR REPLACE FUNCTION slotlock.set_api_key_resource_scope(
|
|
687
|
+
requested_tenant_ref text, requested_id uuid, requested_locations text[], requested_groups text[]
|
|
688
|
+
)
|
|
689
|
+
RETURNS boolean
|
|
690
|
+
LANGUAGE plpgsql
|
|
691
|
+
VOLATILE
|
|
692
|
+
SECURITY DEFINER
|
|
693
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
694
|
+
AS $slotlock_set_api_key_resource_scope$
|
|
695
|
+
BEGIN
|
|
696
|
+
IF requested_locations IS NULL OR requested_groups IS NULL
|
|
697
|
+
OR EXISTS (SELECT 1 FROM unnest(requested_locations || requested_groups) named(ref)
|
|
698
|
+
WHERE named.ref IS NULL OR named.ref !~ '^[a-z0-9][a-z0-9._-]{0,99}$') THEN
|
|
699
|
+
RAISE EXCEPTION 'Slotlock key scope references must be location and group references'
|
|
700
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
701
|
+
END IF;
|
|
702
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.api_keys limited
|
|
703
|
+
WHERE limited.tenant_ref = requested_tenant_ref AND limited.id = requested_id
|
|
704
|
+
AND limited.revoked_at IS NULL) THEN
|
|
705
|
+
RETURN false;
|
|
706
|
+
END IF;
|
|
707
|
+
INSERT INTO slotlock.api_key_resource_scopes (key_id, tenant_ref, locations, groups)
|
|
708
|
+
VALUES (
|
|
709
|
+
requested_id, requested_tenant_ref,
|
|
710
|
+
ARRAY(SELECT DISTINCT ref FROM unnest(requested_locations) ref ORDER BY ref),
|
|
711
|
+
ARRAY(SELECT DISTINCT ref FROM unnest(requested_groups) ref ORDER BY ref))
|
|
712
|
+
ON CONFLICT (key_id) DO NOTHING;
|
|
713
|
+
RETURN FOUND;
|
|
714
|
+
END
|
|
715
|
+
$slotlock_set_api_key_resource_scope$;
|
|
716
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[6]} FROM PUBLIC;
|
|
717
|
+
|
|
718
|
+
-- As authenticate_api_key, with the key's scope: null locations and groups for a key with none.
|
|
719
|
+
-- A key of a partner's account whose partner is not active (RFC 0003 P2) authenticates as nothing.
|
|
720
|
+
CREATE OR REPLACE FUNCTION slotlock.authenticate_scoped_api_key(presented_secret_hash bytea)
|
|
721
|
+
RETURNS TABLE (key_id uuid, key_tenant_ref text, key_scopes text[], scope_locations text[], scope_groups text[])
|
|
722
|
+
LANGUAGE plpgsql
|
|
723
|
+
VOLATILE
|
|
724
|
+
SECURITY DEFINER
|
|
725
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
726
|
+
AS $slotlock_authenticate_scoped_api_key$
|
|
727
|
+
BEGIN
|
|
728
|
+
RETURN QUERY
|
|
729
|
+
SELECT found.key_id, found.key_tenant_ref, found.key_scopes, scope.locations, scope.groups
|
|
730
|
+
FROM slotlock.authenticate_api_key(presented_secret_hash) found
|
|
731
|
+
LEFT JOIN slotlock.api_key_resource_scopes scope ON scope.key_id = found.key_id
|
|
732
|
+
WHERE NOT slotlock.partner_tenant_blocked(found.key_tenant_ref);
|
|
733
|
+
END
|
|
734
|
+
$slotlock_authenticate_scoped_api_key$;
|
|
735
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[7]} FROM PUBLIC;
|
|
736
|
+
|
|
737
|
+
-- The scopes of the tenant's limited keys, for its key list.
|
|
738
|
+
CREATE OR REPLACE FUNCTION slotlock.list_api_key_resource_scopes(requested_tenant_ref text)
|
|
739
|
+
RETURNS TABLE (key_id uuid, locations text[], groups text[])
|
|
740
|
+
LANGUAGE sql
|
|
741
|
+
STABLE
|
|
742
|
+
SECURITY DEFINER
|
|
743
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
744
|
+
AS $slotlock_list_api_key_resource_scopes$
|
|
745
|
+
SELECT scope.key_id, scope.locations, scope.groups
|
|
746
|
+
FROM slotlock.api_key_resource_scopes scope
|
|
747
|
+
WHERE scope.tenant_ref = requested_tenant_ref;
|
|
748
|
+
$slotlock_list_api_key_resource_scopes$;
|
|
749
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[8]} FROM PUBLIC;
|
|
750
|
+
|
|
751
|
+
-- What one agent's key may do, below the server's limits (RFC 0004 A5): its live holds and the
|
|
752
|
+
-- hours they hold, its requests a minute, and whether its bookings wait for a person. Null is the
|
|
753
|
+
-- server's. Kept through rotation (the key keeps its id); gone with the key. Owner-only, reached
|
|
754
|
+
-- through the functions below.
|
|
755
|
+
CREATE TABLE IF NOT EXISTS slotlock.api_key_limits (
|
|
756
|
+
key_id uuid PRIMARY KEY REFERENCES slotlock.api_keys (id) ON DELETE CASCADE,
|
|
757
|
+
tenant_ref text NOT NULL,
|
|
758
|
+
max_holds integer,
|
|
759
|
+
max_held_hours integer,
|
|
760
|
+
requests_per_minute integer,
|
|
761
|
+
requires_approval boolean NOT NULL DEFAULT false,
|
|
762
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
763
|
+
CONSTRAINT slotlock_api_key_limits_valid CHECK (
|
|
764
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
765
|
+
AND (max_holds IS NULL OR max_holds BETWEEN 1 AND 10000)
|
|
766
|
+
AND (max_held_hours IS NULL OR max_held_hours BETWEEN 1 AND 876000)
|
|
767
|
+
AND (requests_per_minute IS NULL OR requests_per_minute BETWEEN 1 AND 100000)
|
|
768
|
+
)
|
|
769
|
+
);
|
|
770
|
+
ALTER TABLE slotlock.api_key_limits ENABLE ROW LEVEL SECURITY;
|
|
771
|
+
REVOKE ALL ON slotlock.api_key_limits FROM PUBLIC;
|
|
772
|
+
|
|
773
|
+
-- Set one of the tenant's active keys' limits, replacing what it had; none at all removes them.
|
|
774
|
+
-- False when the tenant has no such active key.
|
|
775
|
+
CREATE OR REPLACE FUNCTION slotlock.set_api_key_limits(
|
|
776
|
+
requested_tenant_ref text, requested_id uuid, requested_max_holds integer,
|
|
777
|
+
requested_max_held_hours integer, requested_requests_per_minute integer, requested_approval boolean
|
|
778
|
+
)
|
|
779
|
+
RETURNS boolean
|
|
780
|
+
LANGUAGE plpgsql
|
|
781
|
+
VOLATILE
|
|
782
|
+
SECURITY DEFINER
|
|
783
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
784
|
+
AS $slotlock_set_api_key_limits$
|
|
785
|
+
BEGIN
|
|
786
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.api_keys limited
|
|
787
|
+
WHERE limited.tenant_ref = requested_tenant_ref AND limited.id = requested_id
|
|
788
|
+
AND limited.revoked_at IS NULL) THEN
|
|
789
|
+
RETURN false;
|
|
790
|
+
END IF;
|
|
791
|
+
IF requested_max_holds IS NULL AND requested_max_held_hours IS NULL
|
|
792
|
+
AND requested_requests_per_minute IS NULL AND requested_approval IS NOT TRUE THEN
|
|
793
|
+
DELETE FROM slotlock.api_key_limits WHERE key_id = requested_id;
|
|
794
|
+
RETURN true;
|
|
795
|
+
END IF;
|
|
796
|
+
INSERT INTO slotlock.api_key_limits (
|
|
797
|
+
key_id, tenant_ref, max_holds, max_held_hours, requests_per_minute, requires_approval
|
|
798
|
+
) VALUES (
|
|
799
|
+
requested_id, requested_tenant_ref, requested_max_holds, requested_max_held_hours,
|
|
800
|
+
requested_requests_per_minute, requested_approval IS TRUE
|
|
801
|
+
)
|
|
802
|
+
ON CONFLICT (key_id) DO UPDATE
|
|
803
|
+
SET max_holds = EXCLUDED.max_holds, max_held_hours = EXCLUDED.max_held_hours,
|
|
804
|
+
requests_per_minute = EXCLUDED.requests_per_minute,
|
|
805
|
+
requires_approval = EXCLUDED.requires_approval, updated_at = statement_timestamp();
|
|
806
|
+
RETURN true;
|
|
807
|
+
END
|
|
808
|
+
$slotlock_set_api_key_limits$;
|
|
809
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[9]} FROM PUBLIC;
|
|
810
|
+
|
|
811
|
+
-- The limits of the tenant's keys that have any, for its key list.
|
|
812
|
+
CREATE OR REPLACE FUNCTION slotlock.list_api_key_limits(requested_tenant_ref text)
|
|
813
|
+
RETURNS TABLE (key_id uuid, max_holds integer, max_held_hours integer, requests_per_minute integer, requires_approval boolean)
|
|
814
|
+
LANGUAGE sql
|
|
815
|
+
STABLE
|
|
816
|
+
SECURITY DEFINER
|
|
817
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
818
|
+
AS $slotlock_list_api_key_limits$
|
|
819
|
+
SELECT limits.key_id, limits.max_holds, limits.max_held_hours, limits.requests_per_minute,
|
|
820
|
+
limits.requires_approval
|
|
821
|
+
FROM slotlock.api_key_limits limits
|
|
822
|
+
WHERE limits.tenant_ref = requested_tenant_ref;
|
|
823
|
+
$slotlock_list_api_key_limits$;
|
|
824
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[10]} FROM PUBLIC;
|
|
825
|
+
|
|
826
|
+
-- As authenticate_scoped_api_key, with the key's limits (null for the server's).
|
|
827
|
+
CREATE OR REPLACE FUNCTION slotlock.authenticate_limited_api_key(presented_secret_hash bytea)
|
|
828
|
+
RETURNS TABLE (
|
|
829
|
+
key_id uuid, key_tenant_ref text, key_scopes text[], scope_locations text[], scope_groups text[],
|
|
830
|
+
max_holds integer, max_held_hours integer, requests_per_minute integer, requires_approval boolean
|
|
831
|
+
)
|
|
832
|
+
LANGUAGE plpgsql
|
|
833
|
+
VOLATILE
|
|
834
|
+
SECURITY DEFINER
|
|
835
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
836
|
+
AS $slotlock_authenticate_limited_api_key$
|
|
837
|
+
BEGIN
|
|
838
|
+
RETURN QUERY
|
|
839
|
+
SELECT found.key_id, found.key_tenant_ref, found.key_scopes, found.scope_locations,
|
|
840
|
+
found.scope_groups, limits.max_holds, limits.max_held_hours, limits.requests_per_minute,
|
|
841
|
+
COALESCE(limits.requires_approval, false)
|
|
842
|
+
FROM slotlock.authenticate_scoped_api_key(presented_secret_hash) found
|
|
843
|
+
LEFT JOIN slotlock.api_key_limits limits ON limits.key_id = found.key_id;
|
|
844
|
+
END
|
|
845
|
+
$slotlock_authenticate_limited_api_key$;
|
|
846
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[11]} FROM PUBLIC;
|
|
847
|
+
|
|
557
848
|
-- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
|
|
558
849
|
-- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
|
|
559
850
|
-- functions above write keys, as the schema's owner, which forced row-level security on the feed
|
|
@@ -603,19 +894,14 @@ CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
|
|
|
603
894
|
AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
|
|
604
895
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
|
|
605
896
|
`;
|
|
606
|
-
// The dashboard's records (dashboard.ts)
|
|
607
|
-
// (
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// nothing here names a tenant. Row-level security is enabled with no policy and no role is granted
|
|
611
|
-
// anything, so the serving role reaches the table only through the two functions below.
|
|
897
|
+
// The dashboard's shared records (dashboard.ts): finished sign-ins (a callback works once), sent
|
|
898
|
+
// forms (run once) and signed-out sessions (a copied cookie stays out), each a SHA-256 digest; form
|
|
899
|
+
// records also carry the sender's digest so the database can cap them. RLS with no policy and no
|
|
900
|
+
// grants: reached only through the two functions below.
|
|
612
901
|
//
|
|
613
|
-
// The database's clock decides
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
// token. So the functions refuse a token whose expiry the database's clock has passed, or that
|
|
617
|
-
// claims to outlive a day (no dashboard token lives longer than 12 hours), and keep each record
|
|
618
|
-
// until exactly that expiry: while the database would accept the token, its record is there.
|
|
902
|
+
// The database's clock decides: server clocks may drift, so the functions refuse a token the
|
|
903
|
+
// database's clock says has expired, or one claiming to outlive a day, and keep each record until
|
|
904
|
+
// exactly that expiry.
|
|
619
905
|
const SLOTLOCK_DASHBOARD_DDL = `
|
|
620
906
|
CREATE TABLE IF NOT EXISTS slotlock.dashboard_tokens (
|
|
621
907
|
kind text NOT NULL,
|
|
@@ -735,12 +1021,84 @@ AS $slotlock_dashboard_session_ended$
|
|
|
735
1021
|
AND ended.expires_at > now());
|
|
736
1022
|
$slotlock_dashboard_session_ended$;
|
|
737
1023
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
|
|
1024
|
+
|
|
1025
|
+
-- GitHub organizations an owner turned Slotlock on for (Teams on Cloud, RFC 0003, with
|
|
1026
|
+
-- SLOTLOCK_DASHBOARD_ORGS=enabled): their github-org:<id> tenants are served while enabled. Turning
|
|
1027
|
+
-- one off stops its keys at once and keeps its data, which the organization can turn back on to
|
|
1028
|
+
-- reach, or erase. Owner-only; read and written through the functions below.
|
|
1029
|
+
CREATE TABLE IF NOT EXISTS slotlock.dashboard_organizations (
|
|
1030
|
+
org_id text PRIMARY KEY,
|
|
1031
|
+
login text NOT NULL,
|
|
1032
|
+
enabled boolean NOT NULL,
|
|
1033
|
+
changed_by text NOT NULL,
|
|
1034
|
+
changed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1035
|
+
CONSTRAINT slotlock_dashboard_organizations_valid CHECK (
|
|
1036
|
+
org_id ~ '^[1-9][0-9]{0,19}$'
|
|
1037
|
+
AND login ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$'
|
|
1038
|
+
AND changed_by ~ '^github:[1-9][0-9]{0,19}$'
|
|
1039
|
+
)
|
|
1040
|
+
);
|
|
1041
|
+
ALTER TABLE slotlock.dashboard_organizations ENABLE ROW LEVEL SECURITY;
|
|
1042
|
+
REVOKE ALL ON slotlock.dashboard_organizations FROM PUBLIC;
|
|
1043
|
+
|
|
1044
|
+
-- Of these organization ids (comma-separated), the ones turned on.
|
|
1045
|
+
CREATE OR REPLACE FUNCTION slotlock.enabled_organizations(requested_ids text)
|
|
1046
|
+
RETURNS SETOF text
|
|
1047
|
+
LANGUAGE sql
|
|
1048
|
+
STABLE
|
|
1049
|
+
SECURITY DEFINER
|
|
1050
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1051
|
+
AS $slotlock_enabled_organizations$
|
|
1052
|
+
SELECT org.org_id FROM slotlock.dashboard_organizations org
|
|
1053
|
+
WHERE org.enabled
|
|
1054
|
+
AND org.org_id = ANY (pg_catalog.string_to_array(requested_ids, ','));
|
|
1055
|
+
$slotlock_enabled_organizations$;
|
|
1056
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[2]} FROM PUBLIC;
|
|
1057
|
+
|
|
1058
|
+
-- Turn Slotlock on for an organization; true when it was not on. Who may (an owner of it, read from
|
|
1059
|
+
-- GitHub at sign-in) is the dashboard's to check.
|
|
1060
|
+
CREATE OR REPLACE FUNCTION slotlock.enable_organization(requested_org text, requested_login text, requested_by text)
|
|
1061
|
+
RETURNS boolean
|
|
1062
|
+
LANGUAGE plpgsql
|
|
1063
|
+
VOLATILE
|
|
1064
|
+
SECURITY DEFINER
|
|
1065
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1066
|
+
AS $slotlock_enable_organization$
|
|
1067
|
+
DECLARE
|
|
1068
|
+
was boolean;
|
|
1069
|
+
BEGIN
|
|
1070
|
+
SELECT org.enabled INTO was FROM slotlock.dashboard_organizations org
|
|
1071
|
+
WHERE org.org_id = requested_org FOR UPDATE;
|
|
1072
|
+
INSERT INTO slotlock.dashboard_organizations AS org (org_id, login, enabled, changed_by)
|
|
1073
|
+
VALUES (requested_org, requested_login, true, requested_by)
|
|
1074
|
+
ON CONFLICT (org_id) DO UPDATE
|
|
1075
|
+
SET login = EXCLUDED.login, enabled = true,
|
|
1076
|
+
changed_by = CASE WHEN org.enabled THEN org.changed_by ELSE EXCLUDED.changed_by END,
|
|
1077
|
+
changed_at = CASE WHEN org.enabled THEN org.changed_at ELSE statement_timestamp() END;
|
|
1078
|
+
RETURN was IS NOT TRUE;
|
|
1079
|
+
END
|
|
1080
|
+
$slotlock_enable_organization$;
|
|
1081
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[3]} FROM PUBLIC;
|
|
1082
|
+
|
|
1083
|
+
-- Turn it off; true when it was on.
|
|
1084
|
+
CREATE OR REPLACE FUNCTION slotlock.disable_organization(requested_org text, requested_by text)
|
|
1085
|
+
RETURNS boolean
|
|
1086
|
+
LANGUAGE sql
|
|
1087
|
+
VOLATILE
|
|
1088
|
+
SECURITY DEFINER
|
|
1089
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1090
|
+
AS $slotlock_disable_organization$
|
|
1091
|
+
UPDATE slotlock.dashboard_organizations org
|
|
1092
|
+
SET enabled = false, changed_by = requested_by, changed_at = statement_timestamp()
|
|
1093
|
+
WHERE org.org_id = requested_org AND org.enabled
|
|
1094
|
+
RETURNING true;
|
|
1095
|
+
$slotlock_disable_organization$;
|
|
1096
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[4]} FROM PUBLIC;
|
|
738
1097
|
`;
|
|
739
|
-
// The schema's version
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
// anything, so a server reads the version only through installed_schema_version().
|
|
1098
|
+
// The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
|
|
1099
|
+
// is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
|
|
1100
|
+
// skip without locking. RLS with no policy and no grants: read only through
|
|
1101
|
+
// installed_schema_version().
|
|
744
1102
|
const SLOTLOCK_SCHEMA_VERSION_DDL = `
|
|
745
1103
|
CREATE TABLE IF NOT EXISTS slotlock.schema_version (
|
|
746
1104
|
singleton boolean PRIMARY KEY DEFAULT true,
|
|
@@ -772,16 +1130,14 @@ AS $slotlock_installed_schema_version$
|
|
|
772
1130
|
$slotlock_installed_schema_version$;
|
|
773
1131
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
|
|
774
1132
|
`;
|
|
775
|
-
// Tenant suspension and erasure. A suspended tenant
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
|
|
780
|
-
// suspended.
|
|
1133
|
+
// Tenant suspension and erasure. A suspended tenant may read and write nothing; its keys still
|
|
1134
|
+
// authenticate but it gets no new key. The table names tenants and reasons, so RLS with no policy
|
|
1135
|
+
// and no grants: servers read it only through tenant_suspended(), and only the deployment role
|
|
1136
|
+
// (owner) suspends. Erasure keeps the suspension.
|
|
781
1137
|
//
|
|
782
|
-
// Every server transaction for a tenant holds
|
|
783
|
-
// suspension
|
|
784
|
-
//
|
|
1138
|
+
// Every server transaction for a tenant holds its lock shared before reading the suspension;
|
|
1139
|
+
// suspension and erasure take it exclusively, so a suspension waits for transactions in flight and
|
|
1140
|
+
// every later one sees it.
|
|
785
1141
|
const SLOTLOCK_TENANT_ADMIN_DDL = `
|
|
786
1142
|
CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
|
|
787
1143
|
tenant_ref text PRIMARY KEY,
|
|
@@ -802,13 +1158,689 @@ STABLE
|
|
|
802
1158
|
SECURITY DEFINER
|
|
803
1159
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
804
1160
|
AS $slotlock_tenant_suspended$
|
|
1161
|
+
-- Its own suspension, its live tenant's (a test environment, RFC 0003 G9), or its partner's (a
|
|
1162
|
+
-- partner's account, RFC 0003 P2).
|
|
805
1163
|
SELECT EXISTS (
|
|
806
1164
|
SELECT 1 FROM slotlock.tenant_suspensions suspended
|
|
807
|
-
WHERE suspended.tenant_ref
|
|
808
|
-
);
|
|
1165
|
+
WHERE suspended.tenant_ref IN (requested_tenant_ref, regexp_replace(requested_tenant_ref, '~test$', ''))
|
|
1166
|
+
) OR slotlock.partner_tenant_blocked(requested_tenant_ref);
|
|
809
1167
|
$slotlock_tenant_suspended$;
|
|
810
1168
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[0]} FROM PUBLIC;
|
|
811
1169
|
|
|
1170
|
+
-- A hosted tenant's plan (plans.ts): the limits it buys, set from its Stripe subscription. A tenant
|
|
1171
|
+
-- with no row is on the deployment's default plan (plan_defaults) once that applies, and otherwise
|
|
1172
|
+
-- has no plan limits; self-hosted Slotlock writes neither.
|
|
1173
|
+
CREATE TABLE IF NOT EXISTS slotlock.tenant_plans (
|
|
1174
|
+
tenant_ref text PRIMARY KEY,
|
|
1175
|
+
plan text NOT NULL,
|
|
1176
|
+
status text NOT NULL,
|
|
1177
|
+
max_resources integer NOT NULL,
|
|
1178
|
+
max_api_keys integer NOT NULL,
|
|
1179
|
+
max_webhook_endpoints integer NOT NULL,
|
|
1180
|
+
stripe_customer text,
|
|
1181
|
+
stripe_subscription text,
|
|
1182
|
+
current_period_end timestamptz,
|
|
1183
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1184
|
+
CONSTRAINT slotlock_tenant_plans_tenant_ref_valid
|
|
1185
|
+
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
|
|
1186
|
+
CONSTRAINT slotlock_tenant_plans_plan_valid CHECK (plan IN ('free', 'developer', 'team')),
|
|
1187
|
+
CONSTRAINT slotlock_tenant_plans_status_valid CHECK (status IN ('active', 'past_due', 'canceled')),
|
|
1188
|
+
CONSTRAINT slotlock_tenant_plans_limits_valid CHECK (
|
|
1189
|
+
max_resources BETWEEN 1 AND 100000
|
|
1190
|
+
AND max_api_keys BETWEEN 1 AND ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}
|
|
1191
|
+
AND max_webhook_endpoints BETWEEN 1 AND 1000
|
|
1192
|
+
),
|
|
1193
|
+
CONSTRAINT slotlock_tenant_plans_stripe_valid CHECK (
|
|
1194
|
+
(stripe_customer IS NULL OR stripe_customer ~ '^cus_[A-Za-z0-9]{1,250}$')
|
|
1195
|
+
AND (stripe_subscription IS NULL OR stripe_subscription ~ '^sub_[A-Za-z0-9]{1,250}$')
|
|
1196
|
+
)
|
|
1197
|
+
);
|
|
1198
|
+
ALTER TABLE slotlock.tenant_plans ENABLE ROW LEVEL SECURITY;
|
|
1199
|
+
-- A partner's account (RFC 0003 P4) is on one of its contract's plans: its own name and numbers,
|
|
1200
|
+
-- quotas included, set by put_partner_account. Every other row is a plan Pylota sells.
|
|
1201
|
+
ALTER TABLE slotlock.tenant_plans
|
|
1202
|
+
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'direct',
|
|
1203
|
+
ADD COLUMN IF NOT EXISTS partner_id text,
|
|
1204
|
+
ADD COLUMN IF NOT EXISTS quota_operations bigint,
|
|
1205
|
+
ADD COLUMN IF NOT EXISTS quota_bookings bigint,
|
|
1206
|
+
ADD COLUMN IF NOT EXISTS quota_stop_percent integer;
|
|
1207
|
+
DO $slotlock_tenant_plans_source$
|
|
1208
|
+
BEGIN
|
|
1209
|
+
-- Replaced too when it predates the Team plan (Teams on Cloud), whose name it must take.
|
|
1210
|
+
IF NOT EXISTS (
|
|
1211
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
1212
|
+
WHERE conrelid = 'slotlock.tenant_plans'::regclass AND conname = 'slotlock_tenant_plans_source_valid'
|
|
1213
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%team%'
|
|
1214
|
+
) THEN
|
|
1215
|
+
ALTER TABLE slotlock.tenant_plans DROP CONSTRAINT IF EXISTS slotlock_tenant_plans_source_valid;
|
|
1216
|
+
-- Wider than the plan check it replaces, so added NOT VALID (DEPLOYMENT_VALIDATE_CONSTRAINTS).
|
|
1217
|
+
ALTER TABLE slotlock.tenant_plans DROP CONSTRAINT IF EXISTS slotlock_tenant_plans_plan_valid;
|
|
1218
|
+
ALTER TABLE slotlock.tenant_plans ADD CONSTRAINT slotlock_tenant_plans_source_valid CHECK (
|
|
1219
|
+
(source = 'direct' AND partner_id IS NULL AND plan IN ('free', 'developer', 'team')
|
|
1220
|
+
AND quota_operations IS NULL AND quota_bookings IS NULL AND quota_stop_percent IS NULL)
|
|
1221
|
+
OR (source = 'partner' AND partner_id ~ '^[a-z][a-z0-9-]{1,39}$' AND plan ~ '^[a-z][a-z0-9_]{0,40}$'
|
|
1222
|
+
AND quota_operations BETWEEN 1 AND 1000000000000 AND quota_bookings BETWEEN 1 AND 1000000000000
|
|
1223
|
+
AND quota_stop_percent BETWEEN 100 AND 1000)
|
|
1224
|
+
) NOT VALID;
|
|
1225
|
+
END IF;
|
|
1226
|
+
END
|
|
1227
|
+
$slotlock_tenant_plans_source$;
|
|
1228
|
+
REVOKE ALL ON slotlock.tenant_plans FROM PUBLIC;
|
|
1229
|
+
|
|
1230
|
+
-- The plan a hosted deployment puts every tenant without a row of its own on, from applies_from:
|
|
1231
|
+
-- one row or none. Both servers read it through tenant_plan, so a limit holds whichever one a write
|
|
1232
|
+
-- comes through. Only the deployment role sets it (set_plan_default, \`slotlock plan default\`).
|
|
1233
|
+
CREATE TABLE IF NOT EXISTS slotlock.plan_defaults (
|
|
1234
|
+
only_row boolean PRIMARY KEY DEFAULT true,
|
|
1235
|
+
plan text NOT NULL,
|
|
1236
|
+
max_resources integer NOT NULL,
|
|
1237
|
+
max_api_keys integer NOT NULL,
|
|
1238
|
+
max_webhook_endpoints integer NOT NULL,
|
|
1239
|
+
applies_from timestamptz NOT NULL,
|
|
1240
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1241
|
+
CONSTRAINT slotlock_plan_defaults_one_row CHECK (only_row),
|
|
1242
|
+
CONSTRAINT slotlock_plan_defaults_plan_valid CHECK (plan IN ('free', 'developer')),
|
|
1243
|
+
CONSTRAINT slotlock_plan_defaults_limits_valid CHECK (
|
|
1244
|
+
max_resources BETWEEN 1 AND 100000
|
|
1245
|
+
AND max_api_keys BETWEEN 1 AND ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}
|
|
1246
|
+
AND max_webhook_endpoints BETWEEN 1 AND 1000
|
|
1247
|
+
)
|
|
1248
|
+
);
|
|
1249
|
+
ALTER TABLE slotlock.plan_defaults ENABLE ROW LEVEL SECURITY;
|
|
1250
|
+
REVOKE ALL ON slotlock.plan_defaults FROM PUBLIC;
|
|
1251
|
+
|
|
1252
|
+
-- The tenant's own plan, or the default once it applies; no row when neither does.
|
|
1253
|
+
CREATE OR REPLACE FUNCTION slotlock.tenant_plan(requested_tenant_ref text)
|
|
1254
|
+
RETURNS TABLE (
|
|
1255
|
+
plan text, status text, max_resources integer, max_api_keys integer,
|
|
1256
|
+
max_webhook_endpoints integer, stripe_customer text, stripe_subscription text,
|
|
1257
|
+
current_period_end timestamptz
|
|
1258
|
+
)
|
|
1259
|
+
LANGUAGE sql
|
|
1260
|
+
STABLE
|
|
1261
|
+
SECURITY DEFINER
|
|
1262
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1263
|
+
AS $slotlock_tenant_plan$
|
|
1264
|
+
-- A test environment (<tenant>~test, RFC 0003 G9) is on its live tenant's plan, with no billing.
|
|
1265
|
+
WITH live AS (
|
|
1266
|
+
SELECT regexp_replace(requested_tenant_ref, '~test$', '') AS tenant_ref,
|
|
1267
|
+
requested_tenant_ref ~ '~test$' AS test
|
|
1268
|
+
)
|
|
1269
|
+
SELECT current.plan, current.status, current.max_resources, current.max_api_keys,
|
|
1270
|
+
current.max_webhook_endpoints,
|
|
1271
|
+
CASE WHEN live.test THEN NULL ELSE current.stripe_customer END,
|
|
1272
|
+
CASE WHEN live.test THEN NULL ELSE current.stripe_subscription END,
|
|
1273
|
+
CASE WHEN live.test THEN NULL ELSE current.current_period_end END
|
|
1274
|
+
FROM live JOIN slotlock.tenant_plans current ON current.tenant_ref = live.tenant_ref
|
|
1275
|
+
UNION ALL
|
|
1276
|
+
SELECT fallback.plan, 'active', fallback.max_resources, fallback.max_api_keys,
|
|
1277
|
+
fallback.max_webhook_endpoints, NULL, NULL, NULL
|
|
1278
|
+
FROM live, slotlock.plan_defaults fallback
|
|
1279
|
+
WHERE fallback.applies_from <= statement_timestamp()
|
|
1280
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans current
|
|
1281
|
+
WHERE current.tenant_ref = live.tenant_ref);
|
|
1282
|
+
$slotlock_tenant_plan$;
|
|
1283
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[10]} FROM PUBLIC;
|
|
1284
|
+
|
|
1285
|
+
-- The default plan and when it applies, for the dashboard and \`slotlock plan status\`.
|
|
1286
|
+
CREATE OR REPLACE FUNCTION slotlock.plan_default()
|
|
1287
|
+
RETURNS TABLE (
|
|
1288
|
+
plan text, max_resources integer, max_api_keys integer, max_webhook_endpoints integer,
|
|
1289
|
+
applies_from timestamptz
|
|
1290
|
+
)
|
|
1291
|
+
LANGUAGE sql
|
|
1292
|
+
STABLE
|
|
1293
|
+
SECURITY DEFINER
|
|
1294
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1295
|
+
AS $slotlock_plan_default$
|
|
1296
|
+
SELECT fallback.plan, fallback.max_resources, fallback.max_api_keys,
|
|
1297
|
+
fallback.max_webhook_endpoints, fallback.applies_from
|
|
1298
|
+
FROM slotlock.plan_defaults fallback;
|
|
1299
|
+
$slotlock_plan_default$;
|
|
1300
|
+
|
|
1301
|
+
-- The tenant's plan and its own quotas, when it has them (a partner's account, RFC 0003 P4): null
|
|
1302
|
+
-- quotas are the plan's (plans.ts SLOTLOCK_PLAN_QUOTAS).
|
|
1303
|
+
CREATE OR REPLACE FUNCTION slotlock.tenant_plan_quotas(requested_tenant_ref text)
|
|
1304
|
+
RETURNS TABLE (plan text, operations bigint, bookings bigint, stop_percent integer)
|
|
1305
|
+
LANGUAGE sql
|
|
1306
|
+
STABLE
|
|
1307
|
+
SECURITY DEFINER
|
|
1308
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1309
|
+
AS $slotlock_tenant_plan_quotas$
|
|
1310
|
+
SELECT given.plan, own.quota_operations, own.quota_bookings, own.quota_stop_percent
|
|
1311
|
+
FROM slotlock.tenant_plan(requested_tenant_ref) given
|
|
1312
|
+
LEFT JOIN slotlock.tenant_plans own
|
|
1313
|
+
ON own.tenant_ref = regexp_replace(requested_tenant_ref, '~test$', '');
|
|
1314
|
+
$slotlock_tenant_plan_quotas$;
|
|
1315
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[19]} FROM PUBLIC;
|
|
1316
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[12]} FROM PUBLIC;
|
|
1317
|
+
|
|
1318
|
+
-- Before the default applies to every tenant, a new one is put on it at once: one with no API key
|
|
1319
|
+
-- (ever), no row in the feed or anything maintenance lists, and no webhook. Existing tenants keep
|
|
1320
|
+
-- what they had until applies_from, as the notice given to them says. True when a plan was written.
|
|
1321
|
+
-- Under the key lock, so a key being issued is either counted here or sees the plan.
|
|
1322
|
+
CREATE OR REPLACE FUNCTION slotlock.enrol_tenant_plan(requested_tenant_ref text)
|
|
1323
|
+
RETURNS boolean
|
|
1324
|
+
LANGUAGE plpgsql
|
|
1325
|
+
VOLATILE
|
|
1326
|
+
SECURITY DEFINER
|
|
1327
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1328
|
+
AS $slotlock_enrol_tenant_plan$
|
|
1329
|
+
BEGIN
|
|
1330
|
+
-- A test environment's plan is its live tenant's (RFC 0003 G9): that is the one enrolled.
|
|
1331
|
+
requested_tenant_ref := regexp_replace(requested_tenant_ref, '~test$', '');
|
|
1332
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
1333
|
+
INSERT INTO slotlock.tenant_plans (
|
|
1334
|
+
tenant_ref, plan, status, max_resources, max_api_keys, max_webhook_endpoints
|
|
1335
|
+
)
|
|
1336
|
+
SELECT requested_tenant_ref, fallback.plan, 'active', fallback.max_resources,
|
|
1337
|
+
fallback.max_api_keys, fallback.max_webhook_endpoints
|
|
1338
|
+
FROM slotlock.plan_defaults fallback
|
|
1339
|
+
WHERE fallback.applies_from > statement_timestamp()
|
|
1340
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.api_keys issued
|
|
1341
|
+
WHERE issued.tenant_ref = requested_tenant_ref)
|
|
1342
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants listed
|
|
1343
|
+
WHERE listed.tenant_ref = requested_tenant_ref)
|
|
1344
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.webhook_tenants hooked
|
|
1345
|
+
WHERE hooked.tenant_ref = requested_tenant_ref)
|
|
1346
|
+
ON CONFLICT (tenant_ref) DO NOTHING;
|
|
1347
|
+
RETURN FOUND;
|
|
1348
|
+
END
|
|
1349
|
+
$slotlock_enrol_tenant_plan$;
|
|
1350
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[13]} FROM PUBLIC;
|
|
1351
|
+
|
|
1352
|
+
-- Sets the default plan, or removes it with a null plan. Runs as its caller: only the deployment
|
|
1353
|
+
-- role, which owns the table, can.
|
|
1354
|
+
CREATE OR REPLACE FUNCTION slotlock.set_plan_default(
|
|
1355
|
+
requested_plan text,
|
|
1356
|
+
requested_max_resources integer,
|
|
1357
|
+
requested_max_api_keys integer,
|
|
1358
|
+
requested_max_webhook_endpoints integer,
|
|
1359
|
+
requested_applies_from timestamptz
|
|
1360
|
+
)
|
|
1361
|
+
RETURNS void
|
|
1362
|
+
LANGUAGE plpgsql
|
|
1363
|
+
VOLATILE
|
|
1364
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1365
|
+
AS $slotlock_set_plan_default$
|
|
1366
|
+
BEGIN
|
|
1367
|
+
IF requested_plan IS NULL THEN
|
|
1368
|
+
DELETE FROM slotlock.plan_defaults;
|
|
1369
|
+
RETURN;
|
|
1370
|
+
END IF;
|
|
1371
|
+
INSERT INTO slotlock.plan_defaults AS current (
|
|
1372
|
+
only_row, plan, max_resources, max_api_keys, max_webhook_endpoints, applies_from, updated_at
|
|
1373
|
+
)
|
|
1374
|
+
VALUES (
|
|
1375
|
+
true, requested_plan, requested_max_resources, requested_max_api_keys,
|
|
1376
|
+
requested_max_webhook_endpoints, requested_applies_from, statement_timestamp()
|
|
1377
|
+
)
|
|
1378
|
+
ON CONFLICT (only_row) DO UPDATE
|
|
1379
|
+
SET plan = EXCLUDED.plan,
|
|
1380
|
+
max_resources = EXCLUDED.max_resources,
|
|
1381
|
+
max_api_keys = EXCLUDED.max_api_keys,
|
|
1382
|
+
max_webhook_endpoints = EXCLUDED.max_webhook_endpoints,
|
|
1383
|
+
applies_from = EXCLUDED.applies_from,
|
|
1384
|
+
updated_at = EXCLUDED.updated_at;
|
|
1385
|
+
END
|
|
1386
|
+
$slotlock_set_plan_default$;
|
|
1387
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[2]} FROM PUBLIC;
|
|
1388
|
+
|
|
1389
|
+
-- Sets the tenant's plan as its billing says it is now (the dashboard's Stripe webhook). Under the
|
|
1390
|
+
-- key lock, so a key being issued counts against the plan before or after, never between.
|
|
1391
|
+
CREATE OR REPLACE FUNCTION slotlock.set_tenant_plan(
|
|
1392
|
+
requested_tenant_ref text,
|
|
1393
|
+
requested_plan text,
|
|
1394
|
+
requested_status text,
|
|
1395
|
+
requested_max_resources integer,
|
|
1396
|
+
requested_max_api_keys integer,
|
|
1397
|
+
requested_max_webhook_endpoints integer,
|
|
1398
|
+
requested_stripe_customer text,
|
|
1399
|
+
requested_stripe_subscription text,
|
|
1400
|
+
requested_period_end timestamptz
|
|
1401
|
+
)
|
|
1402
|
+
RETURNS void
|
|
1403
|
+
LANGUAGE plpgsql
|
|
1404
|
+
VOLATILE
|
|
1405
|
+
SECURITY DEFINER
|
|
1406
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1407
|
+
AS $slotlock_set_tenant_plan$
|
|
1408
|
+
BEGIN
|
|
1409
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
1410
|
+
INSERT INTO slotlock.tenant_plans AS current (
|
|
1411
|
+
tenant_ref, plan, status, max_resources, max_api_keys, max_webhook_endpoints,
|
|
1412
|
+
stripe_customer, stripe_subscription, current_period_end, updated_at
|
|
1413
|
+
)
|
|
1414
|
+
VALUES (
|
|
1415
|
+
requested_tenant_ref, requested_plan, requested_status, requested_max_resources,
|
|
1416
|
+
requested_max_api_keys, requested_max_webhook_endpoints, requested_stripe_customer,
|
|
1417
|
+
requested_stripe_subscription, requested_period_end, statement_timestamp()
|
|
1418
|
+
)
|
|
1419
|
+
ON CONFLICT (tenant_ref) DO UPDATE
|
|
1420
|
+
SET plan = EXCLUDED.plan,
|
|
1421
|
+
status = EXCLUDED.status,
|
|
1422
|
+
max_resources = EXCLUDED.max_resources,
|
|
1423
|
+
max_api_keys = EXCLUDED.max_api_keys,
|
|
1424
|
+
max_webhook_endpoints = EXCLUDED.max_webhook_endpoints,
|
|
1425
|
+
stripe_customer = COALESCE(EXCLUDED.stripe_customer, current.stripe_customer),
|
|
1426
|
+
stripe_subscription = EXCLUDED.stripe_subscription,
|
|
1427
|
+
current_period_end = EXCLUDED.current_period_end,
|
|
1428
|
+
updated_at = EXCLUDED.updated_at;
|
|
1429
|
+
END
|
|
1430
|
+
$slotlock_set_tenant_plan$;
|
|
1431
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[11]} FROM PUBLIC;
|
|
1432
|
+
|
|
1433
|
+
-- A tenant's use of the service (usage.ts), by UTC day: operations its keys and the assistant ran
|
|
1434
|
+
-- (recorded by the servers), and the bookings and holds it committed (rolled up from its feed). By
|
|
1435
|
+
-- API key ('' for none) and surface. Counts only, never what was asked; owner-only, read and written
|
|
1436
|
+
-- through the functions below, erased with the tenant, kept ${SLOTLOCK_USAGE_RETENTION_DAYS} days.
|
|
1437
|
+
CREATE TABLE IF NOT EXISTS slotlock.usage_daily (
|
|
1438
|
+
tenant_ref text NOT NULL,
|
|
1439
|
+
day date NOT NULL,
|
|
1440
|
+
metric text NOT NULL,
|
|
1441
|
+
key_id text NOT NULL DEFAULT '',
|
|
1442
|
+
origin text NOT NULL,
|
|
1443
|
+
count bigint NOT NULL,
|
|
1444
|
+
CONSTRAINT slotlock_usage_daily_pkey PRIMARY KEY (tenant_ref, day, metric, key_id, origin),
|
|
1445
|
+
CONSTRAINT slotlock_usage_daily_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
|
|
1446
|
+
CONSTRAINT slotlock_usage_daily_key_id_valid
|
|
1447
|
+
CHECK (key_id = '' OR key_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
|
1448
|
+
CONSTRAINT slotlock_usage_daily_origin_valid CHECK (origin ~ '^[a-z][a-z-]{0,39}$'),
|
|
1449
|
+
CONSTRAINT slotlock_usage_daily_count_valid CHECK (count > 0)
|
|
1450
|
+
);
|
|
1451
|
+
-- The metrics it takes, and no other. Its comment is the list it was built from: a release that
|
|
1452
|
+
-- adds a metric replaces it (NOT VALID, so nothing is scanned under the lock; validated after).
|
|
1453
|
+
DO $slotlock_usage_daily_metric$
|
|
1454
|
+
BEGIN
|
|
1455
|
+
ALTER TABLE slotlock.usage_daily DROP CONSTRAINT IF EXISTS slotlock_usage_daily_metric_valid;
|
|
1456
|
+
IF (
|
|
1457
|
+
SELECT pg_catalog.obj_description(oid, 'pg_constraint') FROM pg_catalog.pg_constraint
|
|
1458
|
+
WHERE conrelid = 'slotlock.usage_daily'::regclass AND conname = 'slotlock_usage_daily_metric_known'
|
|
1459
|
+
) IS DISTINCT FROM '${SLOTLOCK_USAGE_METRICS.join(' ')}' THEN
|
|
1460
|
+
ALTER TABLE slotlock.usage_daily DROP CONSTRAINT IF EXISTS slotlock_usage_daily_metric_known;
|
|
1461
|
+
ALTER TABLE slotlock.usage_daily
|
|
1462
|
+
ADD CONSTRAINT slotlock_usage_daily_metric_known
|
|
1463
|
+
CHECK (metric IN (${SLOTLOCK_USAGE_METRICS.map((metric) => `'${metric}'`).join(', ')})) NOT VALID;
|
|
1464
|
+
COMMENT ON CONSTRAINT slotlock_usage_daily_metric_known ON slotlock.usage_daily
|
|
1465
|
+
IS '${SLOTLOCK_USAGE_METRICS.join(' ')}';
|
|
1466
|
+
END IF;
|
|
1467
|
+
END
|
|
1468
|
+
$slotlock_usage_daily_metric$;
|
|
1469
|
+
ALTER TABLE slotlock.usage_daily ENABLE ROW LEVEL SECURITY;
|
|
1470
|
+
REVOKE ALL ON slotlock.usage_daily FROM PUBLIC;
|
|
1471
|
+
|
|
1472
|
+
-- Each batch of operation counts a server recorded, by the id it chose for it, so a batch sent again
|
|
1473
|
+
-- (its answer lost) adds nothing. Kept a week.
|
|
1474
|
+
CREATE TABLE IF NOT EXISTS slotlock.usage_flushes (
|
|
1475
|
+
flush_id uuid PRIMARY KEY,
|
|
1476
|
+
at timestamptz NOT NULL DEFAULT statement_timestamp()
|
|
1477
|
+
);
|
|
1478
|
+
ALTER TABLE slotlock.usage_flushes ENABLE ROW LEVEL SECURITY;
|
|
1479
|
+
REVOKE ALL ON slotlock.usage_flushes FROM PUBLIC;
|
|
1480
|
+
|
|
1481
|
+
-- How far each tenant's feed has been rolled up into usage_daily, as a feed position (txid, id).
|
|
1482
|
+
CREATE TABLE IF NOT EXISTS slotlock.usage_cursors (
|
|
1483
|
+
tenant_ref text PRIMARY KEY,
|
|
1484
|
+
txid xid8 NOT NULL,
|
|
1485
|
+
id bigint NOT NULL,
|
|
1486
|
+
CONSTRAINT slotlock_usage_cursors_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
|
|
1487
|
+
);
|
|
1488
|
+
ALTER TABLE slotlock.usage_cursors ENABLE ROW LEVEL SECURITY;
|
|
1489
|
+
REVOKE ALL ON slotlock.usage_cursors FROM PUBLIC;
|
|
1490
|
+
|
|
1491
|
+
-- Where metering began: a tenant with no cursor yet starts its roll-up here, so the feed written
|
|
1492
|
+
-- before this release is never counted. Recorded once, as the oldest transaction still running when
|
|
1493
|
+
-- the table is created: a change at or after it is counted, one before it never.
|
|
1494
|
+
CREATE TABLE IF NOT EXISTS slotlock.usage_meter_start (
|
|
1495
|
+
only_row boolean PRIMARY KEY DEFAULT true,
|
|
1496
|
+
txid xid8 NOT NULL,
|
|
1497
|
+
CONSTRAINT slotlock_usage_meter_start_one_row CHECK (only_row)
|
|
1498
|
+
);
|
|
1499
|
+
-- The one row, written the first time and never moved by a later migration.
|
|
1500
|
+
INSERT INTO slotlock.usage_meter_start (txid)
|
|
1501
|
+
VALUES (pg_catalog.pg_snapshot_xmin(pg_catalog.pg_current_snapshot()))
|
|
1502
|
+
ON CONFLICT (only_row) DO NOTHING;
|
|
1503
|
+
ALTER TABLE slotlock.usage_meter_start ENABLE ROW LEVEL SECURITY;
|
|
1504
|
+
REVOKE ALL ON slotlock.usage_meter_start FROM PUBLIC;
|
|
1505
|
+
|
|
1506
|
+
-- The quota notices a tenant was sent (usage.threshold_reached, quotas.ts): one per UTC month,
|
|
1507
|
+
-- metric and share of the plan's quota, so each goes once however many servers see the count pass
|
|
1508
|
+
-- it. Owner-only, erased with the tenant; a month's rows go when the next month's first is noted.
|
|
1509
|
+
CREATE TABLE IF NOT EXISTS slotlock.usage_notices (
|
|
1510
|
+
tenant_ref text NOT NULL,
|
|
1511
|
+
month date NOT NULL,
|
|
1512
|
+
metric text NOT NULL,
|
|
1513
|
+
percent integer NOT NULL,
|
|
1514
|
+
noticed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1515
|
+
CONSTRAINT slotlock_usage_notices_pkey PRIMARY KEY (tenant_ref, month, metric, percent),
|
|
1516
|
+
CONSTRAINT slotlock_usage_notices_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
|
|
1517
|
+
CONSTRAINT slotlock_usage_notices_month_valid CHECK (extract(day FROM month) = 1),
|
|
1518
|
+
CONSTRAINT slotlock_usage_notices_metric_valid CHECK (metric IN (${SLOTLOCK_QUOTA_METRICS.map((metric) => `'${metric}'`).join(', ')})),
|
|
1519
|
+
CONSTRAINT slotlock_usage_notices_percent_valid CHECK (percent IN (${SLOTLOCK_QUOTA_NOTICE_PERCENTS.join(', ')}))
|
|
1520
|
+
);
|
|
1521
|
+
ALTER TABLE slotlock.usage_notices ENABLE ROW LEVEL SECURITY;
|
|
1522
|
+
REVOKE ALL ON slotlock.usage_notices FROM PUBLIC;
|
|
1523
|
+
|
|
1524
|
+
-- Metered overage (off unless SLOTLOCK_OVERAGE=on, overage.ts): how many operations above its
|
|
1525
|
+
-- quota each tenant's month has been reported to Stripe as, so each is reported once however often
|
|
1526
|
+
-- the report runs. Owner-only: \`slotlock overage report\` runs as the schema owner.
|
|
1527
|
+
CREATE TABLE IF NOT EXISTS slotlock.overage_reports (
|
|
1528
|
+
tenant_ref text NOT NULL,
|
|
1529
|
+
month date NOT NULL,
|
|
1530
|
+
reported bigint NOT NULL,
|
|
1531
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
1532
|
+
CONSTRAINT slotlock_overage_reports_pkey PRIMARY KEY (tenant_ref, month),
|
|
1533
|
+
CONSTRAINT slotlock_overage_reports_valid CHECK (
|
|
1534
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500 AND extract(day FROM month) = 1 AND reported >= 0
|
|
1535
|
+
)
|
|
1536
|
+
);
|
|
1537
|
+
ALTER TABLE slotlock.overage_reports ENABLE ROW LEVEL SECURITY;
|
|
1538
|
+
REVOKE ALL ON slotlock.overage_reports FROM PUBLIC;
|
|
1539
|
+
|
|
1540
|
+
-- Adds a server's batch of counts, once per flush id: true when added, false when that batch was
|
|
1541
|
+
-- added before. Each row names a tenant, a day within a day of today, a metric a server counts
|
|
1542
|
+
-- (operations, webhook deliveries, assistant messages), a key ('' for none), a surface and a count;
|
|
1543
|
+
-- rows for the same day, metric, key and surface are summed first.
|
|
1544
|
+
CREATE OR REPLACE FUNCTION slotlock.record_usage(requested_flush_id uuid, requested_rows jsonb)
|
|
1545
|
+
RETURNS boolean
|
|
1546
|
+
LANGUAGE plpgsql
|
|
1547
|
+
VOLATILE
|
|
1548
|
+
SECURITY DEFINER
|
|
1549
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1550
|
+
AS $slotlock_record_usage$
|
|
1551
|
+
BEGIN
|
|
1552
|
+
IF requested_flush_id IS NULL OR requested_rows IS NULL OR jsonb_typeof(requested_rows) <> 'array'
|
|
1553
|
+
OR jsonb_array_length(requested_rows) NOT BETWEEN 1 AND 10000 THEN
|
|
1554
|
+
RAISE EXCEPTION 'Slotlock usage batch must be 1-10,000 rows with a flush id'
|
|
1555
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
1556
|
+
END IF;
|
|
1557
|
+
IF EXISTS (
|
|
1558
|
+
SELECT 1 FROM jsonb_array_elements(requested_rows) given
|
|
1559
|
+
WHERE jsonb_typeof(given) <> 'object'
|
|
1560
|
+
OR COALESCE(given ->> 'metric', '') NOT IN (${SLOTLOCK_RECORDED_USAGE_METRICS.map((metric) => `'${metric}'`).join(', ')})
|
|
1561
|
+
OR (given ->> 'day') !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
|
|
1562
|
+
OR (given ->> 'day')::date NOT BETWEEN current_date - 1 AND current_date + 1
|
|
1563
|
+
OR jsonb_typeof(given -> 'count') <> 'number'
|
|
1564
|
+
OR (given ->> 'count')::numeric NOT BETWEEN 1 AND 1000000000
|
|
1565
|
+
) THEN
|
|
1566
|
+
RAISE EXCEPTION 'Slotlock usage rows must be counts a server records, 1-1,000,000,000, within a day of today'
|
|
1567
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
1568
|
+
END IF;
|
|
1569
|
+
INSERT INTO slotlock.usage_flushes (flush_id) VALUES (requested_flush_id) ON CONFLICT DO NOTHING;
|
|
1570
|
+
IF NOT FOUND THEN
|
|
1571
|
+
RETURN false;
|
|
1572
|
+
END IF;
|
|
1573
|
+
-- Each tenant's shared lock, as withTenant takes it: an erasure in flight commits first, and the
|
|
1574
|
+
-- check below then sees it.
|
|
1575
|
+
PERFORM pg_advisory_xact_lock_shared(hashtextextended('slotlock:tenant:' || named.tenant_ref, 0))
|
|
1576
|
+
FROM (SELECT DISTINCT given ->> 'tenant_ref' AS tenant_ref
|
|
1577
|
+
FROM jsonb_array_elements(requested_rows) given
|
|
1578
|
+
ORDER BY 1) named;
|
|
1579
|
+
-- Only for a tenant that still exists, one with an API key or a feed: counts a server held while
|
|
1580
|
+
-- the tenant was erased are dropped rather than recreate rows for it.
|
|
1581
|
+
INSERT INTO slotlock.usage_daily AS kept (tenant_ref, day, metric, key_id, origin, count)
|
|
1582
|
+
SELECT given ->> 'tenant_ref', (given ->> 'day')::date, given ->> 'metric',
|
|
1583
|
+
COALESCE(given ->> 'key_id', ''), given ->> 'origin', sum((given ->> 'count')::bigint)
|
|
1584
|
+
FROM jsonb_array_elements(requested_rows) given
|
|
1585
|
+
WHERE EXISTS (SELECT 1 FROM slotlock.api_keys issued WHERE issued.tenant_ref = given ->> 'tenant_ref')
|
|
1586
|
+
OR EXISTS (SELECT 1 FROM slotlock.maintenance_tenants listed
|
|
1587
|
+
WHERE listed.tenant_ref = given ->> 'tenant_ref')
|
|
1588
|
+
GROUP BY 1, 2, 3, 4, 5
|
|
1589
|
+
ON CONFLICT (tenant_ref, day, metric, key_id, origin) DO UPDATE SET count = kept.count + EXCLUDED.count;
|
|
1590
|
+
-- Now and then, batches older than a week (never sent again by then) are forgotten.
|
|
1591
|
+
IF random() < 0.01 THEN
|
|
1592
|
+
DELETE FROM slotlock.usage_flushes WHERE at < statement_timestamp() - interval '7 days';
|
|
1593
|
+
END IF;
|
|
1594
|
+
RETURN true;
|
|
1595
|
+
END
|
|
1596
|
+
$slotlock_record_usage$;
|
|
1597
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[14]} FROM PUBLIC;
|
|
1598
|
+
|
|
1599
|
+
-- Rolls up to max_rows of the tenant's feed past its cursor into usage_daily: each committed
|
|
1600
|
+
-- reservation.created, event.created (a whole series is one) and hold.confirmed is a booking, each
|
|
1601
|
+
-- hold.created a hold, and maintenance's own changes count for nothing. The feed is read as its readers read it, in (txid, id) order and
|
|
1602
|
+
-- only from transactions below this snapshot's xmin, every one of which has ended, so no change is
|
|
1603
|
+
-- counted twice or skipped, and one that rolled back is never counted. The counts and the cursor
|
|
1604
|
+
-- move in one transaction. Also deletes the tenant's usage older than the retention. Returns how
|
|
1605
|
+
-- many changes it read: fewer than max_rows means it has caught up.
|
|
1606
|
+
CREATE OR REPLACE FUNCTION slotlock.roll_up_usage(requested_tenant_ref text, max_rows integer)
|
|
1607
|
+
RETURNS integer
|
|
1608
|
+
LANGUAGE plpgsql
|
|
1609
|
+
VOLATILE
|
|
1610
|
+
SECURITY DEFINER
|
|
1611
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1612
|
+
AS $slotlock_roll_up_usage$
|
|
1613
|
+
DECLARE
|
|
1614
|
+
setting text;
|
|
1615
|
+
previous jsonb := '{}'::jsonb;
|
|
1616
|
+
from_txid xid8;
|
|
1617
|
+
from_id bigint;
|
|
1618
|
+
horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
|
|
1619
|
+
last_txid xid8;
|
|
1620
|
+
last_id bigint;
|
|
1621
|
+
scanned integer;
|
|
1622
|
+
BEGIN
|
|
1623
|
+
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
|
|
1624
|
+
OR max_rows IS NULL OR max_rows NOT BETWEEN 1 AND 100000 THEN
|
|
1625
|
+
RAISE EXCEPTION 'Slotlock usage roll-up arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
|
|
1626
|
+
END IF;
|
|
1627
|
+
-- As every tenant transaction does (withTenant): an erasure waits for it, and it for an erasure,
|
|
1628
|
+
-- so it never counts into a tenant just erased. Then one roll-up of a tenant at a time.
|
|
1629
|
+
PERFORM pg_advisory_xact_lock_shared(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
1630
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:usage:' || requested_tenant_ref, 0));
|
|
1631
|
+
SELECT cursor.txid, cursor.id INTO from_txid, from_id
|
|
1632
|
+
FROM slotlock.usage_cursors cursor WHERE cursor.tenant_ref = requested_tenant_ref;
|
|
1633
|
+
IF NOT FOUND THEN
|
|
1634
|
+
SELECT start.txid, 0 INTO from_txid, from_id FROM slotlock.usage_meter_start start;
|
|
1635
|
+
END IF;
|
|
1636
|
+
-- The feed has forced row-level security, its owner bound too: read it in the tenant's context.
|
|
1637
|
+
FOR setting IN
|
|
1638
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
1639
|
+
LOOP
|
|
1640
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
1641
|
+
PERFORM set_config(setting, requested_tenant_ref, true);
|
|
1642
|
+
END LOOP;
|
|
1643
|
+
WITH batch AS MATERIALIZED (
|
|
1644
|
+
SELECT change.txid, change.id, change.type, change.origin, change.key_id, change.occurred_at
|
|
1645
|
+
FROM slotlock.outbox change
|
|
1646
|
+
WHERE change.tenant_ref = requested_tenant_ref
|
|
1647
|
+
AND (change.txid, change.id) > (from_txid, from_id)
|
|
1648
|
+
AND change.txid < horizon
|
|
1649
|
+
ORDER BY change.txid, change.id
|
|
1650
|
+
LIMIT max_rows
|
|
1651
|
+
), counted AS (
|
|
1652
|
+
INSERT INTO slotlock.usage_daily AS kept (tenant_ref, day, metric, key_id, origin, count)
|
|
1653
|
+
SELECT requested_tenant_ref, (batch.occurred_at AT TIME ZONE 'UTC')::date,
|
|
1654
|
+
CASE batch.type WHEN 'hold.created' THEN 'holds' ELSE 'bookings' END,
|
|
1655
|
+
COALESCE(batch.key_id::text, ''), COALESCE(batch.origin, 'library'), count(*)
|
|
1656
|
+
FROM batch
|
|
1657
|
+
WHERE batch.type IN ('reservation.created', 'event.created', 'hold.confirmed', 'hold.created')
|
|
1658
|
+
AND COALESCE(batch.origin, 'library') <> 'maintenance'
|
|
1659
|
+
GROUP BY 1, 2, 3, 4, 5
|
|
1660
|
+
ON CONFLICT (tenant_ref, day, metric, key_id, origin) DO UPDATE SET count = kept.count + EXCLUDED.count
|
|
1661
|
+
)
|
|
1662
|
+
SELECT (SELECT batch.txid FROM batch ORDER BY batch.txid DESC, batch.id DESC LIMIT 1),
|
|
1663
|
+
(SELECT batch.id FROM batch ORDER BY batch.txid DESC, batch.id DESC LIMIT 1),
|
|
1664
|
+
(SELECT count(*) FROM batch)::integer
|
|
1665
|
+
INTO last_txid, last_id, scanned;
|
|
1666
|
+
-- How many resources the tenant holds today (archived ones included, as a plan counts them): the
|
|
1667
|
+
-- day's last pass leaves the count at the end of the day. A count, so it replaces, never adds.
|
|
1668
|
+
INSERT INTO slotlock.usage_daily AS kept (tenant_ref, day, metric, key_id, origin, count)
|
|
1669
|
+
SELECT requested_tenant_ref, (statement_timestamp() AT TIME ZONE 'UTC')::date, 'resources', '',
|
|
1670
|
+
'maintenance', count(*)
|
|
1671
|
+
FROM slotlock.resources held
|
|
1672
|
+
WHERE held.tenant_ref = requested_tenant_ref
|
|
1673
|
+
HAVING count(*) > 0
|
|
1674
|
+
ON CONFLICT (tenant_ref, day, metric, key_id, origin) DO UPDATE SET count = EXCLUDED.count;
|
|
1675
|
+
IF scanned > 0 THEN
|
|
1676
|
+
INSERT INTO slotlock.usage_cursors AS kept (tenant_ref, txid, id)
|
|
1677
|
+
VALUES (requested_tenant_ref, last_txid, last_id)
|
|
1678
|
+
ON CONFLICT (tenant_ref) DO UPDATE SET txid = EXCLUDED.txid, id = EXCLUDED.id;
|
|
1679
|
+
END IF;
|
|
1680
|
+
DELETE FROM slotlock.usage_daily
|
|
1681
|
+
WHERE tenant_ref = requested_tenant_ref
|
|
1682
|
+
AND day < current_date - ${SLOTLOCK_USAGE_RETENTION_DAYS};
|
|
1683
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
1684
|
+
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
1685
|
+
END LOOP;
|
|
1686
|
+
RETURN scanned;
|
|
1687
|
+
END
|
|
1688
|
+
$slotlock_roll_up_usage$;
|
|
1689
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[15]} FROM PUBLIC;
|
|
1690
|
+
|
|
1691
|
+
-- The tenant's usage from from_day to to_day (inclusive, at most 400 days), by day, metric, key and
|
|
1692
|
+
-- surface.
|
|
1693
|
+
CREATE OR REPLACE FUNCTION slotlock.tenant_usage(
|
|
1694
|
+
requested_tenant_ref text, from_day date, to_day date
|
|
1695
|
+
)
|
|
1696
|
+
RETURNS TABLE (day date, metric text, key_id text, origin text, count bigint)
|
|
1697
|
+
LANGUAGE sql
|
|
1698
|
+
STABLE
|
|
1699
|
+
SECURITY DEFINER
|
|
1700
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1701
|
+
AS $slotlock_tenant_usage$
|
|
1702
|
+
SELECT used.day, used.metric, used.key_id, used.origin, used.count
|
|
1703
|
+
FROM slotlock.usage_daily used
|
|
1704
|
+
WHERE used.tenant_ref = requested_tenant_ref
|
|
1705
|
+
AND used.day BETWEEN from_day AND to_day
|
|
1706
|
+
AND to_day - from_day BETWEEN 0 AND ${SLOTLOCK_USAGE_RETENTION_DAYS}
|
|
1707
|
+
ORDER BY used.day, used.metric, used.key_id, used.origin;
|
|
1708
|
+
$slotlock_tenant_usage$;
|
|
1709
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[16]} FROM PUBLIC;
|
|
1710
|
+
|
|
1711
|
+
-- The tenant's operations and bookings from from_day (its quota month's first day, UTC) until now,
|
|
1712
|
+
-- for its plan's quotas (quotas.ts): what usage_daily holds, plus the bookings in its feed past the
|
|
1713
|
+
-- roll-up cursor, counted as roll_up_usage will count them (at most ${SLOTLOCK_QUOTA_UNROLLED_LIMIT.toLocaleString('en-US')} of those, more
|
|
1714
|
+
-- than any quota). One statement, so a roll-up that commits meanwhile is counted once, never twice
|
|
1715
|
+
-- or not at all.
|
|
1716
|
+
CREATE OR REPLACE FUNCTION slotlock.tenant_quota_usage(requested_tenant_ref text, from_day date)
|
|
1717
|
+
RETURNS TABLE (operations bigint, bookings bigint)
|
|
1718
|
+
LANGUAGE plpgsql
|
|
1719
|
+
VOLATILE
|
|
1720
|
+
SECURITY DEFINER
|
|
1721
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1722
|
+
AS $slotlock_tenant_quota_usage$
|
|
1723
|
+
DECLARE
|
|
1724
|
+
setting text;
|
|
1725
|
+
previous jsonb := '{}'::jsonb;
|
|
1726
|
+
today date := (statement_timestamp() AT TIME ZONE 'UTC')::date;
|
|
1727
|
+
BEGIN
|
|
1728
|
+
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
|
|
1729
|
+
OR from_day IS NULL OR from_day NOT BETWEEN today - 31 AND today + 1 THEN
|
|
1730
|
+
RAISE EXCEPTION 'Slotlock quota usage arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
|
|
1731
|
+
END IF;
|
|
1732
|
+
-- The feed has forced row-level security, its owner bound too: read it in the tenant's context.
|
|
1733
|
+
FOR setting IN
|
|
1734
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
1735
|
+
LOOP
|
|
1736
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
1737
|
+
PERFORM set_config(setting, requested_tenant_ref, true);
|
|
1738
|
+
END LOOP;
|
|
1739
|
+
RETURN QUERY
|
|
1740
|
+
WITH rolled_to AS (
|
|
1741
|
+
SELECT COALESCE(
|
|
1742
|
+
(SELECT kept.txid FROM slotlock.usage_cursors kept WHERE kept.tenant_ref = requested_tenant_ref),
|
|
1743
|
+
(SELECT start.txid FROM slotlock.usage_meter_start start)) AS txid,
|
|
1744
|
+
COALESCE(
|
|
1745
|
+
(SELECT kept.id FROM slotlock.usage_cursors kept WHERE kept.tenant_ref = requested_tenant_ref),
|
|
1746
|
+
0) AS id
|
|
1747
|
+
), unrolled AS (
|
|
1748
|
+
SELECT 1
|
|
1749
|
+
FROM slotlock.outbox change, rolled_to
|
|
1750
|
+
WHERE change.tenant_ref = requested_tenant_ref
|
|
1751
|
+
AND (change.txid, change.id) > (rolled_to.txid, rolled_to.id)
|
|
1752
|
+
AND change.type IN ('reservation.created', 'event.created', 'hold.confirmed')
|
|
1753
|
+
AND COALESCE(change.origin, 'library') <> 'maintenance'
|
|
1754
|
+
AND change.occurred_at >= from_day::timestamp AT TIME ZONE 'UTC'
|
|
1755
|
+
LIMIT ${SLOTLOCK_QUOTA_UNROLLED_LIMIT}
|
|
1756
|
+
), rolled AS (
|
|
1757
|
+
SELECT COALESCE(sum(used.count) FILTER (WHERE used.metric = 'operations'), 0) AS operations,
|
|
1758
|
+
COALESCE(sum(used.count) FILTER (WHERE used.metric = 'bookings'), 0) AS bookings
|
|
1759
|
+
FROM slotlock.usage_daily used
|
|
1760
|
+
WHERE used.tenant_ref = requested_tenant_ref
|
|
1761
|
+
AND used.day >= from_day
|
|
1762
|
+
AND used.metric IN ('operations', 'bookings')
|
|
1763
|
+
)
|
|
1764
|
+
SELECT rolled.operations::bigint, (rolled.bookings + (SELECT count(*) FROM unrolled))::bigint
|
|
1765
|
+
FROM rolled;
|
|
1766
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
1767
|
+
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
1768
|
+
END LOOP;
|
|
1769
|
+
END
|
|
1770
|
+
$slotlock_tenant_quota_usage$;
|
|
1771
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[17]} FROM PUBLIC;
|
|
1772
|
+
|
|
1773
|
+
-- Notes that the tenant's use of a quota reached a share of it in a month, once: true the first
|
|
1774
|
+
-- time, when it also appends usage.threshold_reached to the tenant's feed (the metric, the share,
|
|
1775
|
+
-- the count, the quota and when the month ends), which its webhooks deliver as any change. Only for
|
|
1776
|
+
-- a tenant that still exists (one with an API key or a feed, as record_usage), under its shared lock.
|
|
1777
|
+
CREATE OR REPLACE FUNCTION slotlock.note_usage_threshold(
|
|
1778
|
+
requested_tenant_ref text,
|
|
1779
|
+
requested_month date,
|
|
1780
|
+
requested_metric text,
|
|
1781
|
+
requested_percent integer,
|
|
1782
|
+
requested_used bigint,
|
|
1783
|
+
requested_quota bigint
|
|
1784
|
+
)
|
|
1785
|
+
RETURNS boolean
|
|
1786
|
+
LANGUAGE plpgsql
|
|
1787
|
+
VOLATILE
|
|
1788
|
+
SECURITY DEFINER
|
|
1789
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1790
|
+
AS $slotlock_note_usage_threshold$
|
|
1791
|
+
DECLARE
|
|
1792
|
+
setting text;
|
|
1793
|
+
previous jsonb := '{}'::jsonb;
|
|
1794
|
+
today date := (statement_timestamp() AT TIME ZONE 'UTC')::date;
|
|
1795
|
+
BEGIN
|
|
1796
|
+
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
|
|
1797
|
+
OR requested_month IS NULL OR extract(day FROM requested_month) <> 1
|
|
1798
|
+
OR requested_month NOT BETWEEN today - 31 AND today + 1
|
|
1799
|
+
OR requested_metric IS NULL
|
|
1800
|
+
OR requested_metric NOT IN (${SLOTLOCK_QUOTA_METRICS.map((metric) => `'${metric}'`).join(', ')})
|
|
1801
|
+
OR requested_percent IS NULL OR requested_percent NOT IN (${SLOTLOCK_QUOTA_NOTICE_PERCENTS.join(', ')})
|
|
1802
|
+
OR requested_used IS NULL OR requested_used < 0
|
|
1803
|
+
OR requested_quota IS NULL OR requested_quota < 1 THEN
|
|
1804
|
+
RAISE EXCEPTION 'Slotlock usage notice arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
|
|
1805
|
+
END IF;
|
|
1806
|
+
-- As withTenant: an erasure in flight commits first, and the check below then sees it.
|
|
1807
|
+
PERFORM pg_advisory_xact_lock_shared(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
1808
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.api_keys issued WHERE issued.tenant_ref = requested_tenant_ref)
|
|
1809
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants listed
|
|
1810
|
+
WHERE listed.tenant_ref = requested_tenant_ref) THEN
|
|
1811
|
+
RETURN false;
|
|
1812
|
+
END IF;
|
|
1813
|
+
INSERT INTO slotlock.usage_notices (tenant_ref, month, metric, percent)
|
|
1814
|
+
VALUES (requested_tenant_ref, requested_month, requested_metric, requested_percent)
|
|
1815
|
+
ON CONFLICT DO NOTHING;
|
|
1816
|
+
IF NOT FOUND THEN
|
|
1817
|
+
RETURN false;
|
|
1818
|
+
END IF;
|
|
1819
|
+
DELETE FROM slotlock.usage_notices
|
|
1820
|
+
WHERE tenant_ref = requested_tenant_ref AND month < requested_month;
|
|
1821
|
+
FOR setting IN
|
|
1822
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
1823
|
+
LOOP
|
|
1824
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
1825
|
+
PERFORM set_config(setting, requested_tenant_ref, true);
|
|
1826
|
+
END LOOP;
|
|
1827
|
+
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data)
|
|
1828
|
+
VALUES (
|
|
1829
|
+
requested_tenant_ref, 'usage.threshold_reached',
|
|
1830
|
+
'usage_' || requested_metric || '_' || to_char(requested_month, 'YYYY_MM') || '_' || requested_percent,
|
|
1831
|
+
jsonb_build_object(
|
|
1832
|
+
'metric', requested_metric, 'percent', requested_percent, 'used', requested_used,
|
|
1833
|
+
'quota', requested_quota, 'period_start', to_char(requested_month, 'YYYY-MM-DD'),
|
|
1834
|
+
'resets_at', to_char((requested_month + interval '1 month'), 'YYYY-MM-DD"T"00:00:00"Z"'))
|
|
1835
|
+
);
|
|
1836
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
1837
|
+
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
1838
|
+
END LOOP;
|
|
1839
|
+
RETURN true;
|
|
1840
|
+
END
|
|
1841
|
+
$slotlock_note_usage_threshold$;
|
|
1842
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[18]} FROM PUBLIC;
|
|
1843
|
+
|
|
812
1844
|
-- True when the tenant was not suspended already; a second suspension keeps the first's reason.
|
|
813
1845
|
CREATE OR REPLACE FUNCTION slotlock.suspend_tenant(requested_tenant_ref text, requested_reason text)
|
|
814
1846
|
RETURNS boolean
|
|
@@ -860,6 +1892,7 @@ AS $slotlock_erase_tenant$
|
|
|
860
1892
|
DECLARE
|
|
861
1893
|
setting text;
|
|
862
1894
|
previous jsonb := '{}'::jsonb;
|
|
1895
|
+
tested record;
|
|
863
1896
|
BEGIN
|
|
864
1897
|
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
|
|
865
1898
|
RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
|
|
@@ -868,6 +1901,15 @@ BEGIN
|
|
|
868
1901
|
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
869
1902
|
-- A key being issued commits first, so its row is deleted below with the rest.
|
|
870
1903
|
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
1904
|
+
-- A paid plan is cancelled first (plans.ts): erasing the tenant would leave its subscription
|
|
1905
|
+
-- renewing for an account that no longer exists. Cancelling it in Stripe moves the plan to Free
|
|
1906
|
+
-- (the dashboard's webhook), and then erasure runs. Under the key lock, which set_tenant_plan takes.
|
|
1907
|
+
IF EXISTS (SELECT 1 FROM slotlock.tenant_plans billed
|
|
1908
|
+
WHERE billed.tenant_ref = requested_tenant_ref
|
|
1909
|
+
AND billed.stripe_subscription IS NOT NULL) THEN
|
|
1910
|
+
RAISE EXCEPTION 'Slotlock tenant has a live subscription: cancel it before erasing the tenant'
|
|
1911
|
+
USING ERRCODE = 'object_in_use', HINT = 'billing_active';
|
|
1912
|
+
END IF;
|
|
871
1913
|
FOR setting IN
|
|
872
1914
|
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
873
1915
|
LOOP
|
|
@@ -894,6 +1936,18 @@ BEGIN
|
|
|
894
1936
|
DELETE FROM slotlock.calendar_event_occurrences WHERE tenant_ref = requested_tenant_ref;
|
|
895
1937
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
896
1938
|
RETURN NEXT;
|
|
1939
|
+
erased_table := 'composite_claims';
|
|
1940
|
+
DELETE FROM slotlock.composite_claims WHERE tenant_ref = requested_tenant_ref;
|
|
1941
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
1942
|
+
RETURN NEXT;
|
|
1943
|
+
erased_table := 'approvals';
|
|
1944
|
+
DELETE FROM slotlock.approvals WHERE tenant_ref = requested_tenant_ref;
|
|
1945
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
1946
|
+
RETURN NEXT;
|
|
1947
|
+
erased_table := 'approval_policies';
|
|
1948
|
+
DELETE FROM slotlock.approval_policies WHERE tenant_ref = requested_tenant_ref;
|
|
1949
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
1950
|
+
RETURN NEXT;
|
|
897
1951
|
erased_table := 'reservations';
|
|
898
1952
|
DELETE FROM slotlock.reservations
|
|
899
1953
|
WHERE tenant_ref = requested_tenant_ref
|
|
@@ -937,6 +1991,42 @@ BEGIN
|
|
|
937
1991
|
DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
|
|
938
1992
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
939
1993
|
RETURN NEXT;
|
|
1994
|
+
erased_table := 'pool_settings';
|
|
1995
|
+
DELETE FROM slotlock.pool_settings WHERE tenant_ref = requested_tenant_ref;
|
|
1996
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
1997
|
+
RETURN NEXT;
|
|
1998
|
+
erased_table := 'tenant_plans';
|
|
1999
|
+
DELETE FROM slotlock.tenant_plans WHERE tenant_ref = requested_tenant_ref;
|
|
2000
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2001
|
+
RETURN NEXT;
|
|
2002
|
+
-- A partner's account: what it used in months its partner has not closed is kept for billing
|
|
2003
|
+
-- first (by the partner's account reference only), then its partner's record of it goes too.
|
|
2004
|
+
INSERT INTO slotlock.partner_erased_usage AS kept (partner_id, account, plan, month, metric, count)
|
|
2005
|
+
SELECT owned.partner_id, owned.account, owned.plan, monthly.month, monthly.metric, monthly.count
|
|
2006
|
+
FROM slotlock.partner_accounts owned
|
|
2007
|
+
JOIN (
|
|
2008
|
+
SELECT daily.month, daily.metric,
|
|
2009
|
+
CASE WHEN daily.metric = 'resources' THEN max(daily.count) ELSE sum(daily.count) END::bigint AS count
|
|
2010
|
+
FROM (
|
|
2011
|
+
SELECT date_trunc('month', used.day)::date AS month, used.day, used.metric, sum(used.count) AS count
|
|
2012
|
+
FROM slotlock.usage_daily used
|
|
2013
|
+
WHERE used.tenant_ref = requested_tenant_ref
|
|
2014
|
+
AND used.day >= (date_trunc('month', statement_timestamp()) - interval '1 month')::date
|
|
2015
|
+
GROUP BY 1, used.day, used.metric
|
|
2016
|
+
) daily
|
|
2017
|
+
GROUP BY daily.month, daily.metric
|
|
2018
|
+
) monthly ON true
|
|
2019
|
+
WHERE owned.tenant_ref = requested_tenant_ref
|
|
2020
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.partner_month_closes closed
|
|
2021
|
+
WHERE closed.partner_id = owned.partner_id AND closed.month = monthly.month)
|
|
2022
|
+
ON CONFLICT (partner_id, account, month, metric) DO UPDATE
|
|
2023
|
+
SET count = CASE WHEN kept.metric = 'resources' THEN GREATEST(kept.count, EXCLUDED.count)
|
|
2024
|
+
ELSE kept.count + EXCLUDED.count END,
|
|
2025
|
+
plan = EXCLUDED.plan, erased_at = statement_timestamp();
|
|
2026
|
+
erased_table := 'partner_accounts';
|
|
2027
|
+
DELETE FROM slotlock.partner_accounts WHERE tenant_ref = requested_tenant_ref;
|
|
2028
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2029
|
+
RETURN NEXT;
|
|
940
2030
|
erased_table := 'assistant_conversations';
|
|
941
2031
|
DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
|
|
942
2032
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -945,10 +2035,24 @@ BEGIN
|
|
|
945
2035
|
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
|
|
946
2036
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
947
2037
|
RETURN NEXT;
|
|
2038
|
+
-- A resource's group memberships, then the resource; its location after it, as resources and
|
|
2039
|
+
-- locations under it name it.
|
|
2040
|
+
erased_table := 'resource_group_members';
|
|
2041
|
+
DELETE FROM slotlock.resource_group_members WHERE tenant_ref = requested_tenant_ref;
|
|
2042
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2043
|
+
RETURN NEXT;
|
|
948
2044
|
erased_table := 'resources';
|
|
949
2045
|
DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
|
|
950
2046
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
951
2047
|
RETURN NEXT;
|
|
2048
|
+
erased_table := 'resource_groups';
|
|
2049
|
+
DELETE FROM slotlock.resource_groups WHERE tenant_ref = requested_tenant_ref;
|
|
2050
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2051
|
+
RETURN NEXT;
|
|
2052
|
+
erased_table := 'locations';
|
|
2053
|
+
DELETE FROM slotlock.locations WHERE tenant_ref = requested_tenant_ref;
|
|
2054
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2055
|
+
RETURN NEXT;
|
|
952
2056
|
-- After every booking table: what their deletion just appended goes with the rest.
|
|
953
2057
|
erased_table := 'outbox';
|
|
954
2058
|
DELETE FROM slotlock.outbox WHERE tenant_ref = requested_tenant_ref;
|
|
@@ -970,6 +2074,35 @@ BEGIN
|
|
|
970
2074
|
DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
971
2075
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
972
2076
|
RETURN NEXT;
|
|
2077
|
+
erased_table := 'maintenance_tenants';
|
|
2078
|
+
DELETE FROM slotlock.maintenance_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
2079
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2080
|
+
RETURN NEXT;
|
|
2081
|
+
erased_table := 'usage_daily';
|
|
2082
|
+
DELETE FROM slotlock.usage_daily WHERE tenant_ref = requested_tenant_ref;
|
|
2083
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2084
|
+
RETURN NEXT;
|
|
2085
|
+
erased_table := 'usage_cursors';
|
|
2086
|
+
DELETE FROM slotlock.usage_cursors WHERE tenant_ref = requested_tenant_ref;
|
|
2087
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2088
|
+
RETURN NEXT;
|
|
2089
|
+
erased_table := 'usage_notices';
|
|
2090
|
+
DELETE FROM slotlock.usage_notices WHERE tenant_ref = requested_tenant_ref;
|
|
2091
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2092
|
+
RETURN NEXT;
|
|
2093
|
+
erased_table := 'overage_reports';
|
|
2094
|
+
DELETE FROM slotlock.overage_reports WHERE tenant_ref = requested_tenant_ref;
|
|
2095
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2096
|
+
RETURN NEXT;
|
|
2097
|
+
-- Before the keys, so their count is reported (the key's deletion would take them too).
|
|
2098
|
+
erased_table := 'api_key_limits';
|
|
2099
|
+
DELETE FROM slotlock.api_key_limits WHERE tenant_ref = requested_tenant_ref;
|
|
2100
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2101
|
+
RETURN NEXT;
|
|
2102
|
+
erased_table := 'api_key_resource_scopes';
|
|
2103
|
+
DELETE FROM slotlock.api_key_resource_scopes WHERE tenant_ref = requested_tenant_ref;
|
|
2104
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2105
|
+
RETURN NEXT;
|
|
973
2106
|
erased_table := 'api_keys';
|
|
974
2107
|
WITH erased AS (
|
|
975
2108
|
DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
|
|
@@ -980,6 +2113,13 @@ BEGIN
|
|
|
980
2113
|
)
|
|
981
2114
|
SELECT count(*) INTO erased_rows FROM erased;
|
|
982
2115
|
RETURN NEXT;
|
|
2116
|
+
-- An organization's record of being turned on goes with its tenant (Teams on Cloud).
|
|
2117
|
+
erased_table := 'dashboard_organizations';
|
|
2118
|
+
DELETE FROM slotlock.dashboard_organizations org
|
|
2119
|
+
WHERE requested_tenant_ref ~ '^github-org:[1-9][0-9]{0,19}$'
|
|
2120
|
+
AND org.org_id = substr(requested_tenant_ref, 12);
|
|
2121
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2122
|
+
RETURN NEXT;
|
|
983
2123
|
erased_table := 'dashboard_tokens';
|
|
984
2124
|
erased_rows := 0;
|
|
985
2125
|
IF requested_tenant_ref LIKE 'github:%' THEN
|
|
@@ -988,9 +2128,29 @@ BEGIN
|
|
|
988
2128
|
AND owner_hash = sha256(convert_to(
|
|
989
2129
|
'slotlock-dashboard-owner.' || substr(requested_tenant_ref, 8), 'UTF8'));
|
|
990
2130
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
2131
|
+
ELSIF requested_tenant_ref LIKE 'google:%' THEN
|
|
2132
|
+
-- A Google person's form records are under their whole reference (dashboard.ts).
|
|
2133
|
+
DELETE FROM slotlock.dashboard_tokens
|
|
2134
|
+
WHERE kind = 'form'
|
|
2135
|
+
AND owner_hash = sha256(convert_to('slotlock-dashboard-owner.' || requested_tenant_ref, 'UTF8'));
|
|
2136
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
991
2137
|
END IF;
|
|
992
2138
|
RETURN NEXT;
|
|
993
2139
|
|
|
2140
|
+
-- A live tenant goes with its test environment (RFC 0003 G9), reported as test.<table>.
|
|
2141
|
+
IF requested_tenant_ref !~ '~test$' AND octet_length(requested_tenant_ref) <= 495 THEN
|
|
2142
|
+
FOR tested IN
|
|
2143
|
+
SELECT inner_erased.erased_table AS name, inner_erased.erased_rows AS count
|
|
2144
|
+
FROM slotlock.erase_tenant(requested_tenant_ref || '~test') inner_erased
|
|
2145
|
+
LOOP
|
|
2146
|
+
IF tested.count > 0 THEN
|
|
2147
|
+
erased_table := 'test.' || tested.name;
|
|
2148
|
+
erased_rows := tested.count;
|
|
2149
|
+
RETURN NEXT;
|
|
2150
|
+
END IF;
|
|
2151
|
+
END LOOP;
|
|
2152
|
+
END IF;
|
|
2153
|
+
|
|
994
2154
|
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
995
2155
|
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
996
2156
|
END LOOP;
|
|
@@ -998,6 +2158,76 @@ END
|
|
|
998
2158
|
$slotlock_erase_tenant$;
|
|
999
2159
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
|
|
1000
2160
|
|
|
2161
|
+
-- Empty a test environment (RFC 0004 A6 sandbox reset): its resources, locations and groups, every
|
|
2162
|
+
-- booking, event, hold and waitlist entry, its approval policies, booking rule defaults and pool
|
|
2163
|
+
-- settings, and its feed, as if it were new. What sets it up is kept: its API keys (and their
|
|
2164
|
+
-- scopes and limits), webhook endpoints, plan, usage and assistant. Only a test environment
|
|
2165
|
+
-- (a tenant ending ~test, api-keys.ts), and only the one the caller acts in (its tenant context), so
|
|
2166
|
+
-- a server resets nothing it was not asked to; a live tenant is never touched.
|
|
2167
|
+
CREATE OR REPLACE FUNCTION slotlock.reset_test_tenant(requested_tenant_ref text)
|
|
2168
|
+
RETURNS TABLE (reset_table text, reset_rows bigint)
|
|
2169
|
+
LANGUAGE plpgsql
|
|
2170
|
+
VOLATILE
|
|
2171
|
+
SECURITY DEFINER
|
|
2172
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2173
|
+
AS $slotlock_reset_test_tenant$
|
|
2174
|
+
DECLARE
|
|
2175
|
+
emptied text;
|
|
2176
|
+
gone bigint;
|
|
2177
|
+
BEGIN
|
|
2178
|
+
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
|
|
2179
|
+
OR right(requested_tenant_ref, 5) <> '~test' THEN
|
|
2180
|
+
RAISE EXCEPTION 'Slotlock resets only a test environment (a tenant ending ~test)'
|
|
2181
|
+
USING ERRCODE = 'insufficient_privilege', HINT = 'sandbox_test_only';
|
|
2182
|
+
END IF;
|
|
2183
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts contract
|
|
2184
|
+
WHERE current_setting(contract.tenant_context_setting, true) = requested_tenant_ref) THEN
|
|
2185
|
+
RAISE EXCEPTION 'Slotlock resets only the tenant the caller acts in'
|
|
2186
|
+
USING ERRCODE = 'insufficient_privilege', HINT = 'sandbox_test_only';
|
|
2187
|
+
END IF;
|
|
2188
|
+
-- Waits for the tenant's transactions under way, as erasure does.
|
|
2189
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
2190
|
+
-- Nothing reset is a change anyone is told of: the feed goes with what it described.
|
|
2191
|
+
PERFORM set_config('slotlock.erasing', requested_tenant_ref, true);
|
|
2192
|
+
reset_table := 'reservations';
|
|
2193
|
+
DELETE FROM slotlock.reservations
|
|
2194
|
+
WHERE tenant_ref = requested_tenant_ref
|
|
2195
|
+
OR resource_id IN (SELECT owned.id FROM slotlock.resources owned
|
|
2196
|
+
WHERE owned.tenant_ref = requested_tenant_ref);
|
|
2197
|
+
GET DIAGNOSTICS reset_rows = ROW_COUNT;
|
|
2198
|
+
RETURN NEXT;
|
|
2199
|
+
FOREACH emptied IN ARRAY ARRAY[
|
|
2200
|
+
'calendar_event_attendees', 'calendar_event_reminders', 'calendar_event_exceptions',
|
|
2201
|
+
'calendar_event_occurrences', 'composite_claims', 'approvals', 'approval_policies',
|
|
2202
|
+
'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
|
|
2203
|
+
'reservation_tombstones', 'reservation_conflict_archive', 'calendar_coverage', 'hold_commands',
|
|
2204
|
+
'waitlist_entries', 'booking_rule_defaults', 'pool_settings', 'resource_group_members',
|
|
2205
|
+
'resources', 'resource_groups', 'outbox'
|
|
2206
|
+
] LOOP
|
|
2207
|
+
reset_table := emptied;
|
|
2208
|
+
EXECUTE format('DELETE FROM slotlock.%I WHERE tenant_ref = $1', emptied) USING requested_tenant_ref;
|
|
2209
|
+
GET DIAGNOSTICS reset_rows = ROW_COUNT;
|
|
2210
|
+
RETURN NEXT;
|
|
2211
|
+
END LOOP;
|
|
2212
|
+
-- Locations last: one may be inside another, so the innermost go first.
|
|
2213
|
+
reset_table := 'locations';
|
|
2214
|
+
reset_rows := 0;
|
|
2215
|
+
LOOP
|
|
2216
|
+
DELETE FROM slotlock.locations place
|
|
2217
|
+
WHERE place.tenant_ref = requested_tenant_ref
|
|
2218
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.locations inner_place
|
|
2219
|
+
WHERE inner_place.tenant_ref = requested_tenant_ref
|
|
2220
|
+
AND inner_place.parent_ref = place.ref);
|
|
2221
|
+
GET DIAGNOSTICS gone = ROW_COUNT;
|
|
2222
|
+
EXIT WHEN gone = 0;
|
|
2223
|
+
reset_rows := reset_rows + gone;
|
|
2224
|
+
END LOOP;
|
|
2225
|
+
RETURN NEXT;
|
|
2226
|
+
PERFORM set_config('slotlock.erasing', '', true);
|
|
2227
|
+
END
|
|
2228
|
+
$slotlock_reset_test_tenant$;
|
|
2229
|
+
REVOKE ALL ON FUNCTION slotlock.reset_test_tenant(text) FROM PUBLIC;
|
|
2230
|
+
|
|
1001
2231
|
-- The tenants with a webhook endpoint, which a server delivers for. Row-level security hides every
|
|
1002
2232
|
-- other tenant's endpoints from the serving role, so this list (names only) is how a server that
|
|
1003
2233
|
-- restarted finds the tenants whose events still wait. No role but its owner reads it.
|
|
@@ -1192,17 +2422,100 @@ BEGIN
|
|
|
1192
2422
|
END
|
|
1193
2423
|
$slotlock_sweep_assistant_conversations$;
|
|
1194
2424
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
|
|
2425
|
+
|
|
2426
|
+
-- The tenants with data, which \`serve\` maintains every hour whether or not their keys call: what
|
|
2427
|
+
-- retention ends is deleted for a tenant gone quiet too. Forced row-level security hides every
|
|
2428
|
+
-- tenant's rows from the owner as well, so this list (names only) is how a server finds them. A
|
|
2429
|
+
-- tenant is listed by the first row it writes to a table maintenance prunes (each change on its
|
|
2430
|
+
-- feed is one, so every booking, event, resource and endpoint counts), and erasure takes it off.
|
|
2431
|
+
-- While it is empty (first created), it starts from every tenant already in a tenant table: the
|
|
2432
|
+
-- deployment has lifted FORCE by now. No role but its owner reads it.
|
|
2433
|
+
CREATE TABLE IF NOT EXISTS slotlock.maintenance_tenants (
|
|
2434
|
+
tenant_ref text PRIMARY KEY,
|
|
2435
|
+
CONSTRAINT slotlock_maintenance_tenants_tenant_ref_valid
|
|
2436
|
+
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
|
|
2437
|
+
);
|
|
2438
|
+
DO $slotlock_maintenance_tenants_known$
|
|
2439
|
+
DECLARE
|
|
2440
|
+
tenant_table text;
|
|
2441
|
+
BEGIN
|
|
2442
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants) THEN
|
|
2443
|
+
FOR tenant_table IN
|
|
2444
|
+
SELECT rel.relname::text
|
|
2445
|
+
FROM pg_catalog.pg_class rel
|
|
2446
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
2447
|
+
JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid
|
|
2448
|
+
WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p')
|
|
2449
|
+
AND att.attname = 'tenant_ref' AND NOT att.attisdropped
|
|
2450
|
+
AND rel.relname <> 'maintenance_tenants'
|
|
2451
|
+
ORDER BY rel.relname
|
|
2452
|
+
LOOP
|
|
2453
|
+
EXECUTE pg_catalog.format(
|
|
2454
|
+
'INSERT INTO slotlock.maintenance_tenants (tenant_ref)
|
|
2455
|
+
SELECT DISTINCT tenant_ref FROM slotlock.%I
|
|
2456
|
+
WHERE octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
2457
|
+
ON CONFLICT (tenant_ref) DO NOTHING', tenant_table);
|
|
2458
|
+
END LOOP;
|
|
2459
|
+
END IF;
|
|
2460
|
+
END
|
|
2461
|
+
$slotlock_maintenance_tenants_known$;
|
|
2462
|
+
ALTER TABLE slotlock.maintenance_tenants ENABLE ROW LEVEL SECURITY;
|
|
2463
|
+
REVOKE ALL ON slotlock.maintenance_tenants FROM PUBLIC;
|
|
2464
|
+
|
|
2465
|
+
-- Lists the tenants of the rows a statement appended (only the writer's own: RLS checks each row).
|
|
2466
|
+
-- Once listed, a tenant costs each later statement one index probe.
|
|
2467
|
+
CREATE OR REPLACE FUNCTION slotlock.list_maintenance_tenant()
|
|
2468
|
+
RETURNS trigger
|
|
2469
|
+
LANGUAGE plpgsql
|
|
2470
|
+
SECURITY DEFINER
|
|
2471
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2472
|
+
AS $slotlock_list_maintenance_tenant$
|
|
2473
|
+
BEGIN
|
|
2474
|
+
INSERT INTO slotlock.maintenance_tenants (tenant_ref)
|
|
2475
|
+
SELECT DISTINCT appended.tenant_ref FROM appended
|
|
2476
|
+
WHERE appended.tenant_ref IS NOT NULL
|
|
2477
|
+
ON CONFLICT (tenant_ref) DO NOTHING;
|
|
2478
|
+
RETURN NULL;
|
|
2479
|
+
END
|
|
2480
|
+
$slotlock_list_maintenance_tenant$;
|
|
2481
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[8]} FROM PUBLIC;
|
|
2482
|
+
-- The feed, and what can be written without a change on it: a reference retired with no booking,
|
|
2483
|
+
-- an event's cancellation and idempotency answers, a hold's answer, a waitlist entry.
|
|
2484
|
+
DO $slotlock_maintenance_tenants_triggers$
|
|
2485
|
+
DECLARE
|
|
2486
|
+
tenant_table text;
|
|
2487
|
+
BEGIN
|
|
2488
|
+
FOREACH tenant_table IN ARRAY ARRAY[
|
|
2489
|
+
'outbox', 'reservation_tombstones', 'calendar_event_tombstones', 'calendar_event_commands',
|
|
2490
|
+
'hold_commands', 'waitlist_entries'
|
|
2491
|
+
] LOOP
|
|
2492
|
+
EXECUTE pg_catalog.format(
|
|
2493
|
+
'CREATE OR REPLACE TRIGGER slotlock_list_maintenance_tenant
|
|
2494
|
+
AFTER INSERT ON slotlock.%I REFERENCING NEW TABLE AS appended
|
|
2495
|
+
FOR EACH STATEMENT EXECUTE FUNCTION slotlock.list_maintenance_tenant()', tenant_table);
|
|
2496
|
+
END LOOP;
|
|
2497
|
+
END
|
|
2498
|
+
$slotlock_maintenance_tenants_triggers$;
|
|
2499
|
+
|
|
2500
|
+
-- A page of 1,000 tenant names after \`after\` (NULL: from the start); a caller pages to the end.
|
|
2501
|
+
CREATE OR REPLACE FUNCTION slotlock.maintenance_tenants(after text)
|
|
2502
|
+
RETURNS SETOF text
|
|
2503
|
+
LANGUAGE sql
|
|
2504
|
+
STABLE
|
|
2505
|
+
SECURITY DEFINER
|
|
2506
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2507
|
+
AS $slotlock_maintenance_tenants$
|
|
2508
|
+
SELECT tenant_ref FROM slotlock.maintenance_tenants
|
|
2509
|
+
WHERE after IS NULL OR tenant_ref > after
|
|
2510
|
+
ORDER BY tenant_ref LIMIT 1000;
|
|
2511
|
+
$slotlock_maintenance_tenants$;
|
|
2512
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[9]} FROM PUBLIC;
|
|
1195
2513
|
`;
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
// (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
|
|
1202
|
-
// the table forces: the check scans the table itself, and referential checks bypass row security.
|
|
1203
|
-
// One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
|
|
1204
|
-
// it binds every new row either way, and applySchema tries it again at each later migration. Once
|
|
1205
|
-
// valid, a constraint is never checked again.
|
|
2514
|
+
// Validate constraints an upgrade added NOT VALID. applySchema runs this in its own transaction
|
|
2515
|
+
// after the DDL commits: inside the DDL's, altered tables stay ACCESS EXCLUSIVE and the scan would
|
|
2516
|
+
// block serving queries. Alone, VALIDATE takes SHARE UPDATE EXCLUSIVE, which no read or row write
|
|
2517
|
+
// conflicts with (PostgreSQL 17 docs), and it sees every row regardless of RLS. One old rows still
|
|
2518
|
+
// break stays NOT VALID with a notice (it still binds new rows) and is retried at each migration.
|
|
1206
2519
|
const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
|
|
1207
2520
|
DECLARE
|
|
1208
2521
|
unchecked record;
|
|
@@ -1228,7 +2541,6 @@ $slotlock_validate_constraints$;
|
|
|
1228
2541
|
`;
|
|
1229
2542
|
/** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
|
|
1230
2543
|
export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
|
|
1231
|
-
// The digests that let the next migration skip are recorded by applySchema once it has validated.
|
|
1232
2544
|
const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
|
|
1233
2545
|
VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
|
|
1234
2546
|
ON CONFLICT (singleton) DO UPDATE
|
|
@@ -1378,7 +2690,68 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
|
|
|
1378
2690
|
last_error IS NULL OR char_length(last_error) <= 500
|
|
1379
2691
|
)
|
|
1380
2692
|
);
|
|
2693
|
+
-- A create's retry identity (RFC 0004 P0-5), kept as long as the endpoint: the same key and
|
|
2694
|
+
-- request answer the endpoint (and its secret) the first made, another is idempotency_conflict.
|
|
2695
|
+
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS idempotency_key text;
|
|
2696
|
+
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS payload_hash text;
|
|
2697
|
+
DO $slotlock_webhook_key$
|
|
2698
|
+
BEGIN
|
|
2699
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_webhook_endpoints_key_valid'
|
|
2700
|
+
AND conrelid = 'slotlock.webhook_endpoints'::regclass) THEN
|
|
2701
|
+
ALTER TABLE slotlock.webhook_endpoints ADD CONSTRAINT slotlock_webhook_endpoints_key_valid CHECK (
|
|
2702
|
+
(idempotency_key IS NULL AND payload_hash IS NULL)
|
|
2703
|
+
OR (octet_length(idempotency_key) BETWEEN 1 AND 500 AND payload_hash ~ '^[0-9a-f]{64}$')
|
|
2704
|
+
) NOT VALID;
|
|
2705
|
+
END IF;
|
|
2706
|
+
END
|
|
2707
|
+
$slotlock_webhook_key$;
|
|
2708
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_webhook_endpoints_key_idx
|
|
2709
|
+
ON slotlock.webhook_endpoints (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
1381
2710
|
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
|
|
2711
|
+
-- Which resources' changes an endpoint receives (RFC 0003 G5): with any of these, only changes about
|
|
2712
|
+
-- a resource it names, or one in a pool, at a location (or a location in it) or in a group it names,
|
|
2713
|
+
-- matched as the change is delivered. Null: no such filter.
|
|
2714
|
+
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_resource_ids uuid[];
|
|
2715
|
+
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_pools text[];
|
|
2716
|
+
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_locations text[];
|
|
2717
|
+
ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_groups text[];
|
|
2718
|
+
DO $slotlock_webhook_filter$
|
|
2719
|
+
BEGIN
|
|
2720
|
+
IF NOT EXISTS (
|
|
2721
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
2722
|
+
WHERE conrelid = 'slotlock.webhook_endpoints'::regclass
|
|
2723
|
+
AND conname = 'slotlock_webhook_endpoints_filter_valid'
|
|
2724
|
+
) THEN
|
|
2725
|
+
ALTER TABLE slotlock.webhook_endpoints
|
|
2726
|
+
ADD CONSTRAINT slotlock_webhook_endpoints_filter_valid CHECK (
|
|
2727
|
+
(filter_resource_ids IS NULL
|
|
2728
|
+
OR cardinality(filter_resource_ids) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_RESOURCES})
|
|
2729
|
+
AND (filter_pools IS NULL OR cardinality(filter_pools) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_REFS})
|
|
2730
|
+
AND (filter_locations IS NULL OR cardinality(filter_locations) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_REFS})
|
|
2731
|
+
AND (filter_groups IS NULL OR cardinality(filter_groups) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_REFS})
|
|
2732
|
+
) NOT VALID;
|
|
2733
|
+
END IF;
|
|
2734
|
+
END
|
|
2735
|
+
$slotlock_webhook_filter$;
|
|
2736
|
+
|
|
2737
|
+
-- A location and every location it is in, by reference (none for none): what a location filter
|
|
2738
|
+
-- matches a resource's location against. Run as its caller, in the tenant's context.
|
|
2739
|
+
CREATE OR REPLACE FUNCTION slotlock.location_path(requested_tenant_ref text, requested_ref text)
|
|
2740
|
+
RETURNS text[]
|
|
2741
|
+
LANGUAGE sql
|
|
2742
|
+
STABLE
|
|
2743
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2744
|
+
AS $slotlock_location_path$
|
|
2745
|
+
WITH RECURSIVE up(ref, parent_ref, steps) AS (
|
|
2746
|
+
SELECT place.ref, place.parent_ref, 1 FROM slotlock.locations place
|
|
2747
|
+
WHERE place.tenant_ref = requested_tenant_ref AND place.ref = requested_ref
|
|
2748
|
+
UNION ALL
|
|
2749
|
+
SELECT place.ref, place.parent_ref, up.steps + 1
|
|
2750
|
+
FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
|
|
2751
|
+
WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
2752
|
+
)
|
|
2753
|
+
SELECT COALESCE(array_agg(up.ref), '{}'::text[]) FROM up;
|
|
2754
|
+
$slotlock_location_path$;
|
|
1382
2755
|
-- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
|
|
1383
2756
|
-- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
|
|
1384
2757
|
DO $slotlock_webhook_type_count$
|
|
@@ -1431,6 +2804,23 @@ CREATE TABLE IF NOT EXISTS slotlock.waitlist_entries (
|
|
|
1431
2804
|
);
|
|
1432
2805
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
|
|
1433
2806
|
ON slotlock.waitlist_entries (tenant_ref, created_at, id) WHERE status = 'waiting';
|
|
2807
|
+
-- A join's retry identity (RFC 0004 P0-5), kept as long as the entry: the same key and request
|
|
2808
|
+
-- answer the entry the first made, another request is idempotency_conflict.
|
|
2809
|
+
ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS idempotency_key text;
|
|
2810
|
+
ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS payload_hash text;
|
|
2811
|
+
DO $slotlock_waitlist_key$
|
|
2812
|
+
BEGIN
|
|
2813
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_waitlist_entries_key_valid'
|
|
2814
|
+
AND conrelid = 'slotlock.waitlist_entries'::regclass) THEN
|
|
2815
|
+
ALTER TABLE slotlock.waitlist_entries ADD CONSTRAINT slotlock_waitlist_entries_key_valid CHECK (
|
|
2816
|
+
(idempotency_key IS NULL AND payload_hash IS NULL)
|
|
2817
|
+
OR (octet_length(idempotency_key) BETWEEN 1 AND 500 AND payload_hash ~ '^[0-9a-f]{64}$')
|
|
2818
|
+
) NOT VALID;
|
|
2819
|
+
END IF;
|
|
2820
|
+
END
|
|
2821
|
+
$slotlock_waitlist_key$;
|
|
2822
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_waitlist_entries_key_idx
|
|
2823
|
+
ON slotlock.waitlist_entries (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
1434
2824
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
|
|
1435
2825
|
ON slotlock.waitlist_entries (tenant_ref, starts_at);
|
|
1436
2826
|
|
|
@@ -1451,6 +2841,46 @@ CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
|
|
|
1451
2841
|
)
|
|
1452
2842
|
);
|
|
1453
2843
|
|
|
2844
|
+
-- The order a pool booking tries a pool's members in (RFC 0003 G7): stable (by id), least_booked
|
|
2845
|
+
-- (fewest bookings made on each in the last 30 days first) or round_robin (from the one after the
|
|
2846
|
+
-- member booked last). A pool without a row is stable.
|
|
2847
|
+
CREATE TABLE IF NOT EXISTS slotlock.pool_settings (
|
|
2848
|
+
tenant_ref text NOT NULL,
|
|
2849
|
+
pool_ref text NOT NULL,
|
|
2850
|
+
member_order text NOT NULL DEFAULT 'stable',
|
|
2851
|
+
last_member uuid,
|
|
2852
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
2853
|
+
CONSTRAINT slotlock_pool_settings_pkey PRIMARY KEY (tenant_ref, pool_ref),
|
|
2854
|
+
CONSTRAINT slotlock_pool_settings_valid CHECK (
|
|
2855
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
2856
|
+
AND pool_ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$'
|
|
2857
|
+
AND member_order IN (${SLOTLOCK_POOL_ORDERS.map((order) => `'${order}'`).join(', ')})
|
|
2858
|
+
)
|
|
2859
|
+
);
|
|
2860
|
+
|
|
2861
|
+
-- At most SLOTLOCK_MAX_POOL_SETTINGS pools a tenant set an order for, under the tenant's lock.
|
|
2862
|
+
CREATE OR REPLACE FUNCTION slotlock.pool_settings_guard()
|
|
2863
|
+
RETURNS trigger
|
|
2864
|
+
LANGUAGE plpgsql
|
|
2865
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
2866
|
+
AS $slotlock_pool_settings_guard$
|
|
2867
|
+
BEGIN
|
|
2868
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:pool-settings:' || NEW.tenant_ref, 0));
|
|
2869
|
+
-- An upsert of a pool the tenant has set fires this before its conflict is found.
|
|
2870
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.pool_settings kept
|
|
2871
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.pool_ref = NEW.pool_ref)
|
|
2872
|
+
AND (SELECT count(*) FROM slotlock.pool_settings kept WHERE kept.tenant_ref = NEW.tenant_ref)
|
|
2873
|
+
>= ${SLOTLOCK_MAX_POOL_SETTINGS} THEN
|
|
2874
|
+
RAISE EXCEPTION 'Slotlock keeps an order for at most ${SLOTLOCK_MAX_POOL_SETTINGS} pools a tenant'
|
|
2875
|
+
USING ERRCODE = 'check_violation', HINT = 'pool_order_limit_reached';
|
|
2876
|
+
END IF;
|
|
2877
|
+
RETURN NEW;
|
|
2878
|
+
END
|
|
2879
|
+
$slotlock_pool_settings_guard$;
|
|
2880
|
+
CREATE OR REPLACE TRIGGER slotlock_pool_settings_guard
|
|
2881
|
+
BEFORE INSERT ON slotlock.pool_settings
|
|
2882
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.pool_settings_guard();
|
|
2883
|
+
|
|
1454
2884
|
-- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
|
|
1455
2885
|
-- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
|
|
1456
2886
|
-- bound to the tenant), with its last four characters to show.
|
|
@@ -1539,7 +2969,11 @@ BEGIN
|
|
|
1539
2969
|
kind := CASE WHEN NEW.status = 'held' THEN 'hold.updated' ELSE 'reservation.updated' END;
|
|
1540
2970
|
END IF;
|
|
1541
2971
|
ELSE
|
|
2972
|
+
-- A no-show release (releaseNoShows) names its deletion, so the feed has one change for it,
|
|
2973
|
+
-- not a cancellation as well. The marker only renames: a deletion is always recorded.
|
|
1542
2974
|
kind := CASE
|
|
2975
|
+
WHEN OLD.status <> 'held' AND current_setting('slotlock.ending', true) = 'no_show'
|
|
2976
|
+
THEN 'reservation.no_show'
|
|
1543
2977
|
WHEN OLD.status <> 'held' THEN 'reservation.cancelled'
|
|
1544
2978
|
WHEN OLD.expires_at <= statement_timestamp() THEN 'hold.expired'
|
|
1545
2979
|
ELSE 'hold.released'
|
|
@@ -1556,7 +2990,12 @@ BEGIN
|
|
|
1556
2990
|
'start', changed.starts_at,
|
|
1557
2991
|
'end', changed.ends_at,
|
|
1558
2992
|
'buffer_after_ms', changed.buffer_after_ms,
|
|
1559
|
-
|
|
2993
|
+
-- A no-show carries the revision its reference is retired at, and the check-in window.
|
|
2994
|
+
'revision', CASE WHEN changed.status = 'held' THEN NULL
|
|
2995
|
+
WHEN kind = 'reservation.no_show' THEN changed.revision + 1
|
|
2996
|
+
ELSE changed.revision END,
|
|
2997
|
+
'check_in_minutes', CASE WHEN kind = 'reservation.no_show'
|
|
2998
|
+
THEN NULLIF(current_setting('slotlock.ending_detail', true), '')::int END,
|
|
1560
2999
|
'expires_at', CASE WHEN changed.status = 'held' THEN changed.expires_at END,
|
|
1561
3000
|
-- A move says where it came from.
|
|
1562
3001
|
'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
|
|
@@ -1706,9 +3145,11 @@ LANGUAGE sql
|
|
|
1706
3145
|
STABLE
|
|
1707
3146
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1708
3147
|
AS $slotlock_outbox_erasing$
|
|
1709
|
-
|
|
3148
|
+
-- Never null: the erasing setting is unset outside erasure, and a null here would read as neither
|
|
3149
|
+
-- erasing nor not to a caller that writes NOT outbox_erasing(...).
|
|
3150
|
+
SELECT COALESCE(owner = current_setting('slotlock.erasing', true)
|
|
1710
3151
|
AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
|
|
1711
|
-
WHERE nspname = 'slotlock');
|
|
3152
|
+
WHERE nspname = 'slotlock'), false);
|
|
1712
3153
|
$slotlock_outbox_erasing$;
|
|
1713
3154
|
|
|
1714
3155
|
CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
|
|
@@ -1721,6 +3162,8 @@ DECLARE
|
|
|
1721
3162
|
kind text;
|
|
1722
3163
|
before jsonb := '{}'::jsonb;
|
|
1723
3164
|
after jsonb := '{}'::jsonb;
|
|
3165
|
+
-- The profile fields that changed, by name only: a name or description is calendar content.
|
|
3166
|
+
profile_changed text[] := '{}';
|
|
1724
3167
|
details jsonb;
|
|
1725
3168
|
BEGIN
|
|
1726
3169
|
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
@@ -1728,7 +3171,7 @@ BEGIN
|
|
|
1728
3171
|
IF TG_OP = 'INSERT' THEN
|
|
1729
3172
|
kind := 'resource.created';
|
|
1730
3173
|
details := jsonb_build_object(
|
|
1731
|
-
'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref,
|
|
3174
|
+
'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref, 'location', NEW.location_ref,
|
|
1732
3175
|
'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
|
|
1733
3176
|
ELSIF TG_OP = 'DELETE' THEN
|
|
1734
3177
|
kind := 'resource.deleted';
|
|
@@ -1750,6 +3193,16 @@ BEGIN
|
|
|
1750
3193
|
before := before || jsonb_build_object('pool', OLD.pool_ref);
|
|
1751
3194
|
after := after || jsonb_build_object('pool', NEW.pool_ref);
|
|
1752
3195
|
END IF;
|
|
3196
|
+
IF OLD.location_ref IS DISTINCT FROM NEW.location_ref THEN
|
|
3197
|
+
before := before || jsonb_build_object('location', OLD.location_ref);
|
|
3198
|
+
after := after || jsonb_build_object('location', NEW.location_ref);
|
|
3199
|
+
END IF;
|
|
3200
|
+
-- The composite it is a part of (RFC 0003 G8), read through to_jsonb: a migration that runs
|
|
3201
|
+
-- this before the column exists sees no change rather than an error.
|
|
3202
|
+
IF (to_jsonb(OLD) -> 'parent_id') IS DISTINCT FROM (to_jsonb(NEW) -> 'parent_id') THEN
|
|
3203
|
+
before := before || jsonb_build_object('parent', to_jsonb(OLD) -> 'parent_id');
|
|
3204
|
+
after := after || jsonb_build_object('parent', to_jsonb(NEW) -> 'parent_id');
|
|
3205
|
+
END IF;
|
|
1753
3206
|
IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
|
|
1754
3207
|
before := before || jsonb_build_object('availability', OLD.availability_rules);
|
|
1755
3208
|
after := after || jsonb_build_object('availability', NEW.availability_rules);
|
|
@@ -1758,9 +3211,24 @@ BEGIN
|
|
|
1758
3211
|
before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
|
|
1759
3212
|
after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
|
|
1760
3213
|
END IF;
|
|
1761
|
-
IF
|
|
3214
|
+
IF OLD.name IS DISTINCT FROM NEW.name THEN profile_changed := profile_changed || 'name'::text; END IF;
|
|
3215
|
+
IF OLD.description IS DISTINCT FROM NEW.description THEN
|
|
3216
|
+
profile_changed := profile_changed || 'description'::text;
|
|
3217
|
+
END IF;
|
|
3218
|
+
IF OLD.kind IS DISTINCT FROM NEW.kind THEN profile_changed := profile_changed || 'kind'::text; END IF;
|
|
3219
|
+
IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN
|
|
3220
|
+
profile_changed := profile_changed || 'capacity'::text;
|
|
3221
|
+
END IF;
|
|
3222
|
+
IF OLD.labels IS DISTINCT FROM NEW.labels THEN profile_changed := profile_changed || 'labels'::text; END IF;
|
|
3223
|
+
IF OLD.attributes IS DISTINCT FROM NEW.attributes THEN
|
|
3224
|
+
profile_changed := profile_changed || 'attributes'::text;
|
|
3225
|
+
END IF;
|
|
3226
|
+
IF after = '{}'::jsonb AND cardinality(profile_changed) = 0 THEN RETURN NULL; END IF;
|
|
1762
3227
|
kind := 'resource.updated';
|
|
1763
|
-
details := jsonb_build_object('before', before, 'after', after)
|
|
3228
|
+
details := jsonb_build_object('before', before, 'after', after)
|
|
3229
|
+
|| CASE WHEN cardinality(profile_changed) > 0
|
|
3230
|
+
THEN jsonb_build_object('profile_changed', to_jsonb(profile_changed))
|
|
3231
|
+
ELSE '{}'::jsonb END;
|
|
1764
3232
|
END IF;
|
|
1765
3233
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1766
3234
|
VALUES (
|
|
@@ -1832,61 +3300,574 @@ BEGIN
|
|
|
1832
3300
|
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
|
|
1833
3301
|
VALUES (
|
|
1834
3302
|
changed.tenant_ref, kind, changed.id::text,
|
|
1835
|
-
jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types))
|
|
3303
|
+
jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types))
|
|
3304
|
+
|| CASE WHEN changed.filter_resource_ids IS NULL AND changed.filter_pools IS NULL
|
|
3305
|
+
AND changed.filter_locations IS NULL AND changed.filter_groups IS NULL
|
|
3306
|
+
THEN '{}'::jsonb
|
|
3307
|
+
ELSE jsonb_build_object('filter', jsonb_strip_nulls(jsonb_build_object(
|
|
3308
|
+
'resource_ids', to_jsonb(changed.filter_resource_ids), 'pools', to_jsonb(changed.filter_pools),
|
|
3309
|
+
'locations', to_jsonb(changed.filter_locations), 'groups', to_jsonb(changed.filter_groups))))
|
|
3310
|
+
END,
|
|
1836
3311
|
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1837
3312
|
);
|
|
1838
3313
|
RETURN NULL;
|
|
1839
3314
|
END
|
|
1840
3315
|
$slotlock_outbox_webhook$;
|
|
1841
3316
|
|
|
3317
|
+
-- A resource deleted: resource.deleted, recorded just before its row goes, while its groups are
|
|
3318
|
+
-- still there (a foreign key's cascade takes them before any AFTER trigger of the resource runs),
|
|
3319
|
+
-- with where it was: its pool, location and groups, so an endpoint filtered by them still receives
|
|
3320
|
+
-- it (event-feed.ts FILTER_MATCH). A delete that fails takes this row back with it.
|
|
3321
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_resource_deleting()
|
|
3322
|
+
RETURNS trigger
|
|
3323
|
+
LANGUAGE plpgsql
|
|
3324
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3325
|
+
AS $slotlock_outbox_resource_deleting$
|
|
3326
|
+
DECLARE
|
|
3327
|
+
was_in text[];
|
|
3328
|
+
BEGIN
|
|
3329
|
+
IF OLD.tenant_ref IS NULL OR slotlock.outbox_erasing(OLD.tenant_ref) THEN RETURN OLD; END IF;
|
|
3330
|
+
was_in := ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
|
|
3331
|
+
WHERE member.tenant_ref = OLD.tenant_ref AND member.resource_id = OLD.id
|
|
3332
|
+
ORDER BY member.group_ref);
|
|
3333
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
3334
|
+
VALUES (
|
|
3335
|
+
OLD.tenant_ref, OLD.id, 'resource.deleted', OLD.id::text,
|
|
3336
|
+
jsonb_build_object('id', OLD.id, 'reference', OLD.external_ref)
|
|
3337
|
+
|| jsonb_strip_nulls(jsonb_build_object('pool', OLD.pool_ref, 'location', OLD.location_ref))
|
|
3338
|
+
|| CASE WHEN cardinality(was_in) > 0 THEN jsonb_build_object('groups', to_jsonb(was_in)) ELSE '{}'::jsonb END,
|
|
3339
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
3340
|
+
);
|
|
3341
|
+
RETURN OLD;
|
|
3342
|
+
END
|
|
3343
|
+
$slotlock_outbox_resource_deleting$;
|
|
3344
|
+
|
|
3345
|
+
-- Deletion has its own trigger, before the row goes (outbox_resource_deleting).
|
|
1842
3346
|
CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
|
|
1843
|
-
AFTER INSERT OR UPDATE
|
|
3347
|
+
AFTER INSERT OR UPDATE ON slotlock.resources
|
|
1844
3348
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_change();
|
|
3349
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_resource_deleting
|
|
3350
|
+
BEFORE DELETE ON slotlock.resources
|
|
3351
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_deleting();
|
|
1845
3352
|
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
|
|
1846
3353
|
AFTER INSERT OR DELETE ON slotlock.calendar_coverage
|
|
1847
3354
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
|
|
1848
3355
|
|
|
1849
|
-
-- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
|
|
1850
|
-
-- named by its source's source_id (src_redacted for a source it cannot find), without the source or
|
|
1851
|
-
-- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
|
|
1852
|
-
-- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
|
|
1853
|
-
-- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
|
|
1854
|
-
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
|
|
1855
|
-
RETURNS trigger
|
|
3356
|
+
-- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
|
|
3357
|
+
-- named by its source's source_id (src_redacted for a source it cannot find), without the source or
|
|
3358
|
+
-- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
|
|
3359
|
+
-- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
|
|
3360
|
+
-- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
|
|
3361
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
|
|
3362
|
+
RETURNS trigger
|
|
3363
|
+
LANGUAGE plpgsql
|
|
3364
|
+
SECURITY INVOKER
|
|
3365
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3366
|
+
AS $slotlock_outbox_coverage_in_shape$
|
|
3367
|
+
DECLARE
|
|
3368
|
+
named text;
|
|
3369
|
+
BEGIN
|
|
3370
|
+
IF ${coverageChangeInShapeSql('NEW')} THEN RETURN NEW; END IF;
|
|
3371
|
+
named := ${coverageChangeSourceIdSql('NEW')};
|
|
3372
|
+
NEW.subject := named;
|
|
3373
|
+
NEW.data := (NEW.data - 'source' - 'last_error') || pg_catalog.jsonb_build_object('source_id', named);
|
|
3374
|
+
RETURN NEW;
|
|
3375
|
+
END
|
|
3376
|
+
$slotlock_outbox_coverage_in_shape$;
|
|
3377
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_in_shape
|
|
3378
|
+
BEFORE INSERT ON slotlock.outbox
|
|
3379
|
+
FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
|
|
3380
|
+
EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
|
|
3381
|
+
-- A resource put in a group or taken out of one (RFC 0003 G3): resource.updated, with the groups
|
|
3382
|
+
-- it was in before and is in after, by reference, sorted. Once per statement, from the rows the
|
|
3383
|
+
-- statement changed, so each resource it touched has one change, whatever its other groups; adding
|
|
3384
|
+
-- a resource to a group it is in changes no row and appends none. Taken out because its group was
|
|
3385
|
+
-- deleted is a change; because the resource was deleted (resource.deleted says so) or its tenant
|
|
3386
|
+
-- erased is not.
|
|
3387
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_group_membership_change()
|
|
3388
|
+
RETURNS trigger
|
|
3389
|
+
LANGUAGE plpgsql
|
|
3390
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3391
|
+
AS $slotlock_outbox_group_membership$
|
|
3392
|
+
BEGIN
|
|
3393
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
3394
|
+
SELECT resource.tenant_ref, resource.id, 'resource.updated', resource.id::text,
|
|
3395
|
+
jsonb_build_object(
|
|
3396
|
+
'id', resource.id, 'reference', resource.external_ref,
|
|
3397
|
+
'before', jsonb_build_object('groups', to_jsonb(CASE WHEN TG_OP = 'INSERT'
|
|
3398
|
+
THEN ARRAY(SELECT kept FROM unnest(now_in.refs) kept WHERE kept <> ALL (touched.groups) ORDER BY kept)
|
|
3399
|
+
ELSE ARRAY(SELECT DISTINCT was FROM unnest(now_in.refs || touched.groups) was ORDER BY was) END)),
|
|
3400
|
+
'after', jsonb_build_object('groups', to_jsonb(now_in.refs))),
|
|
3401
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
3402
|
+
FROM (SELECT changed.tenant_ref, changed.resource_id, array_agg(changed.group_ref) AS groups
|
|
3403
|
+
FROM changed_members changed
|
|
3404
|
+
GROUP BY changed.tenant_ref, changed.resource_id) touched
|
|
3405
|
+
JOIN slotlock.resources resource
|
|
3406
|
+
ON resource.id = touched.resource_id AND resource.tenant_ref = touched.tenant_ref
|
|
3407
|
+
CROSS JOIN LATERAL (
|
|
3408
|
+
SELECT ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
|
|
3409
|
+
WHERE member.tenant_ref = touched.tenant_ref AND member.resource_id = touched.resource_id
|
|
3410
|
+
ORDER BY member.group_ref) AS refs) now_in
|
|
3411
|
+
-- IS NOT TRUE, not NOT: outbox_erasing is null for the schema owner outside erasure, and a
|
|
3412
|
+
-- cascade's trigger runs as the owner on PostgreSQL 18.
|
|
3413
|
+
WHERE slotlock.outbox_erasing(touched.tenant_ref) IS NOT TRUE
|
|
3414
|
+
ORDER BY resource.id;
|
|
3415
|
+
RETURN NULL;
|
|
3416
|
+
END
|
|
3417
|
+
$slotlock_outbox_group_membership$;
|
|
3418
|
+
-- A trigger with a transition table takes one event; both name it changed_members for the function.
|
|
3419
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_group_member_added
|
|
3420
|
+
AFTER INSERT ON slotlock.resource_group_members
|
|
3421
|
+
REFERENCING NEW TABLE AS changed_members
|
|
3422
|
+
FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_group_membership_change();
|
|
3423
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_group_member_removed
|
|
3424
|
+
AFTER DELETE ON slotlock.resource_group_members
|
|
3425
|
+
REFERENCING OLD TABLE AS changed_members
|
|
3426
|
+
FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_group_membership_change();
|
|
3427
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
|
|
3428
|
+
AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
|
|
3429
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
3430
|
+
-- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
|
|
3431
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
|
|
3432
|
+
AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
|
|
3433
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
3434
|
+
|
|
3435
|
+
DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
|
|
3436
|
+
CREATE TRIGGER slotlock_outbox_reservation_change
|
|
3437
|
+
AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
|
|
3438
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_reservation_change();
|
|
3439
|
+
DROP TRIGGER IF EXISTS slotlock_outbox_calendar_event_change ON slotlock.calendar_events;
|
|
3440
|
+
CREATE TRIGGER slotlock_outbox_calendar_event_change
|
|
3441
|
+
AFTER INSERT OR UPDATE OR DELETE ON slotlock.calendar_events
|
|
3442
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_calendar_event_change();
|
|
3443
|
+
`;
|
|
3444
|
+
/**
|
|
3445
|
+
* Partner accounts (RFC 0003 part 4, P1-P4, P6): a partner (stackapi.app, say) creates and runs
|
|
3446
|
+
* Slotlock tenants for its own users, `partner:<partner>:<account>`, with plans from its contract,
|
|
3447
|
+
* and Pylota invoices the partner. Owner-only tables; the serving role reaches them only through
|
|
3448
|
+
* SLOTLOCK_PARTNER_FUNCTIONS, each scoped to the one partner a partner key names.
|
|
3449
|
+
*/
|
|
3450
|
+
const SLOTLOCK_PARTNER_DDL = `
|
|
3451
|
+
CREATE TABLE IF NOT EXISTS slotlock.partners (
|
|
3452
|
+
id text PRIMARY KEY,
|
|
3453
|
+
name text NOT NULL,
|
|
3454
|
+
status text NOT NULL DEFAULT 'active',
|
|
3455
|
+
-- The plans its accounts may be on, the scopes their keys may have, and its prices (partners.ts).
|
|
3456
|
+
contract jsonb NOT NULL,
|
|
3457
|
+
stripe_customer text,
|
|
3458
|
+
-- Where usage.period_closed goes (P5), signed with the secret.
|
|
3459
|
+
webhook_url text,
|
|
3460
|
+
webhook_secret text,
|
|
3461
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3462
|
+
CONSTRAINT slotlock_partners_valid CHECK (
|
|
3463
|
+
id ~ '^[a-z][a-z0-9-]{1,39}$'
|
|
3464
|
+
AND char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'
|
|
3465
|
+
AND status IN ('active', 'suspended')
|
|
3466
|
+
AND jsonb_typeof(contract) = 'object'
|
|
3467
|
+
AND (stripe_customer IS NULL OR stripe_customer ~ '^cus_[A-Za-z0-9]{1,250}$')
|
|
3468
|
+
AND (webhook_url IS NULL OR (webhook_url ~ '^https://' AND octet_length(webhook_url) <= 2048))
|
|
3469
|
+
AND (webhook_secret IS NULL OR webhook_secret ~ '^whsec_[A-Za-z0-9+/=]{32,200}$')
|
|
3470
|
+
)
|
|
3471
|
+
);
|
|
3472
|
+
ALTER TABLE slotlock.partners ENABLE ROW LEVEL SECURITY;
|
|
3473
|
+
REVOKE ALL ON slotlock.partners FROM PUBLIC;
|
|
3474
|
+
|
|
3475
|
+
CREATE TABLE IF NOT EXISTS slotlock.partner_keys (
|
|
3476
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3477
|
+
partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE CASCADE,
|
|
3478
|
+
name text NOT NULL,
|
|
3479
|
+
prefix text NOT NULL,
|
|
3480
|
+
secret_hash bytea NOT NULL,
|
|
3481
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3482
|
+
last_used_at timestamptz,
|
|
3483
|
+
revoked_at timestamptz,
|
|
3484
|
+
CONSTRAINT slotlock_partner_keys_secret_hash_key UNIQUE (secret_hash),
|
|
3485
|
+
CONSTRAINT slotlock_partner_keys_valid CHECK (
|
|
3486
|
+
octet_length(secret_hash) = 32
|
|
3487
|
+
AND char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'
|
|
3488
|
+
AND prefix ~ '^slk_partner_[0-9A-Za-z]{6}$'
|
|
3489
|
+
)
|
|
3490
|
+
);
|
|
3491
|
+
ALTER TABLE slotlock.partner_keys ENABLE ROW LEVEL SECURITY;
|
|
3492
|
+
REVOKE ALL ON slotlock.partner_keys FROM PUBLIC;
|
|
3493
|
+
|
|
3494
|
+
CREATE TABLE IF NOT EXISTS slotlock.partner_accounts (
|
|
3495
|
+
partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
|
|
3496
|
+
account text NOT NULL,
|
|
3497
|
+
tenant_ref text NOT NULL,
|
|
3498
|
+
plan text NOT NULL,
|
|
3499
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3500
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3501
|
+
CONSTRAINT slotlock_partner_accounts_pkey PRIMARY KEY (partner_id, account),
|
|
3502
|
+
CONSTRAINT slotlock_partner_accounts_tenant_key UNIQUE (tenant_ref),
|
|
3503
|
+
CONSTRAINT slotlock_partner_accounts_valid CHECK (
|
|
3504
|
+
account ~ '^[A-Za-z0-9._-]{1,128}$'
|
|
3505
|
+
AND tenant_ref = 'partner:' || partner_id || ':' || account
|
|
3506
|
+
AND plan ~ '^[a-z][a-z0-9_]{0,40}$'
|
|
3507
|
+
)
|
|
3508
|
+
);
|
|
3509
|
+
ALTER TABLE slotlock.partner_accounts ENABLE ROW LEVEL SECURITY;
|
|
3510
|
+
REVOKE ALL ON slotlock.partner_accounts FROM PUBLIC;
|
|
3511
|
+
|
|
3512
|
+
-- Each line of a partner's month (P6), recorded before it is sent to Stripe and with its Stripe id
|
|
3513
|
+
-- after, so a month is billed once however often the job runs.
|
|
3514
|
+
CREATE TABLE IF NOT EXISTS slotlock.partner_invoices (
|
|
3515
|
+
partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
|
|
3516
|
+
month date NOT NULL,
|
|
3517
|
+
line text NOT NULL,
|
|
3518
|
+
description text NOT NULL,
|
|
3519
|
+
quantity bigint NOT NULL,
|
|
3520
|
+
amount bigint NOT NULL,
|
|
3521
|
+
currency text NOT NULL,
|
|
3522
|
+
stripe_item text,
|
|
3523
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3524
|
+
sent_at timestamptz,
|
|
3525
|
+
CONSTRAINT slotlock_partner_invoices_pkey PRIMARY KEY (partner_id, month, line),
|
|
3526
|
+
CONSTRAINT slotlock_partner_invoices_valid CHECK (
|
|
3527
|
+
extract(day FROM month) = 1
|
|
3528
|
+
AND line ~ '^[a-z][a-z0-9_]{0,60}$'
|
|
3529
|
+
AND char_length(description) BETWEEN 1 AND 500
|
|
3530
|
+
AND quantity >= 0 AND amount >= 0
|
|
3531
|
+
AND currency ~ '^[a-z]{3}$'
|
|
3532
|
+
AND (stripe_item IS NULL OR stripe_item ~ '^ii_[A-Za-z0-9]{1,250}$')
|
|
3533
|
+
)
|
|
3534
|
+
);
|
|
3535
|
+
ALTER TABLE slotlock.partner_invoices ENABLE ROW LEVEL SECURITY;
|
|
3536
|
+
REVOKE ALL ON slotlock.partner_invoices FROM PUBLIC;
|
|
3537
|
+
|
|
3538
|
+
-- A partner's month as it was first closed (P5, P6): each account's totals and the checksum the
|
|
3539
|
+
-- usage.period_closed webhook carries. Every later run sends the same body under the same id, and
|
|
3540
|
+
-- the month's invoice lines are priced from these totals only, once.
|
|
3541
|
+
CREATE TABLE IF NOT EXISTS slotlock.partner_month_closes (
|
|
3542
|
+
partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
|
|
3543
|
+
month date NOT NULL,
|
|
3544
|
+
accounts jsonb NOT NULL,
|
|
3545
|
+
checksum text NOT NULL,
|
|
3546
|
+
closed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3547
|
+
webhook_status integer,
|
|
3548
|
+
webhook_delivered_at timestamptz,
|
|
3549
|
+
CONSTRAINT slotlock_partner_month_closes_pkey PRIMARY KEY (partner_id, month),
|
|
3550
|
+
CONSTRAINT slotlock_partner_month_closes_valid CHECK (
|
|
3551
|
+
extract(day FROM month) = 1 AND checksum ~ '^[0-9a-f]{64}$' AND jsonb_typeof(accounts) = 'array'
|
|
3552
|
+
AND (webhook_status IS NULL OR webhook_status BETWEEN 100 AND 599)
|
|
3553
|
+
)
|
|
3554
|
+
);
|
|
3555
|
+
ALTER TABLE slotlock.partner_month_closes ENABLE ROW LEVEL SECURITY;
|
|
3556
|
+
REVOKE ALL ON slotlock.partner_month_closes FROM PUBLIC;
|
|
3557
|
+
|
|
3558
|
+
-- What an account of a partner's used in a month not yet closed, kept when the account is erased
|
|
3559
|
+
-- (erase_tenant), so its month is still billed. By the partner's own account reference: the tenant
|
|
3560
|
+
-- and everything in it are gone.
|
|
3561
|
+
CREATE TABLE IF NOT EXISTS slotlock.partner_erased_usage (
|
|
3562
|
+
partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
|
|
3563
|
+
account text NOT NULL,
|
|
3564
|
+
plan text NOT NULL,
|
|
3565
|
+
month date NOT NULL,
|
|
3566
|
+
metric text NOT NULL,
|
|
3567
|
+
count bigint NOT NULL,
|
|
3568
|
+
erased_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3569
|
+
CONSTRAINT slotlock_partner_erased_usage_pkey PRIMARY KEY (partner_id, account, month, metric),
|
|
3570
|
+
CONSTRAINT slotlock_partner_erased_usage_valid CHECK (extract(day FROM month) = 1 AND count >= 0)
|
|
3571
|
+
);
|
|
3572
|
+
ALTER TABLE slotlock.partner_erased_usage ENABLE ROW LEVEL SECURITY;
|
|
3573
|
+
REVOKE ALL ON slotlock.partner_erased_usage FROM PUBLIC;
|
|
3574
|
+
|
|
3575
|
+
-- Each day a partner compared its counts with Slotlock's (P5, the partner API's reconcile call):
|
|
3576
|
+
-- how many accounts, how many differed by more than ${SLOTLOCK_RECONCILE_TOLERANCE_PERCENT}%, and by
|
|
3577
|
+
-- how much at most. A day with any is an incident on our side: \`slotlock billing daily\` lists it.
|
|
3578
|
+
CREATE TABLE IF NOT EXISTS slotlock.partner_reconciliations (
|
|
3579
|
+
partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE CASCADE,
|
|
3580
|
+
day date NOT NULL,
|
|
3581
|
+
accounts integer NOT NULL,
|
|
3582
|
+
mismatched integer NOT NULL,
|
|
3583
|
+
worst_percent numeric NOT NULL,
|
|
3584
|
+
reconciled_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
3585
|
+
CONSTRAINT slotlock_partner_reconciliations_pkey PRIMARY KEY (partner_id, day),
|
|
3586
|
+
CONSTRAINT slotlock_partner_reconciliations_valid CHECK (
|
|
3587
|
+
accounts BETWEEN 0 AND 100000 AND mismatched BETWEEN 0 AND accounts AND worst_percent >= 0
|
|
3588
|
+
)
|
|
3589
|
+
);
|
|
3590
|
+
ALTER TABLE slotlock.partner_reconciliations ENABLE ROW LEVEL SECURITY;
|
|
3591
|
+
REVOKE ALL ON slotlock.partner_reconciliations FROM PUBLIC;
|
|
3592
|
+
|
|
3593
|
+
-- Whether the tenant is a partner's account whose partner is not active: its keys authenticate as
|
|
3594
|
+
-- nothing and it is suspended with its partner (tenant_suspended). Any other tenant reference, one
|
|
3595
|
+
-- that merely starts with partner: included, is never blocked here.
|
|
3596
|
+
CREATE OR REPLACE FUNCTION slotlock.partner_tenant_blocked(requested_tenant_ref text)
|
|
3597
|
+
RETURNS boolean
|
|
3598
|
+
LANGUAGE sql
|
|
3599
|
+
STABLE
|
|
3600
|
+
SECURITY DEFINER
|
|
3601
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3602
|
+
AS $slotlock_partner_tenant_blocked$
|
|
3603
|
+
SELECT EXISTS (
|
|
3604
|
+
SELECT 1 FROM slotlock.partner_accounts owned
|
|
3605
|
+
JOIN slotlock.partners partner ON partner.id = owned.partner_id
|
|
3606
|
+
WHERE owned.tenant_ref = regexp_replace(requested_tenant_ref, '~test$', '')
|
|
3607
|
+
AND partner.status <> 'active'
|
|
3608
|
+
);
|
|
3609
|
+
$slotlock_partner_tenant_blocked$;
|
|
3610
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[0]} FROM PUBLIC;
|
|
3611
|
+
|
|
3612
|
+
-- A partner key's partner, while the key is not revoked and the partner is active; last_used_at
|
|
3613
|
+
-- moves at most once a minute.
|
|
3614
|
+
CREATE OR REPLACE FUNCTION slotlock.authenticate_partner_key(presented_secret_hash bytea)
|
|
3615
|
+
RETURNS TABLE (key_id uuid, partner_id text)
|
|
3616
|
+
LANGUAGE plpgsql
|
|
3617
|
+
VOLATILE
|
|
3618
|
+
SECURITY DEFINER
|
|
3619
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3620
|
+
AS $slotlock_authenticate_partner_key$
|
|
3621
|
+
#variable_conflict use_column
|
|
3622
|
+
BEGIN
|
|
3623
|
+
UPDATE slotlock.partner_keys used
|
|
3624
|
+
SET last_used_at = now()
|
|
3625
|
+
WHERE used.secret_hash = presented_secret_hash AND used.revoked_at IS NULL
|
|
3626
|
+
AND (used.last_used_at IS NULL OR used.last_used_at < now() - interval '1 minute');
|
|
3627
|
+
RETURN QUERY
|
|
3628
|
+
SELECT found.id, found.partner_id
|
|
3629
|
+
FROM slotlock.partner_keys found
|
|
3630
|
+
JOIN slotlock.partners partner ON partner.id = found.partner_id
|
|
3631
|
+
WHERE found.secret_hash = presented_secret_hash AND found.revoked_at IS NULL
|
|
3632
|
+
AND partner.status = 'active';
|
|
3633
|
+
END
|
|
3634
|
+
$slotlock_authenticate_partner_key$;
|
|
3635
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[1]} FROM PUBLIC;
|
|
3636
|
+
|
|
3637
|
+
CREATE OR REPLACE FUNCTION slotlock.partner_contract(requested_partner_id text)
|
|
3638
|
+
RETURNS jsonb
|
|
3639
|
+
LANGUAGE sql
|
|
3640
|
+
STABLE
|
|
3641
|
+
SECURITY DEFINER
|
|
3642
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3643
|
+
AS $slotlock_partner_contract$
|
|
3644
|
+
SELECT partner.contract FROM slotlock.partners partner
|
|
3645
|
+
WHERE partner.id = requested_partner_id AND partner.status = 'active';
|
|
3646
|
+
$slotlock_partner_contract$;
|
|
3647
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[2]} FROM PUBLIC;
|
|
3648
|
+
|
|
3649
|
+
-- Create the partner's account, or put it on another of its contract's plans: the account's tenant
|
|
3650
|
+
-- gets that plan's limits and quotas (tenant_plans, source partner). Refused (invalid_parameter_value)
|
|
3651
|
+
-- for a plan the contract does not have; nothing for a partner that is not active.
|
|
3652
|
+
CREATE OR REPLACE FUNCTION slotlock.put_partner_account(
|
|
3653
|
+
requested_partner_id text, requested_account text, requested_plan text
|
|
3654
|
+
)
|
|
3655
|
+
RETURNS TABLE (account text, tenant_ref text, plan text, created_at timestamptz, updated_at timestamptz, created boolean)
|
|
3656
|
+
LANGUAGE plpgsql
|
|
3657
|
+
VOLATILE
|
|
3658
|
+
SECURITY DEFINER
|
|
3659
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3660
|
+
AS $slotlock_put_partner_account$
|
|
3661
|
+
#variable_conflict use_column
|
|
3662
|
+
DECLARE
|
|
3663
|
+
terms jsonb;
|
|
3664
|
+
chosen jsonb;
|
|
3665
|
+
tenant text := 'partner:' || requested_partner_id || ':' || requested_account;
|
|
3666
|
+
existed boolean;
|
|
3667
|
+
BEGIN
|
|
3668
|
+
SELECT partner.contract INTO terms FROM slotlock.partners partner
|
|
3669
|
+
WHERE partner.id = requested_partner_id AND partner.status = 'active';
|
|
3670
|
+
IF NOT FOUND THEN RETURN; END IF;
|
|
3671
|
+
IF requested_account IS NULL OR requested_account !~ '^[A-Za-z0-9._-]{1,128}$' THEN
|
|
3672
|
+
RAISE EXCEPTION 'Slotlock partner account must be 1-128 letters, digits, dots, dashes or underscores'
|
|
3673
|
+
USING ERRCODE = 'invalid_parameter_value', HINT = 'invalid_partner_account';
|
|
3674
|
+
END IF;
|
|
3675
|
+
chosen := terms -> 'plans' -> requested_plan;
|
|
3676
|
+
IF requested_plan IS NULL OR chosen IS NULL OR jsonb_typeof(chosen) <> 'object' THEN
|
|
3677
|
+
RAISE EXCEPTION 'Slotlock partner plan is not one of the contract''s'
|
|
3678
|
+
USING ERRCODE = 'invalid_parameter_value', HINT = 'invalid_partner_plan';
|
|
3679
|
+
END IF;
|
|
3680
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || tenant, 0));
|
|
3681
|
+
existed := EXISTS (SELECT 1 FROM slotlock.partner_accounts kept
|
|
3682
|
+
WHERE kept.partner_id = requested_partner_id AND kept.account = requested_account);
|
|
3683
|
+
INSERT INTO slotlock.partner_accounts AS kept (partner_id, account, tenant_ref, plan)
|
|
3684
|
+
VALUES (requested_partner_id, requested_account, tenant, requested_plan)
|
|
3685
|
+
ON CONFLICT (partner_id, account) DO UPDATE
|
|
3686
|
+
SET plan = EXCLUDED.plan,
|
|
3687
|
+
updated_at = CASE WHEN kept.plan = EXCLUDED.plan THEN kept.updated_at ELSE statement_timestamp() END;
|
|
3688
|
+
INSERT INTO slotlock.tenant_plans AS kept (
|
|
3689
|
+
tenant_ref, plan, status, max_resources, max_api_keys, max_webhook_endpoints,
|
|
3690
|
+
source, partner_id, quota_operations, quota_bookings, quota_stop_percent
|
|
3691
|
+
) VALUES (
|
|
3692
|
+
tenant, requested_plan, 'active',
|
|
3693
|
+
(chosen ->> 'resources')::integer, (chosen ->> 'api_keys')::integer, (chosen ->> 'webhook_endpoints')::integer,
|
|
3694
|
+
'partner', requested_partner_id,
|
|
3695
|
+
(chosen ->> 'operations')::bigint, (chosen ->> 'bookings')::bigint, (chosen ->> 'stop_percent')::integer
|
|
3696
|
+
)
|
|
3697
|
+
ON CONFLICT (tenant_ref) DO UPDATE
|
|
3698
|
+
SET plan = EXCLUDED.plan, status = 'active',
|
|
3699
|
+
max_resources = EXCLUDED.max_resources, max_api_keys = EXCLUDED.max_api_keys,
|
|
3700
|
+
max_webhook_endpoints = EXCLUDED.max_webhook_endpoints,
|
|
3701
|
+
source = 'partner', partner_id = EXCLUDED.partner_id,
|
|
3702
|
+
quota_operations = EXCLUDED.quota_operations, quota_bookings = EXCLUDED.quota_bookings,
|
|
3703
|
+
quota_stop_percent = EXCLUDED.quota_stop_percent,
|
|
3704
|
+
stripe_customer = NULL, stripe_subscription = NULL, current_period_end = NULL,
|
|
3705
|
+
updated_at = statement_timestamp();
|
|
3706
|
+
RETURN QUERY
|
|
3707
|
+
SELECT kept.account, kept.tenant_ref, kept.plan, kept.created_at, kept.updated_at, NOT existed
|
|
3708
|
+
FROM slotlock.partner_accounts kept
|
|
3709
|
+
WHERE kept.partner_id = requested_partner_id AND kept.account = requested_account;
|
|
3710
|
+
END
|
|
3711
|
+
$slotlock_put_partner_account$;
|
|
3712
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[3]} FROM PUBLIC;
|
|
3713
|
+
|
|
3714
|
+
-- PL/pgSQL, as partner_usage: these read tables created after this section, which a SQL function's
|
|
3715
|
+
-- body may not name when it is created on a new schema.
|
|
3716
|
+
CREATE OR REPLACE FUNCTION slotlock.list_partner_accounts(requested_partner_id text)
|
|
3717
|
+
RETURNS TABLE (account text, plan text, suspended boolean, created_at timestamptz, updated_at timestamptz)
|
|
3718
|
+
LANGUAGE plpgsql
|
|
3719
|
+
STABLE
|
|
3720
|
+
SECURITY DEFINER
|
|
3721
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3722
|
+
AS $slotlock_list_partner_accounts$
|
|
3723
|
+
#variable_conflict use_column
|
|
3724
|
+
BEGIN
|
|
3725
|
+
RETURN QUERY
|
|
3726
|
+
SELECT owned.account, owned.plan,
|
|
3727
|
+
EXISTS (SELECT 1 FROM slotlock.tenant_suspensions held WHERE held.tenant_ref = owned.tenant_ref),
|
|
3728
|
+
owned.created_at, owned.updated_at
|
|
3729
|
+
FROM slotlock.partner_accounts owned
|
|
3730
|
+
WHERE owned.partner_id = requested_partner_id
|
|
3731
|
+
ORDER BY owned.account
|
|
3732
|
+
LIMIT 10000;
|
|
3733
|
+
END
|
|
3734
|
+
$slotlock_list_partner_accounts$;
|
|
3735
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[4]} FROM PUBLIC;
|
|
3736
|
+
|
|
3737
|
+
-- One account, with how many resources (archived ones included) and active keys it holds.
|
|
3738
|
+
CREATE OR REPLACE FUNCTION slotlock.get_partner_account(requested_partner_id text, requested_account text)
|
|
3739
|
+
RETURNS TABLE (account text, tenant_ref text, plan text, suspended boolean, created_at timestamptz,
|
|
3740
|
+
updated_at timestamptz, resources bigint, api_keys bigint)
|
|
3741
|
+
LANGUAGE plpgsql
|
|
3742
|
+
STABLE
|
|
3743
|
+
SECURITY DEFINER
|
|
3744
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3745
|
+
AS $slotlock_get_partner_account$
|
|
3746
|
+
#variable_conflict use_column
|
|
3747
|
+
DECLARE
|
|
3748
|
+
owned slotlock.partner_accounts%ROWTYPE;
|
|
3749
|
+
setting text;
|
|
3750
|
+
previous jsonb := '{}'::jsonb;
|
|
3751
|
+
counted bigint;
|
|
3752
|
+
BEGIN
|
|
3753
|
+
SELECT * INTO owned FROM slotlock.partner_accounts kept
|
|
3754
|
+
WHERE kept.partner_id = requested_partner_id AND kept.account = requested_account;
|
|
3755
|
+
IF NOT FOUND THEN RETURN; END IF;
|
|
3756
|
+
-- Resources have forced row-level security, their owner bound too: counted in the account's context.
|
|
3757
|
+
FOR setting IN SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
3758
|
+
LOOP
|
|
3759
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
3760
|
+
PERFORM set_config(setting, owned.tenant_ref, true);
|
|
3761
|
+
END LOOP;
|
|
3762
|
+
SELECT count(*) INTO counted FROM slotlock.resources held WHERE held.tenant_ref = owned.tenant_ref;
|
|
3763
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
3764
|
+
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
3765
|
+
END LOOP;
|
|
3766
|
+
RETURN QUERY
|
|
3767
|
+
SELECT owned.account, owned.tenant_ref, owned.plan,
|
|
3768
|
+
EXISTS (SELECT 1 FROM slotlock.tenant_suspensions held WHERE held.tenant_ref = owned.tenant_ref),
|
|
3769
|
+
owned.created_at, owned.updated_at, counted,
|
|
3770
|
+
(SELECT count(*) FROM slotlock.api_keys issued
|
|
3771
|
+
WHERE issued.tenant_ref = owned.tenant_ref AND issued.revoked_at IS NULL
|
|
3772
|
+
AND (issued.expires_at IS NULL OR issued.expires_at > now()));
|
|
3773
|
+
END
|
|
3774
|
+
$slotlock_get_partner_account$;
|
|
3775
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[5]} FROM PUBLIC;
|
|
3776
|
+
|
|
3777
|
+
-- Suspend an account of the partner's, or lift a suspension the partner made: one Pylota made (for
|
|
3778
|
+
-- abuse, say) the partner cannot lift. True when it changed something; null for no such account.
|
|
3779
|
+
CREATE OR REPLACE FUNCTION slotlock.set_partner_account_suspended(
|
|
3780
|
+
requested_partner_id text, requested_account text, requested_suspended boolean, requested_reason text
|
|
3781
|
+
)
|
|
3782
|
+
RETURNS boolean
|
|
1856
3783
|
LANGUAGE plpgsql
|
|
1857
|
-
|
|
3784
|
+
VOLATILE
|
|
3785
|
+
SECURITY DEFINER
|
|
1858
3786
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1859
|
-
AS $
|
|
3787
|
+
AS $slotlock_set_partner_account_suspended$
|
|
1860
3788
|
DECLARE
|
|
1861
|
-
|
|
3789
|
+
tenant text;
|
|
1862
3790
|
BEGIN
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
3791
|
+
SELECT owned.tenant_ref INTO tenant FROM slotlock.partner_accounts owned
|
|
3792
|
+
JOIN slotlock.partners partner ON partner.id = owned.partner_id AND partner.status = 'active'
|
|
3793
|
+
WHERE owned.partner_id = requested_partner_id AND owned.account = requested_account;
|
|
3794
|
+
IF NOT FOUND THEN RETURN NULL; END IF;
|
|
3795
|
+
-- As suspend_tenant: the tenant's lock and its key lock, so writes in flight finish first.
|
|
3796
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || tenant, 0));
|
|
3797
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || tenant, 0));
|
|
3798
|
+
IF requested_suspended THEN
|
|
3799
|
+
INSERT INTO slotlock.tenant_suspensions (tenant_ref, reason)
|
|
3800
|
+
VALUES (tenant, 'partner: ' || COALESCE(left(requested_reason, 200), 'suspended by its partner'))
|
|
3801
|
+
ON CONFLICT (tenant_ref) DO NOTHING;
|
|
3802
|
+
RETURN FOUND;
|
|
3803
|
+
END IF;
|
|
3804
|
+
DELETE FROM slotlock.tenant_suspensions held
|
|
3805
|
+
WHERE held.tenant_ref = tenant AND held.reason LIKE 'partner: %';
|
|
3806
|
+
RETURN FOUND;
|
|
1868
3807
|
END
|
|
1869
|
-
$
|
|
1870
|
-
|
|
1871
|
-
BEFORE INSERT ON slotlock.outbox
|
|
1872
|
-
FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
|
|
1873
|
-
EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
|
|
1874
|
-
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
|
|
1875
|
-
AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
|
|
1876
|
-
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
1877
|
-
-- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
|
|
1878
|
-
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
|
|
1879
|
-
AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
|
|
1880
|
-
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
3808
|
+
$slotlock_set_partner_account_suspended$;
|
|
3809
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[6]} FROM PUBLIC;
|
|
1881
3810
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
3811
|
+
-- The partner's accounts' usage (P5), from usage_daily, by account, UTC day and metric, from
|
|
3812
|
+
-- from_day to to_day (at most the retention); one account, or all of them.
|
|
3813
|
+
CREATE OR REPLACE FUNCTION slotlock.partner_usage(
|
|
3814
|
+
requested_partner_id text, from_day date, to_day date, requested_account text
|
|
3815
|
+
)
|
|
3816
|
+
RETURNS TABLE (account text, day date, metric text, count bigint)
|
|
3817
|
+
LANGUAGE plpgsql
|
|
3818
|
+
STABLE
|
|
3819
|
+
SECURITY DEFINER
|
|
3820
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3821
|
+
AS $slotlock_partner_usage$
|
|
3822
|
+
#variable_conflict use_column
|
|
3823
|
+
BEGIN
|
|
3824
|
+
RETURN QUERY
|
|
3825
|
+
SELECT owned.account, used.day, used.metric,
|
|
3826
|
+
CASE WHEN used.metric = 'resources' THEN max(used.count) ELSE sum(used.count) END::bigint
|
|
3827
|
+
FROM slotlock.partner_accounts owned
|
|
3828
|
+
JOIN slotlock.usage_daily used ON used.tenant_ref = owned.tenant_ref
|
|
3829
|
+
WHERE owned.partner_id = requested_partner_id
|
|
3830
|
+
AND (requested_account IS NULL OR owned.account = requested_account)
|
|
3831
|
+
AND used.day BETWEEN from_day AND to_day
|
|
3832
|
+
AND to_day - from_day BETWEEN 0 AND ${SLOTLOCK_USAGE_RETENTION_DAYS}
|
|
3833
|
+
GROUP BY owned.account, used.day, used.metric
|
|
3834
|
+
ORDER BY owned.account, used.day, used.metric;
|
|
3835
|
+
END
|
|
3836
|
+
$slotlock_partner_usage$;
|
|
3837
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[7]} FROM PUBLIC;
|
|
3838
|
+
|
|
3839
|
+
-- The tenant of a partner's account, for a partner key acting for it (Slotlock-Account, RFC 0003
|
|
3840
|
+
-- P3): one indexed read, on every such request.
|
|
3841
|
+
CREATE OR REPLACE FUNCTION slotlock.partner_account_tenant(requested_partner_id text, requested_account text)
|
|
3842
|
+
RETURNS text
|
|
3843
|
+
LANGUAGE sql
|
|
3844
|
+
STABLE
|
|
3845
|
+
SECURITY DEFINER
|
|
3846
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3847
|
+
AS $slotlock_partner_account_tenant$
|
|
3848
|
+
SELECT owned.tenant_ref FROM slotlock.partner_accounts owned
|
|
3849
|
+
WHERE owned.partner_id = requested_partner_id AND owned.account = requested_account;
|
|
3850
|
+
$slotlock_partner_account_tenant$;
|
|
3851
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[8]} FROM PUBLIC;
|
|
3852
|
+
|
|
3853
|
+
-- Record a partner's comparison of one day (the latest one stands).
|
|
3854
|
+
CREATE OR REPLACE FUNCTION slotlock.record_partner_reconciliation(
|
|
3855
|
+
requested_partner_id text, requested_day date, requested_accounts integer, requested_mismatched integer,
|
|
3856
|
+
requested_worst numeric
|
|
3857
|
+
)
|
|
3858
|
+
RETURNS void
|
|
3859
|
+
LANGUAGE sql
|
|
3860
|
+
VOLATILE
|
|
3861
|
+
SECURITY DEFINER
|
|
3862
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
3863
|
+
AS $slotlock_record_partner_reconciliation$
|
|
3864
|
+
INSERT INTO slotlock.partner_reconciliations AS kept (partner_id, day, accounts, mismatched, worst_percent)
|
|
3865
|
+
VALUES (requested_partner_id, requested_day, requested_accounts, requested_mismatched, requested_worst)
|
|
3866
|
+
ON CONFLICT (partner_id, day) DO UPDATE
|
|
3867
|
+
SET accounts = EXCLUDED.accounts, mismatched = EXCLUDED.mismatched,
|
|
3868
|
+
worst_percent = EXCLUDED.worst_percent, reconciled_at = statement_timestamp();
|
|
3869
|
+
$slotlock_record_partner_reconciliation$;
|
|
3870
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[9]} FROM PUBLIC;
|
|
1890
3871
|
`;
|
|
1891
3872
|
// btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
|
|
1892
3873
|
const CORE_DDL_BODY = `
|
|
@@ -1987,6 +3968,18 @@ CREATE INDEX IF NOT EXISTS slotlock_resources_pool_idx
|
|
|
1987
3968
|
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
|
|
1988
3969
|
-- When the tenant archived it: listed nowhere and booked no more, its history kept. NULL: in use.
|
|
1989
3970
|
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
|
3971
|
+
-- Its profile (resource-profile.ts, RFC 0003 G1), each part optional: a name shown instead of the
|
|
3972
|
+
-- reference, a description, what kind of thing it is (a hint for filtering, never a rule), how many
|
|
3973
|
+
-- people or seats it holds (informational, never enforced), labels to find it by, and attributes
|
|
3974
|
+
-- the integrator wants back. Calendar content: the feed names the fields that changed, never their
|
|
3975
|
+
-- values. Added with constant defaults, so no row is rewritten.
|
|
3976
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS name text;
|
|
3977
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS description text;
|
|
3978
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'other';
|
|
3979
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS capacity integer;
|
|
3980
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS labels text[] NOT NULL DEFAULT '{}';
|
|
3981
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS attributes jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
3982
|
+
CREATE INDEX IF NOT EXISTS slotlock_resources_labels_idx ON slotlock.resources USING gin (labels);
|
|
1990
3983
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
1991
3984
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
1992
3985
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
@@ -2273,6 +4266,30 @@ BEGIN
|
|
|
2273
4266
|
ALTER TABLE slotlock.resources
|
|
2274
4267
|
ADD CONSTRAINT slotlock_resources_visibility_valid CHECK (visibility IN ('shared', 'private'));
|
|
2275
4268
|
END IF;
|
|
4269
|
+
-- The profile's bounds (SLOTLOCK_RESOURCE_PROFILE_LIMITS), NOT VALID so adding them scans
|
|
4270
|
+
-- nothing under the lock; DEPLOYMENT_VALIDATE_CONSTRAINTS validates them after the migration.
|
|
4271
|
+
IF NOT EXISTS (
|
|
4272
|
+
SELECT 1 FROM pg_constraint
|
|
4273
|
+
WHERE conname = 'slotlock_resources_profile_valid'
|
|
4274
|
+
AND conrelid = 'slotlock.resources'::regclass
|
|
4275
|
+
) THEN
|
|
4276
|
+
ALTER TABLE slotlock.resources
|
|
4277
|
+
ADD CONSTRAINT slotlock_resources_profile_valid CHECK (
|
|
4278
|
+
(name IS NULL OR (char_length(name) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name}
|
|
4279
|
+
AND btrim(name) <> ''))
|
|
4280
|
+
AND (description IS NULL
|
|
4281
|
+
OR (char_length(description) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description}
|
|
4282
|
+
AND btrim(description) <> ''))
|
|
4283
|
+
AND kind IN (${SLOTLOCK_RESOURCE_KINDS.map((kind) => `'${kind}'`).join(', ')})
|
|
4284
|
+
AND (capacity IS NULL OR capacity BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity})
|
|
4285
|
+
AND cardinality(labels) <= ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.labels}
|
|
4286
|
+
AND array_position(labels, NULL) IS NULL
|
|
4287
|
+
AND (cardinality(labels) = 0
|
|
4288
|
+
OR array_to_string(labels, ',') ~ '^[a-z0-9][a-z0-9._-]{0,63}(,[a-z0-9][a-z0-9._-]{0,63})*$')
|
|
4289
|
+
AND jsonb_typeof(attributes) = 'object'
|
|
4290
|
+
AND octet_length(attributes::text) <= ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.attributeBytes * 2}
|
|
4291
|
+
) NOT VALID;
|
|
4292
|
+
END IF;
|
|
2276
4293
|
IF NOT EXISTS (
|
|
2277
4294
|
SELECT 1 FROM pg_constraint
|
|
2278
4295
|
WHERE conname = 'slotlock_reservations_revision_valid'
|
|
@@ -2382,6 +4399,406 @@ BEGIN
|
|
|
2382
4399
|
END IF;
|
|
2383
4400
|
END $$;
|
|
2384
4401
|
|
|
4402
|
+
-- Where a tenant's resources are (RFC 0003 G2, resource-profile.ts): a tree of at most
|
|
4403
|
+
-- ${SLOTLOCK_MAX_LOCATION_DEPTH} levels (a region, a site, a building, a floor, a zone, or whatever the
|
|
4404
|
+
-- tenant's are), at most ${SLOTLOCK_MAX_LOCATIONS} a tenant, each named by a reference of the tenant's own.
|
|
4405
|
+
-- A location something is in (a resource, or a location under it) cannot be deleted: the foreign
|
|
4406
|
+
-- keys are NO ACTION, checked at the end of the statement, so erasing a whole tenant's tree in one
|
|
4407
|
+
-- statement passes. Its time zone, when it has one, is the one a resource created there without
|
|
4408
|
+
-- its own takes (the nearest location above that has one).
|
|
4409
|
+
CREATE TABLE IF NOT EXISTS slotlock.locations (
|
|
4410
|
+
tenant_ref text NOT NULL,
|
|
4411
|
+
ref text NOT NULL,
|
|
4412
|
+
name text NOT NULL,
|
|
4413
|
+
parent_ref text,
|
|
4414
|
+
timezone text,
|
|
4415
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4416
|
+
CONSTRAINT slotlock_locations_pkey PRIMARY KEY (tenant_ref, ref),
|
|
4417
|
+
CONSTRAINT slotlock_locations_parent_fkey FOREIGN KEY (tenant_ref, parent_ref)
|
|
4418
|
+
REFERENCES slotlock.locations (tenant_ref, ref),
|
|
4419
|
+
CONSTRAINT slotlock_locations_valid CHECK (
|
|
4420
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
4421
|
+
AND ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$'
|
|
4422
|
+
AND char_length(name) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} AND btrim(name) <> ''
|
|
4423
|
+
AND (parent_ref IS NULL OR parent_ref <> ref)
|
|
4424
|
+
AND (timezone IS NULL OR char_length(timezone) BETWEEN 1 AND 100)
|
|
4425
|
+
)
|
|
4426
|
+
);
|
|
4427
|
+
CREATE INDEX IF NOT EXISTS slotlock_locations_parent_idx
|
|
4428
|
+
ON slotlock.locations (tenant_ref, parent_ref) WHERE parent_ref IS NOT NULL;
|
|
4429
|
+
|
|
4430
|
+
-- A location's depth, a loop through its parents, and the tenant's count, under the tenant's
|
|
4431
|
+
-- location lock, so two writers never each pass a check the other then breaks.
|
|
4432
|
+
CREATE OR REPLACE FUNCTION slotlock.locations_guard()
|
|
4433
|
+
RETURNS trigger
|
|
4434
|
+
LANGUAGE plpgsql
|
|
4435
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4436
|
+
AS $slotlock_locations_guard$
|
|
4437
|
+
DECLARE
|
|
4438
|
+
above integer := 0;
|
|
4439
|
+
below integer := 0;
|
|
4440
|
+
looped boolean := false;
|
|
4441
|
+
BEGIN
|
|
4442
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:locations:' || NEW.tenant_ref, 0));
|
|
4443
|
+
-- An upsert of a location the tenant has fires this before its conflict is found: not a new one.
|
|
4444
|
+
IF TG_OP = 'INSERT'
|
|
4445
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.locations kept
|
|
4446
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.ref = NEW.ref)
|
|
4447
|
+
AND (SELECT count(*) FROM slotlock.locations kept WHERE kept.tenant_ref = NEW.tenant_ref)
|
|
4448
|
+
>= ${SLOTLOCK_MAX_LOCATIONS} THEN
|
|
4449
|
+
RAISE EXCEPTION 'Slotlock keeps at most ${SLOTLOCK_MAX_LOCATIONS} locations a tenant'
|
|
4450
|
+
USING ERRCODE = 'check_violation', HINT = 'location_limit_reached';
|
|
4451
|
+
END IF;
|
|
4452
|
+
IF NEW.parent_ref IS NOT NULL THEN
|
|
4453
|
+
WITH RECURSIVE up(at, parent, steps) AS (
|
|
4454
|
+
SELECT kept.ref, kept.parent_ref, 1 FROM slotlock.locations kept
|
|
4455
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.ref = NEW.parent_ref
|
|
4456
|
+
UNION ALL
|
|
4457
|
+
SELECT kept.ref, kept.parent_ref, up.steps + 1
|
|
4458
|
+
FROM slotlock.locations kept JOIN up ON kept.tenant_ref = NEW.tenant_ref AND kept.ref = up.parent
|
|
4459
|
+
WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
4460
|
+
)
|
|
4461
|
+
SELECT coalesce(max(up.steps), 0), coalesce(bool_or(up.at = NEW.ref), false)
|
|
4462
|
+
INTO above, looped FROM up;
|
|
4463
|
+
IF looped THEN
|
|
4464
|
+
RAISE EXCEPTION 'Slotlock location cannot be under itself'
|
|
4465
|
+
USING ERRCODE = 'check_violation', HINT = 'location_cycle';
|
|
4466
|
+
END IF;
|
|
4467
|
+
END IF;
|
|
4468
|
+
IF TG_OP = 'UPDATE' THEN
|
|
4469
|
+
WITH RECURSIVE down(at, steps) AS (
|
|
4470
|
+
SELECT kept.ref, 1 FROM slotlock.locations kept
|
|
4471
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.parent_ref = NEW.ref
|
|
4472
|
+
UNION ALL
|
|
4473
|
+
SELECT kept.ref, down.steps + 1
|
|
4474
|
+
FROM slotlock.locations kept JOIN down ON kept.tenant_ref = NEW.tenant_ref AND kept.parent_ref = down.at
|
|
4475
|
+
WHERE down.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
4476
|
+
)
|
|
4477
|
+
SELECT coalesce(max(down.steps), 0) INTO below FROM down;
|
|
4478
|
+
END IF;
|
|
4479
|
+
IF above + 1 + below > ${SLOTLOCK_MAX_LOCATION_DEPTH} THEN
|
|
4480
|
+
RAISE EXCEPTION 'Slotlock locations are at most ${SLOTLOCK_MAX_LOCATION_DEPTH} levels deep'
|
|
4481
|
+
USING ERRCODE = 'check_violation', HINT = 'location_too_deep';
|
|
4482
|
+
END IF;
|
|
4483
|
+
RETURN NEW;
|
|
4484
|
+
END
|
|
4485
|
+
$slotlock_locations_guard$;
|
|
4486
|
+
CREATE OR REPLACE TRIGGER slotlock_locations_guard
|
|
4487
|
+
BEFORE INSERT OR UPDATE OF parent_ref ON slotlock.locations
|
|
4488
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.locations_guard();
|
|
4489
|
+
|
|
4490
|
+
-- The location a resource is at, one at most; null when not said.
|
|
4491
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS location_ref text;
|
|
4492
|
+
CREATE INDEX IF NOT EXISTS slotlock_resources_location_idx
|
|
4493
|
+
ON slotlock.resources (tenant_ref, location_ref) WHERE location_ref IS NOT NULL;
|
|
4494
|
+
DO $slotlock_resources_location$
|
|
4495
|
+
BEGIN
|
|
4496
|
+
IF NOT EXISTS (
|
|
4497
|
+
SELECT 1 FROM pg_constraint
|
|
4498
|
+
WHERE conname = 'slotlock_resources_location_fkey'
|
|
4499
|
+
AND conrelid = 'slotlock.resources'::regclass
|
|
4500
|
+
) THEN
|
|
4501
|
+
-- NOT VALID: the column is new, and DEPLOYMENT_VALIDATE_CONSTRAINTS validates it after.
|
|
4502
|
+
ALTER TABLE slotlock.resources
|
|
4503
|
+
ADD CONSTRAINT slotlock_resources_location_fkey FOREIGN KEY (tenant_ref, location_ref)
|
|
4504
|
+
REFERENCES slotlock.locations (tenant_ref, ref) NOT VALID;
|
|
4505
|
+
END IF;
|
|
4506
|
+
END
|
|
4507
|
+
$slotlock_resources_location$;
|
|
4508
|
+
|
|
4509
|
+
-- Any set of resources a tenant wants (RFC 0003 G3): a team, a department, a contract, "Saturday
|
|
4510
|
+
-- clinic", "airport vans". Many a resource, at most ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE}, and at most
|
|
4511
|
+
-- ${SLOTLOCK_MAX_RESOURCE_GROUPS} a tenant. No booking rule reads them: a pool is how resources are
|
|
4512
|
+
-- booked as a class.
|
|
4513
|
+
CREATE TABLE IF NOT EXISTS slotlock.resource_groups (
|
|
4514
|
+
tenant_ref text NOT NULL,
|
|
4515
|
+
ref text NOT NULL,
|
|
4516
|
+
name text NOT NULL,
|
|
4517
|
+
description text,
|
|
4518
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4519
|
+
CONSTRAINT slotlock_resource_groups_pkey PRIMARY KEY (tenant_ref, ref),
|
|
4520
|
+
CONSTRAINT slotlock_resource_groups_valid CHECK (
|
|
4521
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
4522
|
+
AND ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$'
|
|
4523
|
+
AND char_length(name) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} AND btrim(name) <> ''
|
|
4524
|
+
AND (description IS NULL
|
|
4525
|
+
OR (char_length(description) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description}
|
|
4526
|
+
AND btrim(description) <> ''))
|
|
4527
|
+
)
|
|
4528
|
+
);
|
|
4529
|
+
CREATE TABLE IF NOT EXISTS slotlock.resource_group_members (
|
|
4530
|
+
tenant_ref text NOT NULL,
|
|
4531
|
+
group_ref text NOT NULL,
|
|
4532
|
+
resource_id uuid NOT NULL,
|
|
4533
|
+
added_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4534
|
+
CONSTRAINT slotlock_resource_group_members_pkey PRIMARY KEY (tenant_ref, group_ref, resource_id),
|
|
4535
|
+
CONSTRAINT slotlock_resource_group_members_group_fkey FOREIGN KEY (tenant_ref, group_ref)
|
|
4536
|
+
REFERENCES slotlock.resource_groups (tenant_ref, ref) ON DELETE CASCADE,
|
|
4537
|
+
CONSTRAINT slotlock_resource_group_members_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
|
|
4538
|
+
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE
|
|
4539
|
+
);
|
|
4540
|
+
CREATE INDEX IF NOT EXISTS slotlock_resource_group_members_resource_idx
|
|
4541
|
+
ON slotlock.resource_group_members (tenant_ref, resource_id);
|
|
4542
|
+
|
|
4543
|
+
-- The counts, under the tenant's group lock.
|
|
4544
|
+
CREATE OR REPLACE FUNCTION slotlock.resource_groups_guard()
|
|
4545
|
+
RETURNS trigger
|
|
4546
|
+
LANGUAGE plpgsql
|
|
4547
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4548
|
+
AS $slotlock_resource_groups_guard$
|
|
4549
|
+
BEGIN
|
|
4550
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:groups:' || NEW.tenant_ref, 0));
|
|
4551
|
+
-- An upsert of a group, or a membership, the tenant has fires this before its conflict is found.
|
|
4552
|
+
IF TG_TABLE_NAME = 'resource_groups' THEN
|
|
4553
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.resource_groups kept
|
|
4554
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.ref = NEW.ref)
|
|
4555
|
+
AND (SELECT count(*) FROM slotlock.resource_groups kept WHERE kept.tenant_ref = NEW.tenant_ref)
|
|
4556
|
+
>= ${SLOTLOCK_MAX_RESOURCE_GROUPS} THEN
|
|
4557
|
+
RAISE EXCEPTION 'Slotlock keeps at most ${SLOTLOCK_MAX_RESOURCE_GROUPS} groups a tenant'
|
|
4558
|
+
USING ERRCODE = 'check_violation', HINT = 'group_limit_reached';
|
|
4559
|
+
END IF;
|
|
4560
|
+
ELSIF NOT EXISTS (SELECT 1 FROM slotlock.resource_group_members kept
|
|
4561
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.group_ref = NEW.group_ref
|
|
4562
|
+
AND kept.resource_id = NEW.resource_id)
|
|
4563
|
+
AND (SELECT count(*) FROM slotlock.resource_group_members kept
|
|
4564
|
+
WHERE kept.tenant_ref = NEW.tenant_ref AND kept.resource_id = NEW.resource_id)
|
|
4565
|
+
>= ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} THEN
|
|
4566
|
+
RAISE EXCEPTION 'Slotlock puts a resource in at most ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups'
|
|
4567
|
+
USING ERRCODE = 'check_violation', HINT = 'resource_group_limit_reached';
|
|
4568
|
+
END IF;
|
|
4569
|
+
RETURN NEW;
|
|
4570
|
+
END
|
|
4571
|
+
$slotlock_resource_groups_guard$;
|
|
4572
|
+
CREATE OR REPLACE TRIGGER slotlock_resource_groups_guard
|
|
4573
|
+
BEFORE INSERT ON slotlock.resource_groups
|
|
4574
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.resource_groups_guard();
|
|
4575
|
+
CREATE OR REPLACE TRIGGER slotlock_resource_group_members_guard
|
|
4576
|
+
BEFORE INSERT ON slotlock.resource_group_members
|
|
4577
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.resource_groups_guard();
|
|
4578
|
+
|
|
4579
|
+
-- Composite resources (RFC 0003 G8): a parent made of parts (a hall of three rooms), one level
|
|
4580
|
+
-- deep. A part has its parent and its place among the parent's parts. A booking of a part also
|
|
4581
|
+
-- claims its place on the parent ([i, i+1)); a booking of the parent claims every place ([0, ∞)).
|
|
4582
|
+
-- composite_claims keeps those apart by PostgreSQL's own exclusion constraint: the whole conflicts
|
|
4583
|
+
-- with every part, and two parts never with each other. A resource that is neither has no claims.
|
|
4584
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS parent_id uuid;
|
|
4585
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS part_index integer;
|
|
4586
|
+
DO $slotlock_resources_parent$
|
|
4587
|
+
BEGIN
|
|
4588
|
+
IF NOT EXISTS (
|
|
4589
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
4590
|
+
WHERE conrelid = 'slotlock.resources'::regclass AND conname = 'slotlock_resources_parent_fkey'
|
|
4591
|
+
) THEN
|
|
4592
|
+
-- Deleting a parent sets its parts free (the store refuses it while anything on it is live).
|
|
4593
|
+
ALTER TABLE slotlock.resources ADD CONSTRAINT slotlock_resources_parent_fkey
|
|
4594
|
+
FOREIGN KEY (parent_id, tenant_ref) REFERENCES slotlock.resources (id, tenant_ref)
|
|
4595
|
+
ON DELETE SET NULL (parent_id) NOT VALID;
|
|
4596
|
+
ALTER TABLE slotlock.resources ADD CONSTRAINT slotlock_resources_part_valid CHECK (
|
|
4597
|
+
(parent_id IS NULL OR part_index IS NOT NULL)
|
|
4598
|
+
AND (part_index IS NULL OR part_index BETWEEN 0 AND ${SLOTLOCK_MAX_COMPOSITE_PARTS - 1})
|
|
4599
|
+
AND parent_id IS DISTINCT FROM id
|
|
4600
|
+
) NOT VALID;
|
|
4601
|
+
END IF;
|
|
4602
|
+
END
|
|
4603
|
+
$slotlock_resources_parent$;
|
|
4604
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_resources_part_idx
|
|
4605
|
+
ON slotlock.resources (parent_id, part_index) WHERE parent_id IS NOT NULL;
|
|
4606
|
+
|
|
4607
|
+
-- One level, ${SLOTLOCK_MAX_COMPOSITE_PARTS} parts at most, and never while either side holds a
|
|
4608
|
+
-- booking or live hold that has not ended: claims are made as bookings are, never backfilled.
|
|
4609
|
+
CREATE OR REPLACE FUNCTION slotlock.resources_parent_guard()
|
|
4610
|
+
RETURNS trigger
|
|
4611
|
+
LANGUAGE plpgsql
|
|
4612
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4613
|
+
AS $slotlock_resources_parent_guard$
|
|
4614
|
+
BEGIN
|
|
4615
|
+
IF NEW.parent_id IS NOT DISTINCT FROM OLD.parent_id THEN
|
|
4616
|
+
RETURN NEW;
|
|
4617
|
+
END IF;
|
|
4618
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:composite:' || COALESCE(NEW.parent_id, OLD.parent_id)::text, 0));
|
|
4619
|
+
IF NEW.parent_id IS NOT NULL THEN
|
|
4620
|
+
IF EXISTS (SELECT 1 FROM slotlock.resources parent WHERE parent.id = NEW.parent_id AND parent.parent_id IS NOT NULL)
|
|
4621
|
+
OR EXISTS (SELECT 1 FROM slotlock.resources part WHERE part.parent_id = NEW.id) THEN
|
|
4622
|
+
RAISE EXCEPTION 'Slotlock composites are one level deep: a part has no parts, and a parent is no part'
|
|
4623
|
+
USING ERRCODE = 'check_violation', HINT = 'composite_too_deep';
|
|
4624
|
+
END IF;
|
|
4625
|
+
IF (SELECT count(*) FROM slotlock.resources part WHERE part.parent_id = NEW.parent_id)
|
|
4626
|
+
>= ${SLOTLOCK_MAX_COMPOSITE_PARTS} THEN
|
|
4627
|
+
RAISE EXCEPTION 'Slotlock composites have at most ${SLOTLOCK_MAX_COMPOSITE_PARTS} parts'
|
|
4628
|
+
USING ERRCODE = 'check_violation', HINT = 'composite_parts_limit';
|
|
4629
|
+
END IF;
|
|
4630
|
+
NEW.part_index := COALESCE(
|
|
4631
|
+
(SELECT min(free.slot) FROM pg_catalog.generate_series(0, ${SLOTLOCK_MAX_COMPOSITE_PARTS - 1}) AS free(slot)
|
|
4632
|
+
WHERE NOT EXISTS (SELECT 1 FROM slotlock.resources part
|
|
4633
|
+
WHERE part.parent_id = NEW.parent_id AND part.part_index = free.slot)),
|
|
4634
|
+
0);
|
|
4635
|
+
ELSE
|
|
4636
|
+
NEW.part_index := NULL;
|
|
4637
|
+
END IF;
|
|
4638
|
+
IF EXISTS (
|
|
4639
|
+
SELECT 1 FROM slotlock.reservations booked
|
|
4640
|
+
WHERE booked.resource_id IN (NEW.id, NEW.parent_id, OLD.parent_id)
|
|
4641
|
+
AND COALESCE(booked.occupied_ends_at, booked.ends_at) > statement_timestamp()
|
|
4642
|
+
AND (booked.status <> 'held' OR booked.expires_at > statement_timestamp())
|
|
4643
|
+
) THEN
|
|
4644
|
+
RAISE EXCEPTION 'Slotlock changes a composite only while neither side has a booking or live hold to come'
|
|
4645
|
+
USING ERRCODE = 'check_violation', HINT = 'composite_busy';
|
|
4646
|
+
END IF;
|
|
4647
|
+
RETURN NEW;
|
|
4648
|
+
END
|
|
4649
|
+
$slotlock_resources_parent_guard$;
|
|
4650
|
+
CREATE OR REPLACE TRIGGER slotlock_resources_parent_guard
|
|
4651
|
+
BEFORE UPDATE OF parent_id ON slotlock.resources
|
|
4652
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.resources_parent_guard();
|
|
4653
|
+
|
|
4654
|
+
CREATE TABLE IF NOT EXISTS slotlock.composite_claims (
|
|
4655
|
+
reservation_id uuid PRIMARY KEY REFERENCES slotlock.reservations (id) ON DELETE CASCADE,
|
|
4656
|
+
tenant_ref text NOT NULL,
|
|
4657
|
+
-- The composite: the parent, whichever of its resources the booking is on.
|
|
4658
|
+
resource_id uuid NOT NULL REFERENCES slotlock.resources (id) ON DELETE CASCADE,
|
|
4659
|
+
booked_resource_id uuid NOT NULL,
|
|
4660
|
+
span int4range NOT NULL,
|
|
4661
|
+
during tstzrange NOT NULL,
|
|
4662
|
+
status text NOT NULL,
|
|
4663
|
+
expires_at timestamptz,
|
|
4664
|
+
CONSTRAINT slotlock_composite_claims_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500 AND NOT isempty(span)),
|
|
4665
|
+
CONSTRAINT slotlock_composite_claims_no_overlap EXCLUDE USING gist (
|
|
4666
|
+
resource_id WITH =,
|
|
4667
|
+
during WITH &&,
|
|
4668
|
+
span WITH &&
|
|
4669
|
+
)
|
|
4670
|
+
);
|
|
4671
|
+
CREATE INDEX IF NOT EXISTS slotlock_composite_claims_booked_idx
|
|
4672
|
+
ON slotlock.composite_claims (booked_resource_id);
|
|
4673
|
+
|
|
4674
|
+
-- Each booking or hold on a part or a parent claims its place on the composite, in the same
|
|
4675
|
+
-- statement: a claim that overlaps another fails it with 23P01, as a booking that overlaps one on
|
|
4676
|
+
-- its own resource does.
|
|
4677
|
+
CREATE OR REPLACE FUNCTION slotlock.composite_claim()
|
|
4678
|
+
RETURNS trigger
|
|
4679
|
+
LANGUAGE plpgsql
|
|
4680
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
4681
|
+
AS $slotlock_composite_claim$
|
|
4682
|
+
DECLARE
|
|
4683
|
+
booked record;
|
|
4684
|
+
composite uuid;
|
|
4685
|
+
claimed int4range;
|
|
4686
|
+
BEGIN
|
|
4687
|
+
-- An update that moves nothing the claim holds leaves it (no column list on the trigger, so an
|
|
4688
|
+
-- upgrade that adds a column later in the DDL never finds one here first).
|
|
4689
|
+
IF TG_OP = 'UPDATE'
|
|
4690
|
+
AND NEW.resource_id = OLD.resource_id
|
|
4691
|
+
AND NEW.starts_at = OLD.starts_at AND NEW.ends_at = OLD.ends_at
|
|
4692
|
+
AND NEW.occupied_starts_at IS NOT DISTINCT FROM OLD.occupied_starts_at
|
|
4693
|
+
AND NEW.occupied_ends_at IS NOT DISTINCT FROM OLD.occupied_ends_at
|
|
4694
|
+
AND NEW.status = OLD.status
|
|
4695
|
+
AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at THEN
|
|
4696
|
+
RETURN NULL;
|
|
4697
|
+
END IF;
|
|
4698
|
+
IF TG_OP <> 'INSERT' THEN
|
|
4699
|
+
DELETE FROM slotlock.composite_claims claim WHERE claim.reservation_id = OLD.id;
|
|
4700
|
+
END IF;
|
|
4701
|
+
IF TG_OP = 'DELETE' THEN
|
|
4702
|
+
RETURN NULL;
|
|
4703
|
+
END IF;
|
|
4704
|
+
SELECT resource.parent_id, resource.part_index, resource.tenant_ref,
|
|
4705
|
+
EXISTS (SELECT 1 FROM slotlock.resources part WHERE part.parent_id = resource.id) AS whole
|
|
4706
|
+
INTO booked
|
|
4707
|
+
FROM slotlock.resources resource WHERE resource.id = NEW.resource_id;
|
|
4708
|
+
IF booked.parent_id IS NOT NULL THEN
|
|
4709
|
+
composite := booked.parent_id;
|
|
4710
|
+
claimed := int4range(booked.part_index, booked.part_index + 1);
|
|
4711
|
+
ELSIF booked.whole THEN
|
|
4712
|
+
composite := NEW.resource_id;
|
|
4713
|
+
claimed := int4range(0, NULL);
|
|
4714
|
+
ELSE
|
|
4715
|
+
RETURN NULL;
|
|
4716
|
+
END IF;
|
|
4717
|
+
INSERT INTO slotlock.composite_claims (
|
|
4718
|
+
reservation_id, tenant_ref, resource_id, booked_resource_id, span, during, status, expires_at
|
|
4719
|
+
) VALUES (
|
|
4720
|
+
NEW.id, booked.tenant_ref, composite, NEW.resource_id, claimed,
|
|
4721
|
+
tstzrange(COALESCE(NEW.occupied_starts_at, NEW.starts_at), COALESCE(NEW.occupied_ends_at, NEW.ends_at), '[)'),
|
|
4722
|
+
NEW.status, NEW.expires_at
|
|
4723
|
+
);
|
|
4724
|
+
RETURN NULL;
|
|
4725
|
+
END
|
|
4726
|
+
$slotlock_composite_claim$;
|
|
4727
|
+
CREATE OR REPLACE TRIGGER slotlock_reservations_composite_claim
|
|
4728
|
+
AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
|
|
4729
|
+
FOR EACH ROW EXECUTE FUNCTION slotlock.composite_claim();
|
|
4730
|
+
|
|
4731
|
+
-- Approval policies (RFC 0004 A4): which writes a person approves before they are made, or
|
|
4732
|
+
-- refuses outright. applies_to names resources, pools, locations (each with the locations in it)
|
|
4733
|
+
-- or groups ({} is every resource); conditions name what makes a write need it: longer than some
|
|
4734
|
+
-- minutes, outside the resource's hours, or a kind of write. The wait is the policy's own, bounded
|
|
4735
|
+
-- by the booking's start less lead_minutes and by the server's maximum.
|
|
4736
|
+
CREATE TABLE IF NOT EXISTS slotlock.approval_policies (
|
|
4737
|
+
tenant_ref text NOT NULL,
|
|
4738
|
+
ref text NOT NULL,
|
|
4739
|
+
name text NOT NULL,
|
|
4740
|
+
applies_to jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4741
|
+
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4742
|
+
outcome text NOT NULL,
|
|
4743
|
+
approval_ttl_minutes integer NOT NULL DEFAULT 1440,
|
|
4744
|
+
lead_minutes integer NOT NULL DEFAULT 0,
|
|
4745
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4746
|
+
updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4747
|
+
PRIMARY KEY (tenant_ref, ref),
|
|
4748
|
+
CONSTRAINT slotlock_approval_policies_valid CHECK (
|
|
4749
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
4750
|
+
AND ref ~ '^[a-z0-9][a-z0-9_-]{0,62}$'
|
|
4751
|
+
AND char_length(name) BETWEEN 1 AND 200
|
|
4752
|
+
AND jsonb_typeof(applies_to) = 'object' AND jsonb_typeof(conditions) = 'object'
|
|
4753
|
+
AND outcome IN ('require_approval', 'refuse')
|
|
4754
|
+
AND approval_ttl_minutes BETWEEN 1 AND 43200
|
|
4755
|
+
AND lead_minutes BETWEEN 0 AND 43200
|
|
4756
|
+
)
|
|
4757
|
+
);
|
|
4758
|
+
|
|
4759
|
+
-- A write waiting for a person: its time held by holds whose holder is the approval (never the
|
|
4760
|
+
-- agent, so it cannot release them and they do not count against its caps), and the request to run
|
|
4761
|
+
-- as its requester when granted, in the same transaction that lets the holds go.
|
|
4762
|
+
CREATE TABLE IF NOT EXISTS slotlock.approvals (
|
|
4763
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4764
|
+
tenant_ref text NOT NULL,
|
|
4765
|
+
policy_ref text NOT NULL,
|
|
4766
|
+
status text NOT NULL DEFAULT 'pending',
|
|
4767
|
+
operation text NOT NULL,
|
|
4768
|
+
request jsonb NOT NULL,
|
|
4769
|
+
requester jsonb NOT NULL,
|
|
4770
|
+
hold_ids uuid[] NOT NULL,
|
|
4771
|
+
-- What it holds, kept for the record once the holds are gone.
|
|
4772
|
+
resource_ids uuid[] NOT NULL,
|
|
4773
|
+
starts_at timestamptz NOT NULL,
|
|
4774
|
+
ends_at timestamptz NOT NULL,
|
|
4775
|
+
-- The requester, operation and idempotency key, hashed: a retry finds the approval it made.
|
|
4776
|
+
request_key text NOT NULL,
|
|
4777
|
+
expires_at timestamptz NOT NULL,
|
|
4778
|
+
decided_by text,
|
|
4779
|
+
decided_at timestamptz,
|
|
4780
|
+
reason text,
|
|
4781
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
4782
|
+
CONSTRAINT slotlock_approvals_valid CHECK (
|
|
4783
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
4784
|
+
AND status IN ('pending', 'granted', 'declined', 'expired')
|
|
4785
|
+
AND operation IN ('slotlock_create_event', 'slotlock_book_together', 'slotlock_update_event')
|
|
4786
|
+
AND jsonb_typeof(request) = 'object' AND jsonb_typeof(requester) = 'object'
|
|
4787
|
+
AND cardinality(hold_ids) BETWEEN 1 AND 20
|
|
4788
|
+
AND cardinality(resource_ids) BETWEEN 1 AND 20 AND ends_at > starts_at
|
|
4789
|
+
AND request_key ~ '^[0-9a-f]{64}$'
|
|
4790
|
+
AND (decided_by IS NULL OR octet_length(decided_by) BETWEEN 1 AND 500)
|
|
4791
|
+
AND (reason IS NULL OR char_length(reason) <= 500)
|
|
4792
|
+
AND ((status = 'pending') = (decided_at IS NULL))
|
|
4793
|
+
)
|
|
4794
|
+
);
|
|
4795
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_approvals_request_idx
|
|
4796
|
+
ON slotlock.approvals (tenant_ref, request_key);
|
|
4797
|
+
CREATE INDEX IF NOT EXISTS slotlock_approvals_pending_idx
|
|
4798
|
+
ON slotlock.approvals (tenant_ref, created_at, id) WHERE status = 'pending';
|
|
4799
|
+
CREATE INDEX IF NOT EXISTS slotlock_approvals_expiry_idx
|
|
4800
|
+
ON slotlock.approvals (expires_at) WHERE status = 'pending';
|
|
4801
|
+
|
|
2385
4802
|
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
2386
4803
|
-- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
|
|
2387
4804
|
-- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
|
|
@@ -3195,7 +5612,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
3195
5612
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
3196
5613
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
3197
5614
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
3198
|
-
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
5615
|
+
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_PARTNER_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
3199
5616
|
/**
|
|
3200
5617
|
* Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
|
|
3201
5618
|
* or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
|
|
@@ -3206,9 +5623,9 @@ function coverageChangeInShapeSql(row) {
|
|
|
3206
5623
|
AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
|
|
3207
5624
|
}
|
|
3208
5625
|
/**
|
|
3209
|
-
* The source_id an earlier release's coverage change `row` names
|
|
3210
|
-
*
|
|
3211
|
-
*
|
|
5626
|
+
* The source_id an earlier release's coverage change `row` names: the source matching its tenant,
|
|
5627
|
+
* resource and source (0.5.2 and earlier: the subject or `data.source`) or 0.5.3 digest, else
|
|
5628
|
+
* src_redacted.
|
|
3212
5629
|
*/
|
|
3213
5630
|
function coverageChangeSourceIdSql(row) {
|
|
3214
5631
|
return `COALESCE((
|
|
@@ -3224,16 +5641,12 @@ function coverageChangeSourceIdSql(row) {
|
|
|
3224
5641
|
/** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
|
|
3225
5642
|
export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
|
|
3226
5643
|
/**
|
|
3227
|
-
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows
|
|
3228
|
-
*
|
|
3229
|
-
*
|
|
3230
|
-
*
|
|
3231
|
-
*
|
|
3232
|
-
*
|
|
3233
|
-
* can match it any more, and a made-up id would look like a source the tenant could find. Selects
|
|
3234
|
-
* the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
|
|
3235
|
-
* `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
|
|
3236
|
-
* nothing.
|
|
5644
|
+
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows after
|
|
5645
|
+
* `after` up to `head` (bigint SQL expressions), the first `batch` by id; each `coverage.*` row not
|
|
5646
|
+
* yet in shape gets its source_id as subject and `data.source_id`, and loses `source` and
|
|
5647
|
+
* `last_error` (before 0.5.3 these carried the source itself, a URL with a token; 0.5.3's unkeyed
|
|
5648
|
+
* digest could confirm a guess). A deleted source becomes `src_redacted`. Selects `last` (null when
|
|
5649
|
+
* none) and `renamed`, `INTO` `into` when given. Idempotent.
|
|
3237
5650
|
*/
|
|
3238
5651
|
export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
|
|
3239
5652
|
return `WITH scanned AS MATERIALIZED (
|
|
@@ -3263,10 +5676,9 @@ SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
|
|
|
3263
5676
|
(SELECT count(*) FROM renamed)::integer AS renamed${into ? `
|
|
3264
5677
|
INTO ${into}` : ''}`;
|
|
3265
5678
|
}
|
|
3266
|
-
// The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction
|
|
3267
|
-
//
|
|
3268
|
-
//
|
|
3269
|
-
// runs it in batches after the structure commits instead, each its own short transaction.
|
|
5679
|
+
// The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction while FORCE is lifted (the
|
|
5680
|
+
// outbox is under ACCESS EXCLUSIVE throughout). Resumes where the last pass stopped and is skipped
|
|
5681
|
+
// once done; applySchema instead runs it in batches after the structure commits.
|
|
3270
5682
|
const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
|
|
3271
5683
|
DECLARE
|
|
3272
5684
|
scan_after bigint;
|
|
@@ -3330,18 +5742,23 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
3330
5742
|
'booking_rule_defaults',
|
|
3331
5743
|
'assistant_settings',
|
|
3332
5744
|
'assistant_conversations',
|
|
5745
|
+
'locations',
|
|
5746
|
+
'resource_groups',
|
|
5747
|
+
'resource_group_members',
|
|
5748
|
+
'pool_settings',
|
|
5749
|
+
'composite_claims',
|
|
5750
|
+
'approval_policies',
|
|
5751
|
+
'approvals',
|
|
3333
5752
|
]);
|
|
3334
5753
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
3335
5754
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
3336
5755
|
/** Names GRANT reads as a pseudo-role (PUBLIC reaches every role) or PostgreSQL reserves. */
|
|
3337
5756
|
const RESERVED_ROLE_NAMES = new Set(['public', 'current_role', 'current_user', 'session_user']);
|
|
3338
5757
|
/**
|
|
3339
|
-
* Build the grants for the application role: USAGE on the
|
|
3340
|
-
*
|
|
3341
|
-
*
|
|
3342
|
-
*
|
|
3343
|
-
* the known ways out; these statements check nothing). Rerun after every `applySchema()`, since a
|
|
3344
|
-
* release may add a table.
|
|
5758
|
+
* Build the grants for the application role: USAGE on the schema, DML on the store's tables and
|
|
5759
|
+
* EXECUTE on the API key and dashboard functions, nothing else. The role must not be able to leave
|
|
5760
|
+
* forced RLS (`grantApplicationRole` checks; these statements do not). Rerun after every
|
|
5761
|
+
* `applySchema()`, since a release may add a table.
|
|
3345
5762
|
*/
|
|
3346
5763
|
export function createSlotlockApplicationRoleGrantsDdl(role) {
|
|
3347
5764
|
if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
|
|
@@ -3358,6 +5775,46 @@ GRANT EXECUTE ON FUNCTION ${SLOTLOCK_DEFINER_FUNCTIONS.join(', ')} TO "${role}";
|
|
|
3358
5775
|
* `slotlock.tenant_ref`; adapters may supply an established transaction-local setting name.
|
|
3359
5776
|
* Every policy fails closed when the setting is absent or empty, and FORCE binds the table owner.
|
|
3360
5777
|
*/
|
|
5778
|
+
/**
|
|
5779
|
+
* The transaction-local setting withTenant sets for a key limited to part of its tenant (RFC 0003
|
|
5780
|
+
* G6): the ids of the resources it reaches, as a uuid[] literal; empty for every other caller.
|
|
5781
|
+
*/
|
|
5782
|
+
export const SLOTLOCK_RESOURCE_SCOPE_SETTING = 'slotlock.resource_scope';
|
|
5783
|
+
const RESOURCE_SCOPE_OPEN = `NULLIF(current_setting('${SLOTLOCK_RESOURCE_SCOPE_SETTING}', true), '') IS NULL`;
|
|
5784
|
+
const resourceScopeAllows = (column) => `(${RESOURCE_SCOPE_OPEN} OR ${column} = ANY (NULLIF(current_setting('${SLOTLOCK_RESOURCE_SCOPE_SETTING}', true), '')::uuid[]))`;
|
|
5785
|
+
/**
|
|
5786
|
+
* The tables a key limited to part of its tenant is held to, and how each row names its resource:
|
|
5787
|
+
* the resource itself, or the event it belongs to. Tombstones and archives name none and stay as the
|
|
5788
|
+
* tenant policy leaves them.
|
|
5789
|
+
*/
|
|
5790
|
+
export const SLOTLOCK_RESOURCE_SCOPED_TABLES = Object.freeze({
|
|
5791
|
+
resources: resourceScopeAllows('id'),
|
|
5792
|
+
reservations: resourceScopeAllows('resource_id'),
|
|
5793
|
+
calendar_events: resourceScopeAllows('resource_id'),
|
|
5794
|
+
calendar_coverage: resourceScopeAllows('resource_id'),
|
|
5795
|
+
hold_commands: resourceScopeAllows('resource_id'),
|
|
5796
|
+
resource_group_members: resourceScopeAllows('resource_id'),
|
|
5797
|
+
// A change or a waitlist entry about no one resource (a key, a pool) is not the key's.
|
|
5798
|
+
outbox: resourceScopeAllows('resource_id'),
|
|
5799
|
+
waitlist_entries: resourceScopeAllows('resource_id'),
|
|
5800
|
+
...Object.fromEntries([
|
|
5801
|
+
'calendar_event_attendees',
|
|
5802
|
+
'calendar_event_reminders',
|
|
5803
|
+
'calendar_event_exceptions',
|
|
5804
|
+
'calendar_event_occurrences',
|
|
5805
|
+
'calendar_event_commands',
|
|
5806
|
+
].map((table) => [
|
|
5807
|
+
table,
|
|
5808
|
+
`(${RESOURCE_SCOPE_OPEN} OR EXISTS (SELECT 1 FROM slotlock.calendar_events scoped_event WHERE scoped_event.id = event_id))`,
|
|
5809
|
+
])),
|
|
5810
|
+
});
|
|
5811
|
+
/**
|
|
5812
|
+
* One restrictive policy on each table SLOTLOCK_RESOURCE_SCOPED_TABLES names: it only narrows what
|
|
5813
|
+
* the tenant policy allows, for reads and writes alike, and is open when no scope is set.
|
|
5814
|
+
*/
|
|
5815
|
+
const SLOTLOCK_RESOURCE_SCOPE_POLICIES = Object.entries(SLOTLOCK_RESOURCE_SCOPED_TABLES)
|
|
5816
|
+
.map(([table, allows]) => `CREATE POLICY resource_scope_v1 ON slotlock.${table} AS RESTRICTIVE USING ${allows} WITH CHECK ${allows};`)
|
|
5817
|
+
.join('\n');
|
|
3361
5818
|
export function createSlotlockTenantRlsDdl(tenantContextSetting = SLOTLOCK_TENANT_CONTEXT_SETTING) {
|
|
3362
5819
|
if (tenantContextSetting.length > 128 ||
|
|
3363
5820
|
!POSTGRES_SETTING_NAME.test(tenantContextSetting) ||
|
|
@@ -3405,6 +5862,20 @@ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
|
|
|
3405
5862
|
ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
|
|
3406
5863
|
ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
|
|
3407
5864
|
ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
|
|
5865
|
+
ALTER TABLE slotlock.locations ENABLE ROW LEVEL SECURITY;
|
|
5866
|
+
ALTER TABLE slotlock.locations FORCE ROW LEVEL SECURITY;
|
|
5867
|
+
ALTER TABLE slotlock.resource_groups ENABLE ROW LEVEL SECURITY;
|
|
5868
|
+
ALTER TABLE slotlock.resource_groups FORCE ROW LEVEL SECURITY;
|
|
5869
|
+
ALTER TABLE slotlock.resource_group_members ENABLE ROW LEVEL SECURITY;
|
|
5870
|
+
ALTER TABLE slotlock.resource_group_members FORCE ROW LEVEL SECURITY;
|
|
5871
|
+
ALTER TABLE slotlock.pool_settings ENABLE ROW LEVEL SECURITY;
|
|
5872
|
+
ALTER TABLE slotlock.pool_settings FORCE ROW LEVEL SECURITY;
|
|
5873
|
+
ALTER TABLE slotlock.composite_claims ENABLE ROW LEVEL SECURITY;
|
|
5874
|
+
ALTER TABLE slotlock.composite_claims FORCE ROW LEVEL SECURITY;
|
|
5875
|
+
ALTER TABLE slotlock.approval_policies ENABLE ROW LEVEL SECURITY;
|
|
5876
|
+
ALTER TABLE slotlock.approval_policies FORCE ROW LEVEL SECURITY;
|
|
5877
|
+
ALTER TABLE slotlock.approvals ENABLE ROW LEVEL SECURITY;
|
|
5878
|
+
ALTER TABLE slotlock.approvals FORCE ROW LEVEL SECURITY;
|
|
3408
5879
|
|
|
3409
5880
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
3410
5881
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -3436,7 +5907,14 @@ BEGIN
|
|
|
3436
5907
|
'waitlist_entries',
|
|
3437
5908
|
'booking_rule_defaults',
|
|
3438
5909
|
'assistant_settings',
|
|
3439
|
-
'assistant_conversations'
|
|
5910
|
+
'assistant_conversations',
|
|
5911
|
+
'locations',
|
|
5912
|
+
'resource_groups',
|
|
5913
|
+
'resource_group_members',
|
|
5914
|
+
'pool_settings',
|
|
5915
|
+
'composite_claims',
|
|
5916
|
+
'approval_policies',
|
|
5917
|
+
'approvals'
|
|
3440
5918
|
)
|
|
3441
5919
|
LOOP
|
|
3442
5920
|
EXECUTE format(
|
|
@@ -3677,6 +6155,76 @@ BEGIN
|
|
|
3677
6155
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
3678
6156
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3679
6157
|
END IF;
|
|
6158
|
+
|
|
6159
|
+
IF NOT EXISTS (
|
|
6160
|
+
SELECT 1 FROM pg_policies
|
|
6161
|
+
WHERE schemaname = 'slotlock' AND tablename = 'locations'
|
|
6162
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6163
|
+
) THEN
|
|
6164
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.locations
|
|
6165
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6166
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6167
|
+
END IF;
|
|
6168
|
+
|
|
6169
|
+
IF NOT EXISTS (
|
|
6170
|
+
SELECT 1 FROM pg_policies
|
|
6171
|
+
WHERE schemaname = 'slotlock' AND tablename = 'resource_groups'
|
|
6172
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6173
|
+
) THEN
|
|
6174
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.resource_groups
|
|
6175
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6176
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6177
|
+
END IF;
|
|
6178
|
+
|
|
6179
|
+
IF NOT EXISTS (
|
|
6180
|
+
SELECT 1 FROM pg_policies
|
|
6181
|
+
WHERE schemaname = 'slotlock' AND tablename = 'resource_group_members'
|
|
6182
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6183
|
+
) THEN
|
|
6184
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.resource_group_members
|
|
6185
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6186
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6187
|
+
END IF;
|
|
6188
|
+
|
|
6189
|
+
IF NOT EXISTS (
|
|
6190
|
+
SELECT 1 FROM pg_policies
|
|
6191
|
+
WHERE schemaname = 'slotlock' AND tablename = 'pool_settings'
|
|
6192
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6193
|
+
) THEN
|
|
6194
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.pool_settings
|
|
6195
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6196
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6197
|
+
END IF;
|
|
6198
|
+
|
|
6199
|
+
IF NOT EXISTS (
|
|
6200
|
+
SELECT 1 FROM pg_policies
|
|
6201
|
+
WHERE schemaname = 'slotlock' AND tablename = 'composite_claims'
|
|
6202
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6203
|
+
) THEN
|
|
6204
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.composite_claims
|
|
6205
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6206
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6207
|
+
END IF;
|
|
6208
|
+
|
|
6209
|
+
IF NOT EXISTS (
|
|
6210
|
+
SELECT 1 FROM pg_policies
|
|
6211
|
+
WHERE schemaname = 'slotlock' AND tablename = 'approval_policies'
|
|
6212
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6213
|
+
) THEN
|
|
6214
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.approval_policies
|
|
6215
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6216
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6217
|
+
END IF;
|
|
6218
|
+
|
|
6219
|
+
IF NOT EXISTS (
|
|
6220
|
+
SELECT 1 FROM pg_policies
|
|
6221
|
+
WHERE schemaname = 'slotlock' AND tablename = 'approvals'
|
|
6222
|
+
AND policyname = 'tenant_isolation_v1'
|
|
6223
|
+
) THEN
|
|
6224
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.approvals
|
|
6225
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
6226
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
6227
|
+
END IF;
|
|
3680
6228
|
END
|
|
3681
6229
|
$slotlock_rls$;
|
|
3682
6230
|
|
|
@@ -3720,6 +6268,21 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
|
|
|
3720
6268
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3721
6269
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
3722
6270
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6271
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.locations
|
|
6272
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6273
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_groups
|
|
6274
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6275
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_group_members
|
|
6276
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6277
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.pool_settings
|
|
6278
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6279
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.composite_claims
|
|
6280
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6281
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approval_policies
|
|
6282
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6283
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approvals
|
|
6284
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
6285
|
+
${SLOTLOCK_RESOURCE_SCOPE_POLICIES}
|
|
3723
6286
|
|
|
3724
6287
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
3725
6288
|
table_name, tenant_context_setting, policy_fingerprint, installed_at
|
|
@@ -3762,7 +6325,14 @@ WHERE schemaname = 'slotlock'
|
|
|
3762
6325
|
'waitlist_entries',
|
|
3763
6326
|
'booking_rule_defaults',
|
|
3764
6327
|
'assistant_settings',
|
|
3765
|
-
'assistant_conversations'
|
|
6328
|
+
'assistant_conversations',
|
|
6329
|
+
'locations',
|
|
6330
|
+
'resource_groups',
|
|
6331
|
+
'resource_group_members',
|
|
6332
|
+
'pool_settings',
|
|
6333
|
+
'composite_claims',
|
|
6334
|
+
'approval_policies',
|
|
6335
|
+
'approvals'
|
|
3766
6336
|
)
|
|
3767
6337
|
AND policyname = 'tenant_isolation_v1'
|
|
3768
6338
|
ON CONFLICT (table_name) DO UPDATE
|