slotlock 0.6.3 → 0.8.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 +511 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- 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.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 +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
package/ddl.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// role can write to ("$user", public) would run with that ownership. The DDL pins search_path to
|
|
9
9
|
// pg_catalog and pg_temp while it runs and restores the caller's at the end. Apply it in one
|
|
10
10
|
// transaction: under autocommit the pin ends with its own statement.
|
|
11
|
-
import {
|
|
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';
|
|
12
13
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
13
14
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
14
15
|
const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
|
|
@@ -192,6 +193,8 @@ export const SLOTLOCK_API_KEY_RETAINED_LIMIT = 1_000;
|
|
|
192
193
|
export const SLOTLOCK_API_KEY_TABLES = Object.freeze([
|
|
193
194
|
'api_keys',
|
|
194
195
|
'api_key_retired_digests',
|
|
196
|
+
'api_key_resource_scopes',
|
|
197
|
+
'api_key_limits',
|
|
195
198
|
]);
|
|
196
199
|
/**
|
|
197
200
|
* The SECURITY DEFINER functions through which the serving role reaches API keys, by signature.
|
|
@@ -204,16 +207,25 @@ export const SLOTLOCK_API_KEY_FUNCTIONS = Object.freeze([
|
|
|
204
207
|
'slotlock.revoke_api_key(text, uuid)',
|
|
205
208
|
'slotlock.authenticate_api_key(bytea)',
|
|
206
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)',
|
|
207
216
|
]);
|
|
208
217
|
/**
|
|
209
218
|
* The table behind the dashboard's shared records (dashboard.ts). No role but its owner holds a
|
|
210
219
|
* right on it; it is reached only through SLOTLOCK_DASHBOARD_FUNCTIONS.
|
|
211
220
|
*/
|
|
212
|
-
export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens']);
|
|
221
|
+
export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens', 'dashboard_organizations']);
|
|
213
222
|
/** The SECURITY DEFINER functions through which the serving role reaches the dashboard's records. */
|
|
214
223
|
export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
|
|
215
224
|
'slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)',
|
|
216
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)',
|
|
217
229
|
]);
|
|
218
230
|
/** Live form records one person may leave in the dashboard's records (12 hours of forms). */
|
|
219
231
|
export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
|
|
@@ -226,6 +238,14 @@ export const SLOTLOCK_SCHEMA_FUNCTIONS = Object.freeze([
|
|
|
226
238
|
/** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
|
|
227
239
|
export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
|
|
228
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',
|
|
229
249
|
'webhook_tenants',
|
|
230
250
|
'rate_buckets',
|
|
231
251
|
'assistant_tenants',
|
|
@@ -246,18 +266,97 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
|
246
266
|
'slotlock.sweep_assistant_conversations(integer)',
|
|
247
267
|
'slotlock.list_maintenance_tenant()',
|
|
248
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)',
|
|
249
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;
|
|
299
|
+
/**
|
|
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;
|
|
250
320
|
/**
|
|
251
|
-
* Functions only the deployment role runs: suspending and lifting a suspension
|
|
252
|
-
*
|
|
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.
|
|
253
324
|
*/
|
|
254
325
|
export const SLOTLOCK_OPERATOR_FUNCTIONS = Object.freeze([
|
|
255
326
|
'slotlock.suspend_tenant(text, text)',
|
|
256
327
|
'slotlock.unsuspend_tenant(text)',
|
|
328
|
+
'slotlock.set_plan_default(text, integer, integer, integer, timestamptz)',
|
|
257
329
|
]);
|
|
258
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
|
+
]);
|
|
259
357
|
export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
260
358
|
...SLOTLOCK_API_KEY_FUNCTIONS,
|
|
359
|
+
...SLOTLOCK_PARTNER_FUNCTIONS,
|
|
261
360
|
...SLOTLOCK_DASHBOARD_FUNCTIONS,
|
|
262
361
|
...SLOTLOCK_SCHEMA_FUNCTIONS,
|
|
263
362
|
...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
|
|
@@ -265,6 +364,7 @@ export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
|
265
364
|
/** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
|
|
266
365
|
export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
|
|
267
366
|
...SLOTLOCK_API_KEY_TABLES,
|
|
367
|
+
...SLOTLOCK_PARTNER_TABLES,
|
|
268
368
|
...SLOTLOCK_DASHBOARD_TABLES,
|
|
269
369
|
...SLOTLOCK_SCHEMA_TABLES,
|
|
270
370
|
...SLOTLOCK_TENANT_ADMIN_TABLES,
|
|
@@ -316,7 +416,8 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
316
416
|
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500 AND tenant_ref !~ '[[:cntrl:]]'),
|
|
317
417
|
CONSTRAINT slotlock_api_keys_name_valid
|
|
318
418
|
CHECK (char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'),
|
|
319
|
-
|
|
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}$'),
|
|
320
421
|
-- A non-empty list of read, write and admin, each once, in that order (api-keys.ts).
|
|
321
422
|
CONSTRAINT slotlock_api_keys_scopes_known
|
|
322
423
|
CHECK (scopes IN (
|
|
@@ -335,6 +436,23 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
335
436
|
CREATE INDEX IF NOT EXISTS slotlock_api_keys_tenant_idx
|
|
336
437
|
ON slotlock.api_keys (tenant_ref, created_at DESC, id DESC);
|
|
337
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$;
|
|
338
456
|
|
|
339
457
|
-- Schemas from 0.1 allow only read and write. The CHECK with admin admits every row the old one
|
|
340
458
|
-- did, so it is added NOT VALID (no table scan under the lock) and the old one dropped in the same
|
|
@@ -410,7 +528,9 @@ BEGIN
|
|
|
410
528
|
WHERE active.tenant_ref = requested_tenant_ref
|
|
411
529
|
AND active.revoked_at IS NULL
|
|
412
530
|
AND (active.expires_at IS NULL OR active.expires_at > now())
|
|
413
|
-
) < ${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}))
|
|
414
534
|
AND (SELECT count(*) FROM slotlock.api_keys kept
|
|
415
535
|
WHERE kept.tenant_ref = requested_tenant_ref
|
|
416
536
|
) < ${SLOTLOCK_API_KEY_RETAINED_LIMIT}
|
|
@@ -541,6 +661,190 @@ AS $slotlock_erase_api_keys$
|
|
|
541
661
|
$slotlock_erase_api_keys$;
|
|
542
662
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
|
|
543
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
|
+
|
|
544
848
|
-- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
|
|
545
849
|
-- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
|
|
546
850
|
-- functions above write keys, as the schema's owner, which forced row-level security on the feed
|
|
@@ -717,6 +1021,79 @@ AS $slotlock_dashboard_session_ended$
|
|
|
717
1021
|
AND ended.expires_at > now());
|
|
718
1022
|
$slotlock_dashboard_session_ended$;
|
|
719
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;
|
|
720
1097
|
`;
|
|
721
1098
|
// The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
|
|
722
1099
|
// is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
|
|
@@ -781,13 +1158,689 @@ STABLE
|
|
|
781
1158
|
SECURITY DEFINER
|
|
782
1159
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
783
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).
|
|
784
1163
|
SELECT EXISTS (
|
|
785
1164
|
SELECT 1 FROM slotlock.tenant_suspensions suspended
|
|
786
|
-
WHERE suspended.tenant_ref
|
|
787
|
-
);
|
|
1165
|
+
WHERE suspended.tenant_ref IN (requested_tenant_ref, regexp_replace(requested_tenant_ref, '~test$', ''))
|
|
1166
|
+
) OR slotlock.partner_tenant_blocked(requested_tenant_ref);
|
|
788
1167
|
$slotlock_tenant_suspended$;
|
|
789
1168
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[0]} FROM PUBLIC;
|
|
790
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
|
+
|
|
791
1844
|
-- True when the tenant was not suspended already; a second suspension keeps the first's reason.
|
|
792
1845
|
CREATE OR REPLACE FUNCTION slotlock.suspend_tenant(requested_tenant_ref text, requested_reason text)
|
|
793
1846
|
RETURNS boolean
|
|
@@ -839,6 +1892,7 @@ AS $slotlock_erase_tenant$
|
|
|
839
1892
|
DECLARE
|
|
840
1893
|
setting text;
|
|
841
1894
|
previous jsonb := '{}'::jsonb;
|
|
1895
|
+
tested record;
|
|
842
1896
|
BEGIN
|
|
843
1897
|
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
|
|
844
1898
|
RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
|
|
@@ -847,6 +1901,15 @@ BEGIN
|
|
|
847
1901
|
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
848
1902
|
-- A key being issued commits first, so its row is deleted below with the rest.
|
|
849
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;
|
|
850
1913
|
FOR setting IN
|
|
851
1914
|
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
852
1915
|
LOOP
|
|
@@ -873,6 +1936,18 @@ BEGIN
|
|
|
873
1936
|
DELETE FROM slotlock.calendar_event_occurrences WHERE tenant_ref = requested_tenant_ref;
|
|
874
1937
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
875
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;
|
|
876
1951
|
erased_table := 'reservations';
|
|
877
1952
|
DELETE FROM slotlock.reservations
|
|
878
1953
|
WHERE tenant_ref = requested_tenant_ref
|
|
@@ -916,6 +1991,42 @@ BEGIN
|
|
|
916
1991
|
DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
|
|
917
1992
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
918
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;
|
|
919
2030
|
erased_table := 'assistant_conversations';
|
|
920
2031
|
DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
|
|
921
2032
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
@@ -924,10 +2035,24 @@ BEGIN
|
|
|
924
2035
|
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
|
|
925
2036
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
926
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;
|
|
927
2044
|
erased_table := 'resources';
|
|
928
2045
|
DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
|
|
929
2046
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
930
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;
|
|
931
2056
|
-- After every booking table: what their deletion just appended goes with the rest.
|
|
932
2057
|
erased_table := 'outbox';
|
|
933
2058
|
DELETE FROM slotlock.outbox WHERE tenant_ref = requested_tenant_ref;
|
|
@@ -953,6 +2078,31 @@ BEGIN
|
|
|
953
2078
|
DELETE FROM slotlock.maintenance_tenants WHERE tenant_ref = requested_tenant_ref;
|
|
954
2079
|
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
955
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;
|
|
956
2106
|
erased_table := 'api_keys';
|
|
957
2107
|
WITH erased AS (
|
|
958
2108
|
DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
|
|
@@ -963,6 +2113,13 @@ BEGIN
|
|
|
963
2113
|
)
|
|
964
2114
|
SELECT count(*) INTO erased_rows FROM erased;
|
|
965
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;
|
|
966
2123
|
erased_table := 'dashboard_tokens';
|
|
967
2124
|
erased_rows := 0;
|
|
968
2125
|
IF requested_tenant_ref LIKE 'github:%' THEN
|
|
@@ -971,9 +2128,29 @@ BEGIN
|
|
|
971
2128
|
AND owner_hash = sha256(convert_to(
|
|
972
2129
|
'slotlock-dashboard-owner.' || substr(requested_tenant_ref, 8), 'UTF8'));
|
|
973
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;
|
|
974
2137
|
END IF;
|
|
975
2138
|
RETURN NEXT;
|
|
976
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
|
+
|
|
977
2154
|
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
978
2155
|
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
979
2156
|
END LOOP;
|
|
@@ -981,6 +2158,76 @@ END
|
|
|
981
2158
|
$slotlock_erase_tenant$;
|
|
982
2159
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
|
|
983
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
|
+
|
|
984
2231
|
-- The tenants with a webhook endpoint, which a server delivers for. Row-level security hides every
|
|
985
2232
|
-- other tenant's endpoints from the serving role, so this list (names only) is how a server that
|
|
986
2233
|
-- restarted finds the tenants whose events still wait. No role but its owner reads it.
|
|
@@ -1443,7 +2690,68 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
|
|
|
1443
2690
|
last_error IS NULL OR char_length(last_error) <= 500
|
|
1444
2691
|
)
|
|
1445
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;
|
|
1446
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$;
|
|
1447
2755
|
-- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
|
|
1448
2756
|
-- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
|
|
1449
2757
|
DO $slotlock_webhook_type_count$
|
|
@@ -1496,6 +2804,23 @@ CREATE TABLE IF NOT EXISTS slotlock.waitlist_entries (
|
|
|
1496
2804
|
);
|
|
1497
2805
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
|
|
1498
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;
|
|
1499
2824
|
CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
|
|
1500
2825
|
ON slotlock.waitlist_entries (tenant_ref, starts_at);
|
|
1501
2826
|
|
|
@@ -1516,6 +2841,46 @@ CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
|
|
|
1516
2841
|
)
|
|
1517
2842
|
);
|
|
1518
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
|
+
|
|
1519
2884
|
-- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
|
|
1520
2885
|
-- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
|
|
1521
2886
|
-- bound to the tenant), with its last four characters to show.
|
|
@@ -1604,7 +2969,11 @@ BEGIN
|
|
|
1604
2969
|
kind := CASE WHEN NEW.status = 'held' THEN 'hold.updated' ELSE 'reservation.updated' END;
|
|
1605
2970
|
END IF;
|
|
1606
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.
|
|
1607
2974
|
kind := CASE
|
|
2975
|
+
WHEN OLD.status <> 'held' AND current_setting('slotlock.ending', true) = 'no_show'
|
|
2976
|
+
THEN 'reservation.no_show'
|
|
1608
2977
|
WHEN OLD.status <> 'held' THEN 'reservation.cancelled'
|
|
1609
2978
|
WHEN OLD.expires_at <= statement_timestamp() THEN 'hold.expired'
|
|
1610
2979
|
ELSE 'hold.released'
|
|
@@ -1621,7 +2990,12 @@ BEGIN
|
|
|
1621
2990
|
'start', changed.starts_at,
|
|
1622
2991
|
'end', changed.ends_at,
|
|
1623
2992
|
'buffer_after_ms', changed.buffer_after_ms,
|
|
1624
|
-
|
|
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,
|
|
1625
2999
|
'expires_at', CASE WHEN changed.status = 'held' THEN changed.expires_at END,
|
|
1626
3000
|
-- A move says where it came from.
|
|
1627
3001
|
'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
|
|
@@ -1771,9 +3145,11 @@ LANGUAGE sql
|
|
|
1771
3145
|
STABLE
|
|
1772
3146
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1773
3147
|
AS $slotlock_outbox_erasing$
|
|
1774
|
-
|
|
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)
|
|
1775
3151
|
AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
|
|
1776
|
-
WHERE nspname = 'slotlock');
|
|
3152
|
+
WHERE nspname = 'slotlock'), false);
|
|
1777
3153
|
$slotlock_outbox_erasing$;
|
|
1778
3154
|
|
|
1779
3155
|
CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
|
|
@@ -1786,6 +3162,8 @@ DECLARE
|
|
|
1786
3162
|
kind text;
|
|
1787
3163
|
before jsonb := '{}'::jsonb;
|
|
1788
3164
|
after jsonb := '{}'::jsonb;
|
|
3165
|
+
-- The profile fields that changed, by name only: a name or description is calendar content.
|
|
3166
|
+
profile_changed text[] := '{}';
|
|
1789
3167
|
details jsonb;
|
|
1790
3168
|
BEGIN
|
|
1791
3169
|
changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
@@ -1793,7 +3171,7 @@ BEGIN
|
|
|
1793
3171
|
IF TG_OP = 'INSERT' THEN
|
|
1794
3172
|
kind := 'resource.created';
|
|
1795
3173
|
details := jsonb_build_object(
|
|
1796
|
-
'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,
|
|
1797
3175
|
'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
|
|
1798
3176
|
ELSIF TG_OP = 'DELETE' THEN
|
|
1799
3177
|
kind := 'resource.deleted';
|
|
@@ -1815,6 +3193,16 @@ BEGIN
|
|
|
1815
3193
|
before := before || jsonb_build_object('pool', OLD.pool_ref);
|
|
1816
3194
|
after := after || jsonb_build_object('pool', NEW.pool_ref);
|
|
1817
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;
|
|
1818
3206
|
IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
|
|
1819
3207
|
before := before || jsonb_build_object('availability', OLD.availability_rules);
|
|
1820
3208
|
after := after || jsonb_build_object('availability', NEW.availability_rules);
|
|
@@ -1823,9 +3211,24 @@ BEGIN
|
|
|
1823
3211
|
before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
|
|
1824
3212
|
after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
|
|
1825
3213
|
END IF;
|
|
1826
|
-
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;
|
|
1827
3227
|
kind := 'resource.updated';
|
|
1828
|
-
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;
|
|
1829
3232
|
END IF;
|
|
1830
3233
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1831
3234
|
VALUES (
|
|
@@ -1897,61 +3300,574 @@ BEGIN
|
|
|
1897
3300
|
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
|
|
1898
3301
|
VALUES (
|
|
1899
3302
|
changed.tenant_ref, kind, changed.id::text,
|
|
1900
|
-
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,
|
|
1901
3311
|
NULLIF(current_setting('slotlock.actor', true), '')
|
|
1902
3312
|
);
|
|
1903
3313
|
RETURN NULL;
|
|
1904
3314
|
END
|
|
1905
3315
|
$slotlock_outbox_webhook$;
|
|
1906
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).
|
|
1907
3346
|
CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
|
|
1908
|
-
AFTER INSERT OR UPDATE
|
|
3347
|
+
AFTER INSERT OR UPDATE ON slotlock.resources
|
|
1909
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();
|
|
1910
3352
|
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
|
|
1911
3353
|
AFTER INSERT OR DELETE ON slotlock.calendar_coverage
|
|
1912
3354
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
|
|
1913
3355
|
|
|
1914
|
-
-- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
|
|
1915
|
-
-- named by its source's source_id (src_redacted for a source it cannot find), without the source or
|
|
1916
|
-
-- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
|
|
1917
|
-
-- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
|
|
1918
|
-
-- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
|
|
1919
|
-
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
|
|
1920
|
-
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
|
|
1921
3783
|
LANGUAGE plpgsql
|
|
1922
|
-
|
|
3784
|
+
VOLATILE
|
|
3785
|
+
SECURITY DEFINER
|
|
1923
3786
|
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1924
|
-
AS $
|
|
3787
|
+
AS $slotlock_set_partner_account_suspended$
|
|
1925
3788
|
DECLARE
|
|
1926
|
-
|
|
3789
|
+
tenant text;
|
|
1927
3790
|
BEGIN
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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;
|
|
1933
3807
|
END
|
|
1934
|
-
$
|
|
1935
|
-
|
|
1936
|
-
BEFORE INSERT ON slotlock.outbox
|
|
1937
|
-
FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
|
|
1938
|
-
EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
|
|
1939
|
-
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
|
|
1940
|
-
AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
|
|
1941
|
-
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
1942
|
-
-- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
|
|
1943
|
-
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
|
|
1944
|
-
AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
|
|
1945
|
-
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;
|
|
1946
3810
|
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
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;
|
|
1955
3871
|
`;
|
|
1956
3872
|
// btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
|
|
1957
3873
|
const CORE_DDL_BODY = `
|
|
@@ -2052,6 +3968,18 @@ CREATE INDEX IF NOT EXISTS slotlock_resources_pool_idx
|
|
|
2052
3968
|
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
|
|
2053
3969
|
-- When the tenant archived it: listed nowhere and booked no more, its history kept. NULL: in use.
|
|
2054
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);
|
|
2055
3983
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
2056
3984
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
2057
3985
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
@@ -2338,6 +4266,30 @@ BEGIN
|
|
|
2338
4266
|
ALTER TABLE slotlock.resources
|
|
2339
4267
|
ADD CONSTRAINT slotlock_resources_visibility_valid CHECK (visibility IN ('shared', 'private'));
|
|
2340
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;
|
|
2341
4293
|
IF NOT EXISTS (
|
|
2342
4294
|
SELECT 1 FROM pg_constraint
|
|
2343
4295
|
WHERE conname = 'slotlock_reservations_revision_valid'
|
|
@@ -2447,6 +4399,406 @@ BEGIN
|
|
|
2447
4399
|
END IF;
|
|
2448
4400
|
END $$;
|
|
2449
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
|
+
|
|
2450
4802
|
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
2451
4803
|
-- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
|
|
2452
4804
|
-- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
|
|
@@ -3260,7 +5612,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
3260
5612
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
3261
5613
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
3262
5614
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
3263
|
-
${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}`;
|
|
3264
5616
|
/**
|
|
3265
5617
|
* Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
|
|
3266
5618
|
* or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
|
|
@@ -3390,6 +5742,13 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
3390
5742
|
'booking_rule_defaults',
|
|
3391
5743
|
'assistant_settings',
|
|
3392
5744
|
'assistant_conversations',
|
|
5745
|
+
'locations',
|
|
5746
|
+
'resource_groups',
|
|
5747
|
+
'resource_group_members',
|
|
5748
|
+
'pool_settings',
|
|
5749
|
+
'composite_claims',
|
|
5750
|
+
'approval_policies',
|
|
5751
|
+
'approvals',
|
|
3393
5752
|
]);
|
|
3394
5753
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
3395
5754
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -3416,6 +5775,46 @@ GRANT EXECUTE ON FUNCTION ${SLOTLOCK_DEFINER_FUNCTIONS.join(', ')} TO "${role}";
|
|
|
3416
5775
|
* `slotlock.tenant_ref`; adapters may supply an established transaction-local setting name.
|
|
3417
5776
|
* Every policy fails closed when the setting is absent or empty, and FORCE binds the table owner.
|
|
3418
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');
|
|
3419
5818
|
export function createSlotlockTenantRlsDdl(tenantContextSetting = SLOTLOCK_TENANT_CONTEXT_SETTING) {
|
|
3420
5819
|
if (tenantContextSetting.length > 128 ||
|
|
3421
5820
|
!POSTGRES_SETTING_NAME.test(tenantContextSetting) ||
|
|
@@ -3463,6 +5862,20 @@ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
|
|
|
3463
5862
|
ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
|
|
3464
5863
|
ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
|
|
3465
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;
|
|
3466
5879
|
|
|
3467
5880
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
3468
5881
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -3494,7 +5907,14 @@ BEGIN
|
|
|
3494
5907
|
'waitlist_entries',
|
|
3495
5908
|
'booking_rule_defaults',
|
|
3496
5909
|
'assistant_settings',
|
|
3497
|
-
'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'
|
|
3498
5918
|
)
|
|
3499
5919
|
LOOP
|
|
3500
5920
|
EXECUTE format(
|
|
@@ -3735,6 +6155,76 @@ BEGIN
|
|
|
3735
6155
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
3736
6156
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
3737
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;
|
|
3738
6228
|
END
|
|
3739
6229
|
$slotlock_rls$;
|
|
3740
6230
|
|
|
@@ -3778,6 +6268,21 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
|
|
|
3778
6268
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
3779
6269
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
|
|
3780
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}
|
|
3781
6286
|
|
|
3782
6287
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
3783
6288
|
table_name, tenant_context_setting, policy_fingerprint, installed_at
|
|
@@ -3820,7 +6325,14 @@ WHERE schemaname = 'slotlock'
|
|
|
3820
6325
|
'waitlist_entries',
|
|
3821
6326
|
'booking_rule_defaults',
|
|
3822
6327
|
'assistant_settings',
|
|
3823
|
-
'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'
|
|
3824
6336
|
)
|
|
3825
6337
|
AND policyname = 'tenant_isolation_v1'
|
|
3826
6338
|
ON CONFLICT (table_name) DO UPDATE
|