slotlock 0.1.1 → 0.3.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 +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/ddl.js
CHANGED
|
@@ -18,8 +18,92 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
|
|
|
18
18
|
`;
|
|
19
19
|
const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
|
|
20
20
|
`;
|
|
21
|
+
// applyTenantRls forces row-level security, which binds the table owner too, and the deployment
|
|
22
|
+
// sets no tenant: its data steps (reaping holds and listing conflicts before the overlap
|
|
23
|
+
// constraint, merging duplicate resources, binding archives, backfilling event children) would see
|
|
24
|
+
// no rows, skip what they repair and leave a constraint to meet rows its own check never saw. The
|
|
25
|
+
// deployment lifts FORCE from the tables that have it before its first data step and puts it back
|
|
26
|
+
// after its last, on those tables only. applySchema runs both in one transaction, which holds the
|
|
27
|
+
// tables locked in between; a failure rolls the lift back with everything else. Run statement by
|
|
28
|
+
// statement, the lift commits on its own and a refused data step stops the run before the restore,
|
|
29
|
+
// so the tables lifted are recorded in a table, not the session: the next deployment, from any
|
|
30
|
+
// session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
|
|
31
|
+
// start while a tenant table is left without it.
|
|
32
|
+
const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
|
|
33
|
+
table_name text PRIMARY KEY
|
|
34
|
+
);
|
|
35
|
+
REVOKE ALL ON slotlock.deployment_lifted_row_security FROM PUBLIC;
|
|
36
|
+
DO $slotlock_lift_forced_row_security$
|
|
37
|
+
DECLARE
|
|
38
|
+
forced_table text;
|
|
39
|
+
BEGIN
|
|
40
|
+
INSERT INTO slotlock.deployment_lifted_row_security (table_name)
|
|
41
|
+
SELECT rel.relname::text
|
|
42
|
+
FROM pg_catalog.pg_class rel
|
|
43
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
44
|
+
WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p') AND rel.relforcerowsecurity
|
|
45
|
+
ON CONFLICT (table_name) DO NOTHING;
|
|
46
|
+
FOR forced_table IN
|
|
47
|
+
SELECT rel.relname::text
|
|
48
|
+
FROM pg_catalog.pg_class rel
|
|
49
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
50
|
+
WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p') AND rel.relforcerowsecurity
|
|
51
|
+
ORDER BY rel.relname
|
|
52
|
+
LOOP
|
|
53
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I NO FORCE ROW LEVEL SECURITY', forced_table);
|
|
54
|
+
END LOOP;
|
|
55
|
+
END
|
|
56
|
+
$slotlock_lift_forced_row_security$;
|
|
57
|
+
`;
|
|
58
|
+
const DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY = `DO $slotlock_restore_forced_row_security$
|
|
59
|
+
DECLARE
|
|
60
|
+
forced_table text;
|
|
61
|
+
BEGIN
|
|
62
|
+
FOR forced_table IN
|
|
63
|
+
SELECT table_name FROM slotlock.deployment_lifted_row_security ORDER BY table_name
|
|
64
|
+
LOOP
|
|
65
|
+
IF pg_catalog.to_regclass(pg_catalog.format('slotlock.%I', forced_table)) IS NOT NULL THEN
|
|
66
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I FORCE ROW LEVEL SECURITY', forced_table);
|
|
67
|
+
END IF;
|
|
68
|
+
END LOOP;
|
|
69
|
+
DELETE FROM slotlock.deployment_lifted_row_security;
|
|
70
|
+
END
|
|
71
|
+
$slotlock_restore_forced_row_security$;
|
|
72
|
+
`;
|
|
21
73
|
/** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
|
|
22
74
|
export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
|
|
75
|
+
/**
|
|
76
|
+
* The version of the schema this release creates, recorded in `slotlock.schema_version` by every
|
|
77
|
+
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2.
|
|
78
|
+
*/
|
|
79
|
+
export const SLOTLOCK_SCHEMA_VERSION = 3;
|
|
80
|
+
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
81
|
+
export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
|
|
82
|
+
/** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
|
|
83
|
+
const MIN_SERVER_VERSION_NUM = 160000;
|
|
84
|
+
// Before anything changes: a server too old for the DDL fails here, with a message that names the
|
|
85
|
+
// version, rather than part way through on a function it lacks; and a schema a newer release
|
|
86
|
+
// migrated is refused, since this release's DDL would put back what that release replaced.
|
|
87
|
+
const SLOTLOCK_SCHEMA_PRECONDITIONS = `DO $slotlock_schema_preconditions$
|
|
88
|
+
DECLARE
|
|
89
|
+
installed integer;
|
|
90
|
+
BEGIN
|
|
91
|
+
IF pg_catalog.current_setting('server_version_num')::integer < ${MIN_SERVER_VERSION_NUM} THEN
|
|
92
|
+
RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',
|
|
93
|
+
pg_catalog.current_setting('server_version')
|
|
94
|
+
USING ERRCODE = 'feature_not_supported';
|
|
95
|
+
END IF;
|
|
96
|
+
IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN
|
|
97
|
+
EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;
|
|
98
|
+
IF installed > ${SLOTLOCK_SCHEMA_VERSION} THEN
|
|
99
|
+
RAISE EXCEPTION '${SLOTLOCK_SCHEMA_NEWER_REFUSAL} (version %; this release is version ${SLOTLOCK_SCHEMA_VERSION})', installed
|
|
100
|
+
USING ERRCODE = 'object_not_in_prerequisite_state',
|
|
101
|
+
HINT = 'Migrate and serve it with the release that migrated it, or a later one.';
|
|
102
|
+
END IF;
|
|
103
|
+
END IF;
|
|
104
|
+
END
|
|
105
|
+
$slotlock_schema_preconditions$;
|
|
106
|
+
`;
|
|
23
107
|
// Every slotlock.<name> the deployment uses resolves in that schema, and the deployment writes rows
|
|
24
108
|
// into its tables, firing their triggers, as the owner of every table. A role that owns the schema,
|
|
25
109
|
// can create objects in it, owns an object in it, owns the function a trigger on its tables runs, or
|
|
@@ -102,6 +186,12 @@ BEGIN
|
|
|
102
186
|
END
|
|
103
187
|
$slotlock_schema_control$;
|
|
104
188
|
`;
|
|
189
|
+
/**
|
|
190
|
+
* What every migration checks before it changes anything, applySchema and applyTenantRls included
|
|
191
|
+
* when they find nothing to change: that no other role controls the schema, then the server version
|
|
192
|
+
* and the schema version. Run with search_path pinned, as the DDL pins it.
|
|
193
|
+
*/
|
|
194
|
+
export const SLOTLOCK_DEPLOYMENT_CHECKS = `${SLOTLOCK_SCHEMA_CONTROL_CHECK}${SLOTLOCK_SCHEMA_PRECONDITIONS}`;
|
|
105
195
|
/** Active (unrevoked, unexpired) API keys one tenant may hold. */
|
|
106
196
|
export const SLOTLOCK_API_KEY_ACTIVE_LIMIT = 100;
|
|
107
197
|
/** API keys one tenant may hold in all, revoked and expired ones included, so lists stay bounded. */
|
|
@@ -138,16 +228,51 @@ export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
|
|
|
138
228
|
]);
|
|
139
229
|
/** Live form records one person may leave in the dashboard's records (12 hours of forms). */
|
|
140
230
|
export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
|
|
231
|
+
/** The table that records which release migrated the schema (SLOTLOCK_SCHEMA_VERSION). */
|
|
232
|
+
export const SLOTLOCK_SCHEMA_TABLES = Object.freeze(['schema_version']);
|
|
233
|
+
/** The SECURITY DEFINER function through which a server reads the version it is serving. */
|
|
234
|
+
export const SLOTLOCK_SCHEMA_FUNCTIONS = Object.freeze([
|
|
235
|
+
'slotlock.installed_schema_version()',
|
|
236
|
+
]);
|
|
237
|
+
/** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
|
|
238
|
+
export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze(['tenant_suspensions']);
|
|
239
|
+
/**
|
|
240
|
+
* The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
|
|
241
|
+
* tenant, by signature.
|
|
242
|
+
*/
|
|
243
|
+
export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
|
|
244
|
+
'slotlock.tenant_suspended(text)',
|
|
245
|
+
'slotlock.erase_tenant(text)',
|
|
246
|
+
]);
|
|
247
|
+
/**
|
|
248
|
+
* Functions only the deployment role runs: suspending and lifting a suspension. No other role is
|
|
249
|
+
* granted them, so a serving role cannot lift a suspension.
|
|
250
|
+
*/
|
|
251
|
+
export const SLOTLOCK_OPERATOR_FUNCTIONS = Object.freeze([
|
|
252
|
+
'slotlock.suspend_tenant(text, text)',
|
|
253
|
+
'slotlock.unsuspend_tenant(text)',
|
|
254
|
+
]);
|
|
141
255
|
/** Every SECURITY DEFINER function the serving role may execute, by signature. */
|
|
142
256
|
export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
|
|
143
257
|
...SLOTLOCK_API_KEY_FUNCTIONS,
|
|
144
258
|
...SLOTLOCK_DASHBOARD_FUNCTIONS,
|
|
259
|
+
...SLOTLOCK_SCHEMA_FUNCTIONS,
|
|
260
|
+
...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
|
|
145
261
|
]);
|
|
146
262
|
/** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
|
|
147
263
|
export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
|
|
148
264
|
...SLOTLOCK_API_KEY_TABLES,
|
|
149
265
|
...SLOTLOCK_DASHBOARD_TABLES,
|
|
266
|
+
...SLOTLOCK_SCHEMA_TABLES,
|
|
267
|
+
...SLOTLOCK_TENANT_ADMIN_TABLES,
|
|
150
268
|
]);
|
|
269
|
+
/**
|
|
270
|
+
* The advisory lock every server transaction for a tenant holds shared (store.ts withTenant), and
|
|
271
|
+
* suspension and erasure take exclusively, keyed by this text's hashtextextended(…, 0).
|
|
272
|
+
*/
|
|
273
|
+
export function slotlockTenantLockKey(tenantRef) {
|
|
274
|
+
return `slotlock:tenant:${tenantRef}`;
|
|
275
|
+
}
|
|
151
276
|
const API_KEY_COLUMNS = `key_id uuid,
|
|
152
277
|
key_tenant_ref text,
|
|
153
278
|
key_name text,
|
|
@@ -191,8 +316,13 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
191
316
|
CONSTRAINT slotlock_api_keys_name_valid
|
|
192
317
|
CHECK (char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'),
|
|
193
318
|
CONSTRAINT slotlock_api_keys_prefix_valid CHECK (prefix ~ '^slk_[0-9A-Za-z]{8}$'),
|
|
194
|
-
|
|
195
|
-
|
|
319
|
+
-- A non-empty list of read, write and admin, each once, in that order (api-keys.ts).
|
|
320
|
+
CONSTRAINT slotlock_api_keys_scopes_known
|
|
321
|
+
CHECK (scopes IN (
|
|
322
|
+
ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['admin']::text[],
|
|
323
|
+
ARRAY['read', 'write']::text[], ARRAY['read', 'admin']::text[], ARRAY['write', 'admin']::text[],
|
|
324
|
+
ARRAY['read', 'write', 'admin']::text[]
|
|
325
|
+
)),
|
|
196
326
|
CONSTRAINT slotlock_api_keys_created_by_valid
|
|
197
327
|
CHECK (created_by IS NULL OR (octet_length(created_by) BETWEEN 1 AND 200
|
|
198
328
|
AND created_by !~ '[[:cntrl:]]')),
|
|
@@ -204,6 +334,27 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
|
|
|
204
334
|
CREATE INDEX IF NOT EXISTS slotlock_api_keys_tenant_idx
|
|
205
335
|
ON slotlock.api_keys (tenant_ref, created_at DESC, id DESC);
|
|
206
336
|
ALTER TABLE slotlock.api_keys ENABLE ROW LEVEL SECURITY;
|
|
337
|
+
|
|
338
|
+
-- Schemas from 0.1 allow only read and write. The CHECK with admin admits every row the old one
|
|
339
|
+
-- did, so it is added NOT VALID (no table scan under the lock) and the old one dropped in the same
|
|
340
|
+
-- transaction.
|
|
341
|
+
DO $slotlock_api_key_scopes$
|
|
342
|
+
BEGIN
|
|
343
|
+
IF NOT EXISTS (
|
|
344
|
+
SELECT 1 FROM pg_constraint
|
|
345
|
+
WHERE conname = 'slotlock_api_keys_scopes_known'
|
|
346
|
+
AND conrelid = 'slotlock.api_keys'::regclass
|
|
347
|
+
) THEN
|
|
348
|
+
ALTER TABLE slotlock.api_keys
|
|
349
|
+
ADD CONSTRAINT slotlock_api_keys_scopes_known CHECK (scopes IN (
|
|
350
|
+
ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['admin']::text[],
|
|
351
|
+
ARRAY['read', 'write']::text[], ARRAY['read', 'admin']::text[], ARRAY['write', 'admin']::text[],
|
|
352
|
+
ARRAY['read', 'write', 'admin']::text[]
|
|
353
|
+
)) NOT VALID;
|
|
354
|
+
END IF;
|
|
355
|
+
ALTER TABLE slotlock.api_keys DROP CONSTRAINT IF EXISTS slotlock_api_keys_scopes_valid;
|
|
356
|
+
END
|
|
357
|
+
$slotlock_api_key_scopes$;
|
|
207
358
|
REVOKE ALL ON slotlock.api_keys FROM PUBLIC;
|
|
208
359
|
|
|
209
360
|
CREATE TABLE IF NOT EXISTS slotlock.api_key_retired_digests (
|
|
@@ -238,6 +389,11 @@ BEGIN
|
|
|
238
389
|
USING ERRCODE = 'invalid_transaction_state';
|
|
239
390
|
END IF;
|
|
240
391
|
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
392
|
+
IF EXISTS (SELECT 1 FROM slotlock.tenant_suspensions suspended
|
|
393
|
+
WHERE suspended.tenant_ref = requested_tenant_ref) THEN
|
|
394
|
+
RAISE EXCEPTION 'Slotlock tenant is suspended: it gets no new API key'
|
|
395
|
+
USING ERRCODE = 'insufficient_privilege', HINT = 'tenant_suspended';
|
|
396
|
+
END IF;
|
|
241
397
|
IF EXISTS (SELECT 1 FROM slotlock.api_key_retired_digests retired
|
|
242
398
|
WHERE retired.secret_hash = requested_secret_hash) THEN
|
|
243
399
|
RAISE EXCEPTION 'Slotlock never uses a retired API key digest again'
|
|
@@ -292,6 +448,13 @@ AS $slotlock_rotate_api_key$
|
|
|
292
448
|
DECLARE
|
|
293
449
|
retiring bytea;
|
|
294
450
|
BEGIN
|
|
451
|
+
-- Ordered with suspension, which takes this lock too.
|
|
452
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
453
|
+
IF EXISTS (SELECT 1 FROM slotlock.tenant_suspensions suspended
|
|
454
|
+
WHERE suspended.tenant_ref = requested_tenant_ref) THEN
|
|
455
|
+
RAISE EXCEPTION 'Slotlock tenant is suspended: it gets no new API key secret'
|
|
456
|
+
USING ERRCODE = 'insufficient_privilege', HINT = 'tenant_suspended';
|
|
457
|
+
END IF;
|
|
295
458
|
IF EXISTS (SELECT 1 FROM slotlock.api_key_retired_digests retired
|
|
296
459
|
WHERE retired.secret_hash = requested_secret_hash) THEN
|
|
297
460
|
RAISE EXCEPTION 'Slotlock never uses a retired API key digest again'
|
|
@@ -492,11 +655,269 @@ AS $slotlock_dashboard_session_ended$
|
|
|
492
655
|
$slotlock_dashboard_session_ended$;
|
|
493
656
|
REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
|
|
494
657
|
`;
|
|
658
|
+
// The schema's version. One row, written by the DDL itself at its end, so a schema migrated by
|
|
659
|
+
// running SLOTLOCK_CORE_DDL directly is versioned too. applySchema adds digests of the DDL and of the
|
|
660
|
+
// catalog it left, so that a later migration with nothing to change can tell, and take no lock on
|
|
661
|
+
// any table. Nothing here names a tenant; RLS is enabled with no policy and no role is granted
|
|
662
|
+
// anything, so a server reads the version only through installed_schema_version().
|
|
663
|
+
const SLOTLOCK_SCHEMA_VERSION_DDL = `
|
|
664
|
+
CREATE TABLE IF NOT EXISTS slotlock.schema_version (
|
|
665
|
+
singleton boolean PRIMARY KEY DEFAULT true,
|
|
666
|
+
version integer NOT NULL,
|
|
667
|
+
ddl_sha256 text,
|
|
668
|
+
catalog_sha256 text,
|
|
669
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
670
|
+
CONSTRAINT slotlock_schema_version_singleton CHECK (singleton),
|
|
671
|
+
CONSTRAINT slotlock_schema_version_valid CHECK (version > 0)
|
|
672
|
+
);
|
|
673
|
+
ALTER TABLE slotlock.schema_version ENABLE ROW LEVEL SECURITY;
|
|
674
|
+
REVOKE ALL ON slotlock.schema_version FROM PUBLIC;
|
|
675
|
+
|
|
676
|
+
-- NULL when no migration has recorded a version (a schema from 0.2 or earlier).
|
|
677
|
+
CREATE OR REPLACE FUNCTION slotlock.installed_schema_version()
|
|
678
|
+
RETURNS integer
|
|
679
|
+
LANGUAGE sql
|
|
680
|
+
STABLE
|
|
681
|
+
SECURITY DEFINER
|
|
682
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
683
|
+
AS $slotlock_installed_schema_version$
|
|
684
|
+
SELECT version FROM slotlock.schema_version WHERE singleton;
|
|
685
|
+
$slotlock_installed_schema_version$;
|
|
686
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
|
|
687
|
+
`;
|
|
688
|
+
// Tenant suspension and erasure. A suspended tenant's server transactions (withTenant) may read,
|
|
689
|
+
// and write nothing; its keys still authenticate, but it gets no new key or secret. The table names
|
|
690
|
+
// tenants and why they were suspended, so RLS is enabled with no policy and no role is granted
|
|
691
|
+
// anything: a server reads it only through tenant_suspended(), and only the deployment role, which
|
|
692
|
+
// owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
|
|
693
|
+
// suspended.
|
|
694
|
+
//
|
|
695
|
+
// Every server transaction for a tenant holds the tenant's lock shared, from before it reads the
|
|
696
|
+
// suspension; suspension and erasure take it exclusively. So a suspension waits for the tenant's
|
|
697
|
+
// transactions in flight, and every one that starts after it sees it.
|
|
698
|
+
const SLOTLOCK_TENANT_ADMIN_DDL = `
|
|
699
|
+
CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
|
|
700
|
+
tenant_ref text PRIMARY KEY,
|
|
701
|
+
reason text,
|
|
702
|
+
suspended_at timestamptz NOT NULL DEFAULT now(),
|
|
703
|
+
CONSTRAINT slotlock_tenant_suspensions_tenant_ref_valid
|
|
704
|
+
CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
|
|
705
|
+
CONSTRAINT slotlock_tenant_suspensions_reason_valid
|
|
706
|
+
CHECK (reason IS NULL OR (char_length(reason) BETWEEN 1 AND 500 AND reason !~ '[[:cntrl:]]'))
|
|
707
|
+
);
|
|
708
|
+
ALTER TABLE slotlock.tenant_suspensions ENABLE ROW LEVEL SECURITY;
|
|
709
|
+
REVOKE ALL ON slotlock.tenant_suspensions FROM PUBLIC;
|
|
710
|
+
|
|
711
|
+
CREATE OR REPLACE FUNCTION slotlock.tenant_suspended(requested_tenant_ref text)
|
|
712
|
+
RETURNS boolean
|
|
713
|
+
LANGUAGE sql
|
|
714
|
+
STABLE
|
|
715
|
+
SECURITY DEFINER
|
|
716
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
717
|
+
AS $slotlock_tenant_suspended$
|
|
718
|
+
SELECT EXISTS (
|
|
719
|
+
SELECT 1 FROM slotlock.tenant_suspensions suspended
|
|
720
|
+
WHERE suspended.tenant_ref = requested_tenant_ref
|
|
721
|
+
);
|
|
722
|
+
$slotlock_tenant_suspended$;
|
|
723
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[0]} FROM PUBLIC;
|
|
724
|
+
|
|
725
|
+
-- True when the tenant was not suspended already; a second suspension keeps the first's reason.
|
|
726
|
+
CREATE OR REPLACE FUNCTION slotlock.suspend_tenant(requested_tenant_ref text, requested_reason text)
|
|
727
|
+
RETURNS boolean
|
|
728
|
+
LANGUAGE plpgsql
|
|
729
|
+
VOLATILE
|
|
730
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
731
|
+
AS $slotlock_suspend_tenant$
|
|
732
|
+
BEGIN
|
|
733
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
734
|
+
-- The tenant's key lock too: a key or secret being issued commits first, and one issued after
|
|
735
|
+
-- this commits sees the suspension and is refused.
|
|
736
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
737
|
+
INSERT INTO slotlock.tenant_suspensions (tenant_ref, reason)
|
|
738
|
+
VALUES (requested_tenant_ref, requested_reason)
|
|
739
|
+
ON CONFLICT (tenant_ref) DO NOTHING;
|
|
740
|
+
RETURN FOUND;
|
|
741
|
+
END
|
|
742
|
+
$slotlock_suspend_tenant$;
|
|
743
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[0]} FROM PUBLIC;
|
|
744
|
+
|
|
745
|
+
-- True when the tenant was suspended.
|
|
746
|
+
CREATE OR REPLACE FUNCTION slotlock.unsuspend_tenant(requested_tenant_ref text)
|
|
747
|
+
RETURNS boolean
|
|
748
|
+
LANGUAGE plpgsql
|
|
749
|
+
VOLATILE
|
|
750
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
751
|
+
AS $slotlock_unsuspend_tenant$
|
|
752
|
+
BEGIN
|
|
753
|
+
DELETE FROM slotlock.tenant_suspensions WHERE tenant_ref = requested_tenant_ref;
|
|
754
|
+
RETURN FOUND;
|
|
755
|
+
END
|
|
756
|
+
$slotlock_unsuspend_tenant$;
|
|
757
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[1]} FROM PUBLIC;
|
|
758
|
+
|
|
759
|
+
-- Tenant erasure: every row the tenant owns, one row of the result per table with how many were
|
|
760
|
+
-- deleted, in an order no foreign key refuses. Holds carry no tenant: those on the tenant's resources
|
|
761
|
+
-- are its. API key digests are retired, as erase_api_keys retires them, so no erased key works again.
|
|
762
|
+
-- A personal (github:<id>) tenant's dashboard form records carry a digest of the person, the
|
|
763
|
+
-- dashboard's (dashboard.ts ownerDigest). Running it again deletes nothing. Forced row-level security
|
|
764
|
+
-- binds the function's owner too, so it names the tenant in every setting a policy reads, and puts
|
|
765
|
+
-- the caller's values back after.
|
|
766
|
+
CREATE OR REPLACE FUNCTION slotlock.erase_tenant(requested_tenant_ref text)
|
|
767
|
+
RETURNS TABLE (erased_table text, erased_rows bigint)
|
|
768
|
+
LANGUAGE plpgsql
|
|
769
|
+
VOLATILE
|
|
770
|
+
SECURITY DEFINER
|
|
771
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
772
|
+
AS $slotlock_erase_tenant$
|
|
773
|
+
DECLARE
|
|
774
|
+
setting text;
|
|
775
|
+
previous jsonb := '{}'::jsonb;
|
|
776
|
+
BEGIN
|
|
777
|
+
IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
|
|
778
|
+
RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
|
|
779
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
780
|
+
END IF;
|
|
781
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
|
|
782
|
+
-- A key being issued commits first, so its row is deleted below with the rest.
|
|
783
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
|
|
784
|
+
FOR setting IN
|
|
785
|
+
SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
|
|
786
|
+
LOOP
|
|
787
|
+
previous := previous || jsonb_build_object(setting, current_setting(setting, true));
|
|
788
|
+
PERFORM set_config(setting, requested_tenant_ref, true);
|
|
789
|
+
END LOOP;
|
|
790
|
+
|
|
791
|
+
erased_table := 'calendar_event_attendees';
|
|
792
|
+
DELETE FROM slotlock.calendar_event_attendees WHERE tenant_ref = requested_tenant_ref;
|
|
793
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
794
|
+
RETURN NEXT;
|
|
795
|
+
erased_table := 'calendar_event_reminders';
|
|
796
|
+
DELETE FROM slotlock.calendar_event_reminders WHERE tenant_ref = requested_tenant_ref;
|
|
797
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
798
|
+
RETURN NEXT;
|
|
799
|
+
erased_table := 'calendar_event_exceptions';
|
|
800
|
+
DELETE FROM slotlock.calendar_event_exceptions WHERE tenant_ref = requested_tenant_ref;
|
|
801
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
802
|
+
RETURN NEXT;
|
|
803
|
+
erased_table := 'calendar_event_occurrences';
|
|
804
|
+
DELETE FROM slotlock.calendar_event_occurrences WHERE tenant_ref = requested_tenant_ref;
|
|
805
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
806
|
+
RETURN NEXT;
|
|
807
|
+
erased_table := 'reservations';
|
|
808
|
+
DELETE FROM slotlock.reservations
|
|
809
|
+
WHERE tenant_ref = requested_tenant_ref
|
|
810
|
+
OR resource_id IN (SELECT owned.id FROM slotlock.resources owned
|
|
811
|
+
WHERE owned.tenant_ref = requested_tenant_ref);
|
|
812
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
813
|
+
RETURN NEXT;
|
|
814
|
+
erased_table := 'calendar_events';
|
|
815
|
+
DELETE FROM slotlock.calendar_events WHERE tenant_ref = requested_tenant_ref;
|
|
816
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
817
|
+
RETURN NEXT;
|
|
818
|
+
erased_table := 'calendar_event_tombstones';
|
|
819
|
+
DELETE FROM slotlock.calendar_event_tombstones WHERE tenant_ref = requested_tenant_ref;
|
|
820
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
821
|
+
RETURN NEXT;
|
|
822
|
+
erased_table := 'calendar_event_commands';
|
|
823
|
+
DELETE FROM slotlock.calendar_event_commands WHERE tenant_ref = requested_tenant_ref;
|
|
824
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
825
|
+
RETURN NEXT;
|
|
826
|
+
erased_table := 'reservation_tombstones';
|
|
827
|
+
DELETE FROM slotlock.reservation_tombstones WHERE tenant_ref = requested_tenant_ref;
|
|
828
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
829
|
+
RETURN NEXT;
|
|
830
|
+
erased_table := 'reservation_conflict_archive';
|
|
831
|
+
DELETE FROM slotlock.reservation_conflict_archive WHERE tenant_ref = requested_tenant_ref;
|
|
832
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
833
|
+
RETURN NEXT;
|
|
834
|
+
erased_table := 'calendar_coverage';
|
|
835
|
+
DELETE FROM slotlock.calendar_coverage WHERE tenant_ref = requested_tenant_ref;
|
|
836
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
837
|
+
RETURN NEXT;
|
|
838
|
+
erased_table := 'hold_commands';
|
|
839
|
+
DELETE FROM slotlock.hold_commands WHERE tenant_ref = requested_tenant_ref;
|
|
840
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
841
|
+
RETURN NEXT;
|
|
842
|
+
erased_table := 'resources';
|
|
843
|
+
DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
|
|
844
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
845
|
+
RETURN NEXT;
|
|
846
|
+
erased_table := 'api_keys';
|
|
847
|
+
WITH erased AS (
|
|
848
|
+
DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
|
|
849
|
+
), retired AS (
|
|
850
|
+
INSERT INTO slotlock.api_key_retired_digests (secret_hash)
|
|
851
|
+
SELECT erased.secret_hash FROM erased
|
|
852
|
+
ON CONFLICT DO NOTHING
|
|
853
|
+
)
|
|
854
|
+
SELECT count(*) INTO erased_rows FROM erased;
|
|
855
|
+
RETURN NEXT;
|
|
856
|
+
erased_table := 'dashboard_tokens';
|
|
857
|
+
erased_rows := 0;
|
|
858
|
+
IF requested_tenant_ref LIKE 'github:%' THEN
|
|
859
|
+
DELETE FROM slotlock.dashboard_tokens
|
|
860
|
+
WHERE kind = 'form'
|
|
861
|
+
AND owner_hash = sha256(convert_to(
|
|
862
|
+
'slotlock-dashboard-owner.' || substr(requested_tenant_ref, 8), 'UTF8'));
|
|
863
|
+
GET DIAGNOSTICS erased_rows = ROW_COUNT;
|
|
864
|
+
END IF;
|
|
865
|
+
RETURN NEXT;
|
|
866
|
+
|
|
867
|
+
FOR setting IN SELECT jsonb_object_keys(previous) LOOP
|
|
868
|
+
PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
|
|
869
|
+
END LOOP;
|
|
870
|
+
END
|
|
871
|
+
$slotlock_erase_tenant$;
|
|
872
|
+
REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
|
|
873
|
+
`;
|
|
874
|
+
// A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
|
|
875
|
+
// here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
|
|
876
|
+
// DDL's transaction every table it altered stays under ACCESS EXCLUSIVE until commit, so the scan
|
|
877
|
+
// would hold up every serving query. On its own, each VALIDATE takes SHARE UPDATE EXCLUSIVE (and
|
|
878
|
+
// ROW SHARE on a foreign key's referenced table), which no read or row write conflicts with
|
|
879
|
+
// (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
|
|
880
|
+
// the table forces: the check scans the table itself, and referential checks bypass row security.
|
|
881
|
+
// One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
|
|
882
|
+
// it binds every new row either way, and applySchema tries it again at each later migration. Once
|
|
883
|
+
// valid, a constraint is never checked again.
|
|
884
|
+
const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
|
|
885
|
+
DECLARE
|
|
886
|
+
unchecked record;
|
|
887
|
+
BEGIN
|
|
888
|
+
FOR unchecked IN
|
|
889
|
+
SELECT rel.relname::text AS table_name, con.conname::text AS constraint_name
|
|
890
|
+
FROM pg_catalog.pg_constraint con
|
|
891
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
892
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
893
|
+
WHERE ns.nspname = 'slotlock' AND NOT con.convalidated
|
|
894
|
+
ORDER BY rel.relname, con.conname
|
|
895
|
+
LOOP
|
|
896
|
+
BEGIN
|
|
897
|
+
EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I VALIDATE CONSTRAINT %I',
|
|
898
|
+
unchecked.table_name, unchecked.constraint_name);
|
|
899
|
+
EXCEPTION WHEN check_violation OR foreign_key_violation OR not_null_violation THEN
|
|
900
|
+
RAISE NOTICE 'Slotlock left %.% NOT VALID: existing rows break it',
|
|
901
|
+
unchecked.table_name, unchecked.constraint_name;
|
|
902
|
+
END;
|
|
903
|
+
END LOOP;
|
|
904
|
+
END
|
|
905
|
+
$slotlock_validate_constraints$;
|
|
906
|
+
`;
|
|
907
|
+
/** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
|
|
908
|
+
export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
|
|
909
|
+
// The digests that let the next migration skip are recorded by applySchema once it has validated.
|
|
910
|
+
const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
|
|
911
|
+
VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
|
|
912
|
+
ON CONFLICT (singleton) DO UPDATE
|
|
913
|
+
SET version = EXCLUDED.version, ddl_sha256 = NULL, catalog_sha256 = NULL,
|
|
914
|
+
applied_at = EXCLUDED.applied_at;
|
|
915
|
+
`;
|
|
495
916
|
// btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
|
|
496
|
-
|
|
917
|
+
const CORE_DDL_BODY = `
|
|
497
918
|
${DEPLOYMENT_SEARCH_PATH_PIN}CREATE SCHEMA IF NOT EXISTS slotlock;
|
|
498
|
-
${
|
|
499
|
-
|
|
919
|
+
${SLOTLOCK_DEPLOYMENT_CHECKS}CREATE EXTENSION IF NOT EXISTS btree_gist SCHEMA slotlock;
|
|
920
|
+
${DEPLOYMENT_LIFT_FORCED_ROW_SECURITY}${SLOTLOCK_SCHEMA_VERSION_DDL}
|
|
500
921
|
-- Records the exact catalog representation installed for each owned RLS policy. Health checks
|
|
501
922
|
-- compare live policy fingerprints with this contract so ALTER POLICY drift cannot hide behind a
|
|
502
923
|
-- retained name/comment. This table contains configuration only, never tenant or calendar data.
|
|
@@ -552,7 +973,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
|
|
|
552
973
|
),
|
|
553
974
|
CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
554
975
|
resource_id WITH =,
|
|
555
|
-
tstzrange(starts_at, ends_at, '[)') WITH &&
|
|
976
|
+
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
556
977
|
)
|
|
557
978
|
);
|
|
558
979
|
|
|
@@ -565,6 +986,11 @@ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
|
565
986
|
-- Its CHECK holds SLOTLOCK_MAX_AVAILABILITY_RULES as it was when first added: changing the cap
|
|
566
987
|
-- needs a constraint under a new name.
|
|
567
988
|
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS availability_rules jsonb;
|
|
989
|
+
-- Who sees a resource: 'shared' ones are listed to agents and booked through them, 'private' ones
|
|
990
|
+
-- (a tax-deadline calendar, say) only by the tenant's own code. Fixed when the resource is created.
|
|
991
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
|
|
992
|
+
-- When the tenant archived it: listed nowhere and booked no more, its history kept. NULL: in use.
|
|
993
|
+
ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
|
568
994
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
|
|
569
995
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
|
|
570
996
|
ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
|
|
@@ -611,6 +1037,20 @@ CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
|
|
|
611
1037
|
),
|
|
612
1038
|
CONSTRAINT slotlock_reservation_tombstones_revision_valid CHECK (revision > 0)
|
|
613
1039
|
);
|
|
1040
|
+
-- When the reference was retired, so maintenance can prune it past the retention window. Rows from
|
|
1041
|
+
-- before 0.3 read '-infinity', earlier than every time, so none counts as a recent cancellation
|
|
1042
|
+
-- against the tenant's limits; new rows take now(). An ADD COLUMN default is evaluated once and
|
|
1043
|
+
-- kept for the rows already there, with no table rewrite, and SET DEFAULT changes only later
|
|
1044
|
+
-- inserts (PostgreSQL 16, ALTER TABLE notes; Date/Time Types, special values).
|
|
1045
|
+
ALTER TABLE slotlock.reservation_tombstones
|
|
1046
|
+
ADD COLUMN IF NOT EXISTS cancelled_at timestamptz NOT NULL DEFAULT '-infinity';
|
|
1047
|
+
ALTER TABLE slotlock.reservation_tombstones ALTER COLUMN cancelled_at SET DEFAULT now();
|
|
1048
|
+
-- True when the cancel retired a reference no booking held (its create may still be on the way).
|
|
1049
|
+
-- Only those are pruned past the retention window. A cancelled booking's tombstone is kept until
|
|
1050
|
+
-- the tenant is erased, so a delayed or replayed create never books it again; rows from before 0.3
|
|
1051
|
+
-- are kept too.
|
|
1052
|
+
ALTER TABLE slotlock.reservation_tombstones
|
|
1053
|
+
ADD COLUMN IF NOT EXISTS stray boolean NOT NULL DEFAULT false;
|
|
614
1054
|
-- Resource identity: one resource per tenant/external_ref namespace (plus one isolated NULL-tenant
|
|
615
1055
|
-- legacy namespace). Dedupe first (repoint reservations to the keeper, drop the rest) so the
|
|
616
1056
|
-- unique indexes also bind on DBs poisoned by the pre-index
|
|
@@ -749,10 +1189,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_resources_legacy_external_ref_key
|
|
|
749
1189
|
ON slotlock.resources (external_ref)
|
|
750
1190
|
WHERE tenant_ref IS NULL AND external_ref IS NOT NULL;
|
|
751
1191
|
|
|
752
|
-
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_tenant_external_ref_key
|
|
753
|
-
ON slotlock.reservations (tenant_ref, external_ref)
|
|
754
|
-
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL;
|
|
755
|
-
|
|
756
1192
|
DO $$
|
|
757
1193
|
BEGIN
|
|
758
1194
|
IF NOT EXISTS (
|
|
@@ -776,6 +1212,14 @@ BEGIN
|
|
|
776
1212
|
ELSE false END
|
|
777
1213
|
);
|
|
778
1214
|
END IF;
|
|
1215
|
+
IF NOT EXISTS (
|
|
1216
|
+
SELECT 1 FROM pg_constraint
|
|
1217
|
+
WHERE conname = 'slotlock_resources_visibility_valid'
|
|
1218
|
+
AND conrelid = 'slotlock.resources'::regclass
|
|
1219
|
+
) THEN
|
|
1220
|
+
ALTER TABLE slotlock.resources
|
|
1221
|
+
ADD CONSTRAINT slotlock_resources_visibility_valid CHECK (visibility IN ('shared', 'private'));
|
|
1222
|
+
END IF;
|
|
779
1223
|
IF NOT EXISTS (
|
|
780
1224
|
SELECT 1 FROM pg_constraint
|
|
781
1225
|
WHERE conname = 'slotlock_reservations_revision_valid'
|
|
@@ -855,18 +1299,62 @@ BEGIN
|
|
|
855
1299
|
buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
|
|
856
1300
|
) NOT VALID;
|
|
857
1301
|
END IF;
|
|
1302
|
+
END $$;
|
|
1303
|
+
|
|
1304
|
+
-- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
|
|
1305
|
+
-- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
|
|
1306
|
+
-- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
|
|
1307
|
+
-- reaps expired holds in the way, as the next writer would, then refuses to go on while a live row
|
|
1308
|
+
-- is in the way: a constraint is never made to fit by deleting a booking or a live hold. Rows from
|
|
1309
|
+
-- before the occupied-end trigger may lack occupied_ends_at; their end is where they stop.
|
|
1310
|
+
DO $slotlock_occupied_overlap$
|
|
1311
|
+
DECLARE
|
|
1312
|
+
conflicts bigint;
|
|
1313
|
+
examples text;
|
|
1314
|
+
BEGIN
|
|
858
1315
|
IF NOT EXISTS (
|
|
859
1316
|
SELECT 1 FROM pg_constraint
|
|
860
|
-
WHERE conname = '
|
|
1317
|
+
WHERE conname = 'slotlock_reservations_no_overlap'
|
|
861
1318
|
AND conrelid = 'slotlock.reservations'::regclass
|
|
1319
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_ends_at%'
|
|
862
1320
|
) THEN
|
|
1321
|
+
DELETE FROM slotlock.reservations hold
|
|
1322
|
+
USING slotlock.reservations buffered
|
|
1323
|
+
WHERE hold.status = 'held'
|
|
1324
|
+
AND hold.expires_at <= now()
|
|
1325
|
+
AND buffered.resource_id = hold.resource_id
|
|
1326
|
+
AND buffered.id <> hold.id
|
|
1327
|
+
AND buffered.buffer_after_ms > 0
|
|
1328
|
+
AND tstzrange(buffered.starts_at, COALESCE(buffered.occupied_ends_at, buffered.ends_at), '[)')
|
|
1329
|
+
&& tstzrange(hold.starts_at, hold.ends_at, '[)');
|
|
1330
|
+
SELECT count(*),
|
|
1331
|
+
string_agg(buffered.id::text || ' and ' || other.id::text, ', ' ORDER BY buffered.id, other.id)
|
|
1332
|
+
INTO conflicts, examples
|
|
1333
|
+
FROM slotlock.reservations buffered
|
|
1334
|
+
JOIN slotlock.reservations other
|
|
1335
|
+
ON other.resource_id = buffered.resource_id
|
|
1336
|
+
AND other.id <> buffered.id
|
|
1337
|
+
AND tstzrange(buffered.starts_at, COALESCE(buffered.occupied_ends_at, buffered.ends_at), '[)')
|
|
1338
|
+
&& tstzrange(other.starts_at, COALESCE(other.occupied_ends_at, other.ends_at), '[)')
|
|
1339
|
+
WHERE buffered.buffer_after_ms > 0
|
|
1340
|
+
-- A pair of buffered rows counts once.
|
|
1341
|
+
AND (other.buffer_after_ms = 0 OR buffered.id < other.id);
|
|
1342
|
+
IF conflicts > 0 THEN
|
|
1343
|
+
RAISE EXCEPTION 'Slotlock cannot upgrade: % reservations or holds overlap another''s turnaround buffer', conflicts
|
|
1344
|
+
USING ERRCODE = '23P01',
|
|
1345
|
+
DETAIL = 'Reservation ids, in pairs: ' || left(examples, 2000),
|
|
1346
|
+
HINT = 'Move, cancel or release one of each pair (slotlock.reservations), then migrate again.';
|
|
1347
|
+
END IF;
|
|
1348
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
|
|
863
1349
|
ALTER TABLE slotlock.reservations
|
|
864
|
-
ADD CONSTRAINT
|
|
1350
|
+
ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
|
|
865
1351
|
resource_id WITH =,
|
|
866
|
-
tstzrange(starts_at, occupied_ends_at, '[)') WITH &&
|
|
867
|
-
)
|
|
1352
|
+
tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
|
|
1353
|
+
);
|
|
868
1354
|
END IF;
|
|
869
|
-
|
|
1355
|
+
ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
|
|
1356
|
+
END
|
|
1357
|
+
$slotlock_occupied_overlap$;
|
|
870
1358
|
|
|
871
1359
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
|
|
872
1360
|
ON slotlock.reservations (resource_id, starts_at, ends_at);
|
|
@@ -910,12 +1398,13 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_events (
|
|
|
910
1398
|
AND octet_length(external_ref) BETWEEN 1 AND 500
|
|
911
1399
|
),
|
|
912
1400
|
CONSTRAINT slotlock_calendar_events_window_valid CHECK (ends_at > starts_at),
|
|
913
|
-
-- A recurring series expands inside a window of at most
|
|
914
|
-
--
|
|
915
|
-
|
|
1401
|
+
-- A recurring series expands inside a window of at most 369 days (a 367-day search from any
|
|
1402
|
+
-- moment of the day, plus the day before it). A one-off event is its own window, so a lease or
|
|
1403
|
+
-- long rental may span up to 3,660 days (the store's hard ceiling).
|
|
1404
|
+
CONSTRAINT slotlock_calendar_events_materialization_window_limit CHECK (
|
|
916
1405
|
materialized_ends_at > materialized_starts_at
|
|
917
1406
|
AND (
|
|
918
|
-
materialized_ends_at - materialized_starts_at <= interval '
|
|
1407
|
+
materialized_ends_at - materialized_starts_at <= interval '369 days'
|
|
919
1408
|
OR (
|
|
920
1409
|
recurrence_rule IS NULL
|
|
921
1410
|
AND materialized_starts_at = starts_at
|
|
@@ -994,7 +1483,8 @@ DO $slotlock_calendar_event_window_bounds$
|
|
|
994
1483
|
BEGIN
|
|
995
1484
|
IF NOT EXISTS (
|
|
996
1485
|
SELECT 1 FROM pg_constraint
|
|
997
|
-
WHERE conname
|
|
1486
|
+
WHERE conname IN ('slotlock_calendar_events_materialization_window_bounded',
|
|
1487
|
+
'slotlock_calendar_events_materialization_window_limit')
|
|
998
1488
|
AND conrelid = 'slotlock.calendar_events'::regclass
|
|
999
1489
|
) THEN
|
|
1000
1490
|
ALTER TABLE slotlock.calendar_events
|
|
@@ -1016,6 +1506,34 @@ $slotlock_calendar_event_window_bounds$;
|
|
|
1016
1506
|
ALTER TABLE slotlock.calendar_events
|
|
1017
1507
|
DROP CONSTRAINT IF EXISTS slotlock_calendar_events_materialization_window_valid;
|
|
1018
1508
|
|
|
1509
|
+
-- Up to 0.2 a series' window was at most 367 days. The 369-day CHECK admits every row the 367-day
|
|
1510
|
+
-- one did: add it NOT VALID and drop the old one in the same transaction.
|
|
1511
|
+
DO $slotlock_calendar_event_window_limit$
|
|
1512
|
+
BEGIN
|
|
1513
|
+
IF NOT EXISTS (
|
|
1514
|
+
SELECT 1 FROM pg_constraint
|
|
1515
|
+
WHERE conname = 'slotlock_calendar_events_materialization_window_limit'
|
|
1516
|
+
AND conrelid = 'slotlock.calendar_events'::regclass
|
|
1517
|
+
) THEN
|
|
1518
|
+
ALTER TABLE slotlock.calendar_events
|
|
1519
|
+
ADD CONSTRAINT slotlock_calendar_events_materialization_window_limit CHECK (
|
|
1520
|
+
materialized_ends_at > materialized_starts_at
|
|
1521
|
+
AND (
|
|
1522
|
+
materialized_ends_at - materialized_starts_at <= interval '369 days'
|
|
1523
|
+
OR (
|
|
1524
|
+
recurrence_rule IS NULL
|
|
1525
|
+
AND materialized_starts_at = starts_at
|
|
1526
|
+
AND materialized_ends_at = ends_at
|
|
1527
|
+
AND ends_at - starts_at <= interval '3660 days'
|
|
1528
|
+
)
|
|
1529
|
+
)
|
|
1530
|
+
) NOT VALID;
|
|
1531
|
+
END IF;
|
|
1532
|
+
END
|
|
1533
|
+
$slotlock_calendar_event_window_limit$;
|
|
1534
|
+
ALTER TABLE slotlock.calendar_events
|
|
1535
|
+
DROP CONSTRAINT IF EXISTS slotlock_calendar_events_materialization_window_bounded;
|
|
1536
|
+
|
|
1019
1537
|
-- Where a recurring series' last expansion stopped (ical.js's iterator state as JSON), so the next
|
|
1020
1538
|
-- one resumes there instead of searching again from the series' start. Only Slotlock writes it. A
|
|
1021
1539
|
-- cursor that does not fit the row's rule and start is ignored, so NULL, or a cursor left by an
|
|
@@ -1024,6 +1542,14 @@ ALTER TABLE slotlock.calendar_events
|
|
|
1024
1542
|
-- not tie the cursor to a rule: a release from before cursors may turn a series into a one-off and
|
|
1025
1543
|
-- leave its cursor, which is then never read.
|
|
1026
1544
|
ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_cursor jsonb;
|
|
1545
|
+
-- When the horizon roll may next examine a series it could not extend (an occurrence collided, or
|
|
1546
|
+
-- the rule is over the expansion budget): until then it waits behind the series that can roll, so
|
|
1547
|
+
-- a pile of blocked series never starves a healthy one. NULL: due as usual.
|
|
1548
|
+
ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS roll_retry_at timestamptz;
|
|
1549
|
+
-- For a recurring series, a time by which every occurrence ends, moved ones included; 'infinity'
|
|
1550
|
+
-- when its rule has no end, or none Slotlock could follow it to. NULL: not worked out yet (a series
|
|
1551
|
+
-- from before 0.3, until its next roll), and so treated as going on. Unused for a one-off event.
|
|
1552
|
+
ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by timestamptz;
|
|
1027
1553
|
ALTER TABLE slotlock.calendar_events
|
|
1028
1554
|
DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
|
|
1029
1555
|
DO $slotlock_calendar_event_recurrence_cursor$
|
|
@@ -1279,6 +1805,47 @@ BEGIN
|
|
|
1279
1805
|
END IF;
|
|
1280
1806
|
END $$;
|
|
1281
1807
|
|
|
1808
|
+
-- A booking's reference is unique in its tenant. The rows a calendar event occupies carry a
|
|
1809
|
+
-- reference derived from the event in the same columns, but they are not bookings and are left out:
|
|
1810
|
+
-- a booking with the same reference neither collides with one nor answers for it. Up to 0.1 one
|
|
1811
|
+
-- index covered both; every row it admitted, this one admits.
|
|
1812
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_booking_external_ref_key
|
|
1813
|
+
ON slotlock.reservations (tenant_ref, external_ref)
|
|
1814
|
+
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL AND calendar_event_id IS NULL;
|
|
1815
|
+
DROP INDEX IF EXISTS slotlock.slotlock_reservations_tenant_external_ref_key;
|
|
1816
|
+
|
|
1817
|
+
-- Up to 0.2 a confirmed hold, and a createReservation booking, on a tenant's resource kept no tenant
|
|
1818
|
+
-- or reference, so no cancel or move could ever reach it. Each takes its resource's tenant and its
|
|
1819
|
+
-- own id as reference, as both now do when written. A tombstone already holding that reference was
|
|
1820
|
+
-- written by cancelling it as a reference no booking held (the only way to try): it retired nothing
|
|
1821
|
+
-- and is dropped, so the booking can be cancelled. A booking another booking's reference already
|
|
1822
|
+
-- uses (never written by Slotlock) is left as it was.
|
|
1823
|
+
DELETE FROM slotlock.reservation_tombstones tombstone
|
|
1824
|
+
USING slotlock.reservations booking
|
|
1825
|
+
JOIN slotlock.resources resource ON resource.id = booking.resource_id
|
|
1826
|
+
WHERE booking.tenant_ref IS NULL
|
|
1827
|
+
AND booking.status = 'confirmed'
|
|
1828
|
+
AND booking.calendar_event_id IS NULL
|
|
1829
|
+
AND resource.tenant_ref IS NOT NULL
|
|
1830
|
+
AND tombstone.tenant_ref = resource.tenant_ref
|
|
1831
|
+
AND tombstone.external_ref = booking.id::text;
|
|
1832
|
+
UPDATE slotlock.reservations booking
|
|
1833
|
+
SET tenant_ref = resource.tenant_ref,
|
|
1834
|
+
external_ref = booking.id::text,
|
|
1835
|
+
occupied_ends_at = booking.ends_at + booking.buffer_after_ms * interval '1 millisecond'
|
|
1836
|
+
FROM slotlock.resources resource
|
|
1837
|
+
WHERE resource.id = booking.resource_id
|
|
1838
|
+
AND booking.tenant_ref IS NULL
|
|
1839
|
+
AND booking.status = 'confirmed'
|
|
1840
|
+
AND booking.calendar_event_id IS NULL
|
|
1841
|
+
AND resource.tenant_ref IS NOT NULL
|
|
1842
|
+
AND NOT EXISTS (
|
|
1843
|
+
SELECT 1 FROM slotlock.reservations taken
|
|
1844
|
+
WHERE taken.tenant_ref = resource.tenant_ref
|
|
1845
|
+
AND taken.external_ref = booking.id::text
|
|
1846
|
+
AND taken.calendar_event_id IS NULL
|
|
1847
|
+
);
|
|
1848
|
+
|
|
1282
1849
|
-- Materialized occurrences point at reservations created under the SAME exclusion arbiter as
|
|
1283
1850
|
-- bookings and holds. Transparent events retain occurrence visibility with a NULL reservation.
|
|
1284
1851
|
CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
|
|
@@ -1439,6 +2006,68 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_coverage (
|
|
|
1439
2006
|
CONSTRAINT slotlock_calendar_coverage_revision_valid CHECK (revision > 0)
|
|
1440
2007
|
);
|
|
1441
2008
|
|
|
2009
|
+
-- A keyed hold's first answer, by the key its caller gave inside its tenant and principal, so a
|
|
2010
|
+
-- retried request answers the same hold instead of overlapping it. The answer stays as given until
|
|
2011
|
+
-- the hold would have expired, even once it is confirmed, released or reaped, and counts against
|
|
2012
|
+
-- the tenant's hold limit while its hold is not live; then maintenance, or a hold at that limit,
|
|
2013
|
+
-- deletes it (store.ts forgetExpiredHoldAnswers).
|
|
2014
|
+
CREATE TABLE IF NOT EXISTS slotlock.hold_commands (
|
|
2015
|
+
tenant_ref text NOT NULL,
|
|
2016
|
+
principal_ref text NOT NULL,
|
|
2017
|
+
idempotency_key text NOT NULL,
|
|
2018
|
+
payload_hash text NOT NULL,
|
|
2019
|
+
hold_id uuid NOT NULL,
|
|
2020
|
+
resource_id uuid NOT NULL,
|
|
2021
|
+
starts_at timestamptz NOT NULL,
|
|
2022
|
+
ends_at timestamptz NOT NULL,
|
|
2023
|
+
buffer_after_ms bigint NOT NULL,
|
|
2024
|
+
expires_at timestamptz NOT NULL,
|
|
2025
|
+
source text,
|
|
2026
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
2027
|
+
CONSTRAINT slotlock_hold_commands_key PRIMARY KEY (tenant_ref, principal_ref, idempotency_key),
|
|
2028
|
+
CONSTRAINT slotlock_hold_commands_resource_tenant_fkey
|
|
2029
|
+
FOREIGN KEY (resource_id, tenant_ref)
|
|
2030
|
+
REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
|
|
2031
|
+
CONSTRAINT slotlock_hold_commands_identity_valid CHECK (
|
|
2032
|
+
octet_length(tenant_ref) BETWEEN 1 AND 500
|
|
2033
|
+
AND octet_length(principal_ref) BETWEEN 1 AND 500
|
|
2034
|
+
AND octet_length(idempotency_key) BETWEEN 1 AND 500
|
|
2035
|
+
AND octet_length(payload_hash) = 64
|
|
2036
|
+
)
|
|
2037
|
+
);
|
|
2038
|
+
CREATE INDEX IF NOT EXISTS slotlock_hold_commands_expiry_idx
|
|
2039
|
+
ON slotlock.hold_commands (tenant_ref, expires_at);
|
|
2040
|
+
-- References retired with no booking: a tenant's count of them, and maintenance's prune.
|
|
2041
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservation_tombstones_stray_idx
|
|
2042
|
+
ON slotlock.reservation_tombstones (tenant_ref, cancelled_at) WHERE stray;
|
|
2043
|
+
-- Holds by resource and expiry: a tenant's live-hold count, and maintenance's purge of expired ones.
|
|
2044
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservations_held_expiry_idx
|
|
2045
|
+
ON slotlock.reservations (resource_id, expires_at) WHERE status = 'held';
|
|
2046
|
+
-- Confirmed bookings with no tenant, by resource: maintenance finds those a 0.2 server wrote on a
|
|
2047
|
+
-- tenant's resource after the migration (store.ts pruneBookingRetention).
|
|
2048
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservations_untenanted_booking_idx
|
|
2049
|
+
ON slotlock.reservations (resource_id)
|
|
2050
|
+
WHERE tenant_ref IS NULL AND status = 'confirmed' AND calendar_event_id IS NULL;
|
|
2051
|
+
-- What the tenant limits count (store.ts tenantLimitCount), each read as a range so a count stops
|
|
2052
|
+
-- at the limit: bookings and events by when they end (one-offs and series apart) and by when they
|
|
2053
|
+
-- were made, and cancellations by when they were made.
|
|
2054
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservations_booking_end_idx
|
|
2055
|
+
ON slotlock.reservations (tenant_ref, occupied_ends_at)
|
|
2056
|
+
WHERE external_ref IS NOT NULL AND calendar_event_id IS NULL;
|
|
2057
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservations_booking_created_idx
|
|
2058
|
+
ON slotlock.reservations (tenant_ref, created_at)
|
|
2059
|
+
WHERE external_ref IS NOT NULL AND calendar_event_id IS NULL;
|
|
2060
|
+
CREATE INDEX IF NOT EXISTS slotlock_reservation_tombstones_cancelled_idx
|
|
2061
|
+
ON slotlock.reservation_tombstones (tenant_ref, cancelled_at);
|
|
2062
|
+
CREATE INDEX IF NOT EXISTS slotlock_calendar_events_one_off_end_idx
|
|
2063
|
+
ON slotlock.calendar_events (tenant_ref, ends_at) WHERE recurrence_rule IS NULL;
|
|
2064
|
+
CREATE INDEX IF NOT EXISTS slotlock_calendar_events_series_end_idx
|
|
2065
|
+
ON slotlock.calendar_events (tenant_ref, recurrence_ends_by) WHERE recurrence_rule IS NOT NULL;
|
|
2066
|
+
CREATE INDEX IF NOT EXISTS slotlock_calendar_events_created_idx
|
|
2067
|
+
ON slotlock.calendar_events (tenant_ref, created_at);
|
|
2068
|
+
CREATE INDEX IF NOT EXISTS slotlock_calendar_event_tombstones_cancelled_idx
|
|
2069
|
+
ON slotlock.calendar_event_tombstones (tenant_ref, cancelled_at);
|
|
2070
|
+
|
|
1442
2071
|
CREATE INDEX IF NOT EXISTS slotlock_calendar_events_resource_time_idx
|
|
1443
2072
|
ON slotlock.calendar_events (tenant_ref, resource_id, starts_at, ends_at);
|
|
1444
2073
|
CREATE INDEX IF NOT EXISTS slotlock_calendar_events_owner_resource_time_idx
|
|
@@ -1454,11 +2083,19 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
1454
2083
|
ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
|
|
1455
2084
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
1456
2085
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
1457
|
-
${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${
|
|
2086
|
+
${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
2087
|
+
/** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
|
|
2088
|
+
export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
2089
|
+
/**
|
|
2090
|
+
* SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
|
|
2091
|
+
* commits (SLOTLOCK_VALIDATION_DDL).
|
|
2092
|
+
*/
|
|
2093
|
+
export const SLOTLOCK_STRUCTURE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
1458
2094
|
export const SLOTLOCK_TENANT_CONTEXT_SETTING = 'slotlock.tenant_ref';
|
|
1459
2095
|
/**
|
|
1460
2096
|
* Every table the store reads or writes at runtime, each under forced tenant RLS.
|
|
1461
|
-
* `slotlock.rls_policy_contracts`
|
|
2097
|
+
* `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
|
|
2098
|
+
* deployment-only and deliberately absent.
|
|
1462
2099
|
*/
|
|
1463
2100
|
export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
1464
2101
|
'resources',
|
|
@@ -1473,6 +2110,7 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
|
|
|
1473
2110
|
'calendar_event_tombstones',
|
|
1474
2111
|
'calendar_event_commands',
|
|
1475
2112
|
'calendar_coverage',
|
|
2113
|
+
'hold_commands',
|
|
1476
2114
|
]);
|
|
1477
2115
|
const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
1478
2116
|
const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -1508,7 +2146,7 @@ export function createSlotlockTenantRlsDdl(tenantContextSetting = SLOTLOCK_TENAN
|
|
|
1508
2146
|
throw Object.assign(new Error(`Slotlock tenant context setting must be a dotted identifier other than ${CALLER_SEARCH_PATH_SETTING}`), { code: 'invalid_tenant_context_setting' });
|
|
1509
2147
|
}
|
|
1510
2148
|
return `
|
|
1511
|
-
${DEPLOYMENT_SEARCH_PATH_PIN}${
|
|
2149
|
+
${DEPLOYMENT_SEARCH_PATH_PIN}${SLOTLOCK_DEPLOYMENT_CHECKS}ALTER TABLE slotlock.resources ENABLE ROW LEVEL SECURITY;
|
|
1512
2150
|
ALTER TABLE slotlock.resources FORCE ROW LEVEL SECURITY;
|
|
1513
2151
|
ALTER TABLE slotlock.reservations ENABLE ROW LEVEL SECURITY;
|
|
1514
2152
|
ALTER TABLE slotlock.reservations FORCE ROW LEVEL SECURITY;
|
|
@@ -1532,6 +2170,8 @@ ALTER TABLE slotlock.calendar_event_commands ENABLE ROW LEVEL SECURITY;
|
|
|
1532
2170
|
ALTER TABLE slotlock.calendar_event_commands FORCE ROW LEVEL SECURITY;
|
|
1533
2171
|
ALTER TABLE slotlock.calendar_coverage ENABLE ROW LEVEL SECURITY;
|
|
1534
2172
|
ALTER TABLE slotlock.calendar_coverage FORCE ROW LEVEL SECURITY;
|
|
2173
|
+
ALTER TABLE slotlock.hold_commands ENABLE ROW LEVEL SECURITY;
|
|
2174
|
+
ALTER TABLE slotlock.hold_commands FORCE ROW LEVEL SECURITY;
|
|
1535
2175
|
|
|
1536
2176
|
-- One database must have one tenant-context contract. Re-applying with an adapter-specific
|
|
1537
2177
|
-- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
|
|
@@ -1555,7 +2195,8 @@ BEGIN
|
|
|
1555
2195
|
'calendar_event_occurrences',
|
|
1556
2196
|
'calendar_event_tombstones',
|
|
1557
2197
|
'calendar_event_commands',
|
|
1558
|
-
'calendar_coverage'
|
|
2198
|
+
'calendar_coverage',
|
|
2199
|
+
'hold_commands'
|
|
1559
2200
|
)
|
|
1560
2201
|
LOOP
|
|
1561
2202
|
EXECUTE format(
|
|
@@ -1716,6 +2357,16 @@ BEGIN
|
|
|
1716
2357
|
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
1717
2358
|
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
1718
2359
|
END IF;
|
|
2360
|
+
|
|
2361
|
+
IF NOT EXISTS (
|
|
2362
|
+
SELECT 1 FROM pg_policies
|
|
2363
|
+
WHERE schemaname = 'slotlock' AND tablename = 'hold_commands'
|
|
2364
|
+
AND policyname = 'tenant_isolation_v1'
|
|
2365
|
+
) THEN
|
|
2366
|
+
CREATE POLICY tenant_isolation_v1 ON slotlock.hold_commands
|
|
2367
|
+
USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
|
|
2368
|
+
WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
|
|
2369
|
+
END IF;
|
|
1719
2370
|
END
|
|
1720
2371
|
$slotlock_rls$;
|
|
1721
2372
|
|
|
@@ -1743,6 +2394,8 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_event_commands
|
|
|
1743
2394
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
1744
2395
|
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.calendar_coverage
|
|
1745
2396
|
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
2397
|
+
COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_commands
|
|
2398
|
+
IS 'slotlock:tenant-context:${tenantContextSetting}';
|
|
1746
2399
|
|
|
1747
2400
|
INSERT INTO slotlock.rls_policy_contracts (
|
|
1748
2401
|
table_name, tenant_context_setting, policy_fingerprint, installed_at
|
|
@@ -1777,7 +2430,8 @@ WHERE schemaname = 'slotlock'
|
|
|
1777
2430
|
'calendar_event_occurrences',
|
|
1778
2431
|
'calendar_event_tombstones',
|
|
1779
2432
|
'calendar_event_commands',
|
|
1780
|
-
'calendar_coverage'
|
|
2433
|
+
'calendar_coverage',
|
|
2434
|
+
'hold_commands'
|
|
1781
2435
|
)
|
|
1782
2436
|
AND policyname = 'tenant_isolation_v1'
|
|
1783
2437
|
ON CONFLICT (table_name) DO UPDATE
|