slotlock 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/ddl-identity.js
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
// Teams phase 5: a team's verified domains, and joining a team by one (src/domains.ts has the DNS
|
|
2
|
+
// side). Reached only through the SECURITY DEFINER functions below, as the rest of the dashboard's
|
|
3
|
+
// records are: each locks the team's row before it changes the team's people, checks the actor's
|
|
4
|
+
// role itself, and answers with a refusal code rather than an error.
|
|
5
|
+
//
|
|
6
|
+
// A domain a team claims is `pending` until its TXT record is found (seven days at most), then
|
|
7
|
+
// `verified`. It is looked up again every day: absent from both resolvers on two checks a day apart,
|
|
8
|
+
// it is `lapsing` and its token is replaced, so only a record published after the absence restores
|
|
9
|
+
// it; fourteen days later it has `lapsed`, and another team may claim it. At most one team holds a
|
|
10
|
+
// domain at a time. A domain's join policy decides what a person whose verified address is at it may
|
|
11
|
+
// do: ask to join (`request`, the default), join as a Viewer at once (`auto`), or nothing (`off`).
|
|
12
|
+
/**
|
|
13
|
+
* Who a person is, as every dashboard table checks it: a GitHub account, a Google account, or a
|
|
14
|
+
* person a team's identity provider signed in (sso:<uuid>, teams phase 5.4).
|
|
15
|
+
*/
|
|
16
|
+
export const SLOTLOCK_PERSON_PATTERN = '^(github:[1-9][0-9]{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$';
|
|
17
|
+
const PERSON = SLOTLOCK_PERSON_PATTERN;
|
|
18
|
+
/** A domain as normalizeSlotlockDomain keys it: lowercase ASCII labels, two or more. */
|
|
19
|
+
const DOMAIN = '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$';
|
|
20
|
+
/** The tables of verified domains and requests to join: their owner's only. */
|
|
21
|
+
export const SLOTLOCK_IDENTITY_TABLES = Object.freeze(['workspace_domains', 'workspace_join_requests', 'workspace_join_blocks']);
|
|
22
|
+
/** The SECURITY DEFINER functions through which the serving role reaches them. */
|
|
23
|
+
export const SLOTLOCK_IDENTITY_FUNCTIONS = Object.freeze([
|
|
24
|
+
'slotlock.claim_domain(text, text, text, text, text)',
|
|
25
|
+
'slotlock.workspace_domains_list(text)',
|
|
26
|
+
'slotlock.record_domain_check(uuid, text, text, text, text, text)',
|
|
27
|
+
'slotlock.domains_due(integer)',
|
|
28
|
+
'slotlock.release_domain(text, text, uuid)',
|
|
29
|
+
'slotlock.set_invite_domain_restriction(text, text, boolean)',
|
|
30
|
+
'slotlock.set_domain_join_policy(text, text, uuid, text)',
|
|
31
|
+
'slotlock.discoverable_workspaces(text, text[])',
|
|
32
|
+
'slotlock.request_to_join(text, text, text[], text, integer)',
|
|
33
|
+
'slotlock.workspace_join_requests_list(text)',
|
|
34
|
+
'slotlock.decide_join_request(text, text, uuid, text, text, integer)',
|
|
35
|
+
'slotlock.workspace_domain_members(text)',
|
|
36
|
+
'slotlock.prune_workspace_domains()',
|
|
37
|
+
'slotlock.workspace_invites_restricted(text)',
|
|
38
|
+
]);
|
|
39
|
+
/** How many domains a team may claim or hold at once. */
|
|
40
|
+
export const SLOTLOCK_WORKSPACE_DOMAIN_LIMIT = 20;
|
|
41
|
+
/**
|
|
42
|
+
* How a person's address was verified, as a request to join records it: by a Google Workspace
|
|
43
|
+
* account of that domain (Google's `hd` claim), the only evidence that lets someone join at once;
|
|
44
|
+
* or by a Google or GitHub account that verified it once and never checks again, which only asks.
|
|
45
|
+
*/
|
|
46
|
+
export const SLOTLOCK_JOIN_EVIDENCE = Object.freeze(['google_workspace', 'google', 'github']);
|
|
47
|
+
/**
|
|
48
|
+
* SQL that is true when a team holds its invitations to its verified domains and the invitation
|
|
49
|
+
* (to `email`, or to the GitHub account `github`) is not to one of them.
|
|
50
|
+
*/
|
|
51
|
+
export const SLOTLOCK_INVITE_DOMAIN_REFUSED = (tenant, email, github) => `(coalesce((SELECT ws.invites_domain_restricted FROM slotlock.workspaces ws WHERE ws.tenant_ref = ${tenant}), false)
|
|
52
|
+
AND (${github} IS NOT NULL OR ${email} IS NULL OR NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM slotlock.workspace_domains claim
|
|
54
|
+
WHERE claim.tenant_ref = ${tenant} AND claim.status IN ('verified', 'lapsing')
|
|
55
|
+
AND lower(substring(${email} FROM '@([^@]+)$')) IN (claim.domain_ascii, claim.domain_unicode))))`;
|
|
56
|
+
/** Requests to join a person may make a day, across every team. */
|
|
57
|
+
export const SLOTLOCK_JOIN_REQUEST_DAILY_LIMIT = 20;
|
|
58
|
+
const fn = SLOTLOCK_IDENTITY_FUNCTIONS;
|
|
59
|
+
/** The addresses given, as `local@ascii-domain` lowercase: the domains an address is matched on. */
|
|
60
|
+
const EMAIL_DOMAINS = (emails) => `(SELECT lower(substring(btrim(given) FROM '@([^@]+)$')) FROM unnest(coalesce(${emails}, ARRAY[]::text[])) given)`;
|
|
61
|
+
export const SLOTLOCK_IDENTITY_DDL = `
|
|
62
|
+
-- A team's claim on a domain, and the domain once verified. The token is public by design (it is
|
|
63
|
+
-- published in DNS): it is unique to the claim, so one team's record never verifies another's.
|
|
64
|
+
CREATE TABLE IF NOT EXISTS slotlock.workspace_domains (
|
|
65
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
66
|
+
tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
|
|
67
|
+
domain_ascii text NOT NULL,
|
|
68
|
+
domain_unicode text NOT NULL,
|
|
69
|
+
status text NOT NULL DEFAULT 'pending',
|
|
70
|
+
token text NOT NULL,
|
|
71
|
+
join_policy text NOT NULL DEFAULT 'request',
|
|
72
|
+
created_by text,
|
|
73
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
74
|
+
verified_at timestamptz,
|
|
75
|
+
last_checked_at timestamptz,
|
|
76
|
+
last_seen_at timestamptz,
|
|
77
|
+
missing_since timestamptz,
|
|
78
|
+
error_since timestamptz,
|
|
79
|
+
lapse_after timestamptz,
|
|
80
|
+
ended_at timestamptz,
|
|
81
|
+
CONSTRAINT slotlock_workspace_domains_valid CHECK (
|
|
82
|
+
char_length(domain_ascii) BETWEEN 3 AND 253
|
|
83
|
+
AND domain_ascii ~ '${DOMAIN}'
|
|
84
|
+
AND char_length(domain_unicode) BETWEEN 3 AND 253
|
|
85
|
+
AND domain_unicode !~ '[[:space:][:cntrl:]@/<>]'
|
|
86
|
+
AND status IN ('pending', 'verified', 'lapsing', 'lapsed', 'released')
|
|
87
|
+
AND token ~ '^[a-z2-7]{26}$'
|
|
88
|
+
AND join_policy IN ('off', 'request', 'auto')
|
|
89
|
+
AND (created_by IS NULL OR created_by ~ '${PERSON}')
|
|
90
|
+
AND (status NOT IN ('verified', 'lapsing') OR verified_at IS NOT NULL)
|
|
91
|
+
AND (status = 'lapsing') = (lapse_after IS NOT NULL)
|
|
92
|
+
AND (status IN ('lapsed', 'released')) = (ended_at IS NOT NULL)
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
-- One team holds a domain at a time; a team claims each once.
|
|
96
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_workspace_domains_held_key
|
|
97
|
+
ON slotlock.workspace_domains (domain_ascii) WHERE status IN ('verified', 'lapsing');
|
|
98
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_workspace_domains_claim_key
|
|
99
|
+
ON slotlock.workspace_domains (tenant_ref, domain_ascii) WHERE status IN ('pending', 'verified', 'lapsing');
|
|
100
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_domains_due_idx
|
|
101
|
+
ON slotlock.workspace_domains (last_checked_at NULLS FIRST) WHERE status IN ('pending', 'verified', 'lapsing');
|
|
102
|
+
ALTER TABLE slotlock.workspace_domains ENABLE ROW LEVEL SECURITY;
|
|
103
|
+
REVOKE ALL ON slotlock.workspace_domains FROM PUBLIC;
|
|
104
|
+
|
|
105
|
+
-- Invitations only to addresses at the team's verified domains, when its Owner says so.
|
|
106
|
+
ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS invites_domain_restricted boolean NOT NULL DEFAULT false;
|
|
107
|
+
|
|
108
|
+
-- A membership a domain gave (asked for and approved, or joined at once), and through which domain;
|
|
109
|
+
-- and the address a membership came by (a domain's, or an invitation's), to remember a removal by.
|
|
110
|
+
ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS joined_via_domain uuid
|
|
111
|
+
REFERENCES slotlock.workspace_domains (id) ON DELETE SET NULL;
|
|
112
|
+
ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS joined_email text
|
|
113
|
+
CONSTRAINT slotlock_workspace_memberships_joined_email_valid
|
|
114
|
+
CHECK (joined_email IS NULL OR (char_length(joined_email) BETWEEN 3 AND 254 AND joined_email = lower(joined_email)));
|
|
115
|
+
DO $slotlock_memberships_sources$
|
|
116
|
+
BEGIN
|
|
117
|
+
IF NOT EXISTS (
|
|
118
|
+
SELECT 1 FROM pg_constraint
|
|
119
|
+
WHERE conname = 'slotlock_workspace_memberships_valid' AND conrelid = 'slotlock.workspace_memberships'::regclass
|
|
120
|
+
AND pg_get_constraintdef(oid) LIKE '%''scim''%'
|
|
121
|
+
) THEN
|
|
122
|
+
ALTER TABLE slotlock.workspace_memberships DROP CONSTRAINT IF EXISTS slotlock_workspace_memberships_valid;
|
|
123
|
+
ALTER TABLE slotlock.workspace_memberships ADD CONSTRAINT slotlock_workspace_memberships_valid CHECK (
|
|
124
|
+
tenant_ref ~ '^(github-org:[1-9][0-9]{0,19}|team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
|
|
125
|
+
AND person_ref ~ '${PERSON}'
|
|
126
|
+
AND role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
|
|
127
|
+
AND status IN ('active', 'inactive')
|
|
128
|
+
AND source IN ('github_org', 'invitation', 'created', 'domain', 'sso', 'scim')
|
|
129
|
+
AND (status = 'active') = (deactivated_at IS NULL)
|
|
130
|
+
);
|
|
131
|
+
END IF;
|
|
132
|
+
END
|
|
133
|
+
$slotlock_memberships_sources$;
|
|
134
|
+
|
|
135
|
+
-- A person's request to join a team, by an address they hold verified at one of its domains. It
|
|
136
|
+
-- waits seven days for an Owner or Admin, who picks the role (Viewer, Member or Developer).
|
|
137
|
+
CREATE TABLE IF NOT EXISTS slotlock.workspace_join_requests (
|
|
138
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
139
|
+
tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
|
|
140
|
+
domain_id uuid NOT NULL REFERENCES slotlock.workspace_domains (id) ON DELETE CASCADE,
|
|
141
|
+
person_ref text NOT NULL,
|
|
142
|
+
email text NOT NULL,
|
|
143
|
+
status text NOT NULL DEFAULT 'pending',
|
|
144
|
+
role text,
|
|
145
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
146
|
+
expires_at timestamptz NOT NULL,
|
|
147
|
+
decided_by text,
|
|
148
|
+
decided_at timestamptz,
|
|
149
|
+
CONSTRAINT slotlock_workspace_join_requests_valid CHECK (
|
|
150
|
+
person_ref ~ '${PERSON}'
|
|
151
|
+
AND char_length(email) BETWEEN 3 AND 254
|
|
152
|
+
AND email ~ '^[^@[:space:][:cntrl:]<>]+@[^@[:space:][:cntrl:]<>]+$'
|
|
153
|
+
AND status IN ('pending', 'approved', 'declined', 'expired')
|
|
154
|
+
AND (role IS NULL OR role IN ('developer', 'member', 'viewer'))
|
|
155
|
+
AND (status = 'approved') = (role IS NOT NULL)
|
|
156
|
+
AND (decided_by IS NULL OR decided_by ~ '${PERSON}')
|
|
157
|
+
AND expires_at > created_at
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
ALTER TABLE slotlock.workspace_join_requests ADD COLUMN IF NOT EXISTS evidence text NOT NULL DEFAULT 'github'
|
|
161
|
+
CONSTRAINT slotlock_workspace_join_requests_evidence_valid CHECK (evidence IN ('google_workspace', 'google', 'github'));
|
|
162
|
+
CREATE UNIQUE INDEX IF NOT EXISTS slotlock_workspace_join_requests_pending_key
|
|
163
|
+
ON slotlock.workspace_join_requests (tenant_ref, person_ref) WHERE status = 'pending';
|
|
164
|
+
CREATE INDEX IF NOT EXISTS slotlock_workspace_join_requests_person_idx
|
|
165
|
+
ON slotlock.workspace_join_requests (person_ref, created_at);
|
|
166
|
+
ALTER TABLE slotlock.workspace_join_requests ENABLE ROW LEVEL SECURITY;
|
|
167
|
+
REVOKE ALL ON slotlock.workspace_join_requests FROM PUBLIC;
|
|
168
|
+
|
|
169
|
+
-- Addresses a team removed or declined, by their SHA-256 only: whoever holds one asks again rather
|
|
170
|
+
-- than joining at once, under any account and after erasing the one they had. A team's record of its
|
|
171
|
+
-- own decision: it goes with the team, or a year after it was made, or when a request is approved.
|
|
172
|
+
CREATE TABLE IF NOT EXISTS slotlock.workspace_join_blocks (
|
|
173
|
+
tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
|
|
174
|
+
email_sha256 bytea NOT NULL,
|
|
175
|
+
created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
|
176
|
+
CONSTRAINT slotlock_workspace_join_blocks_pkey PRIMARY KEY (tenant_ref, email_sha256),
|
|
177
|
+
CONSTRAINT slotlock_workspace_join_blocks_valid CHECK (octet_length(email_sha256) = 32)
|
|
178
|
+
);
|
|
179
|
+
ALTER TABLE slotlock.workspace_join_blocks ENABLE ROW LEVEL SECURITY;
|
|
180
|
+
REVOKE ALL ON slotlock.workspace_join_blocks FROM PUBLIC;
|
|
181
|
+
|
|
182
|
+
-- Claim a domain for a team, by its Owner, with a new token. A claim already pending past its seven
|
|
183
|
+
-- days is claimed afresh (a new token); one the team claimed or holds already is refused.
|
|
184
|
+
-- The caller has normalised and checked the domain (public suffixes, consumer mail); the shape is
|
|
185
|
+
-- checked again here.
|
|
186
|
+
CREATE OR REPLACE FUNCTION slotlock.claim_domain(
|
|
187
|
+
requested_tenant text, requested_actor text, requested_ascii text, requested_unicode text, requested_token text)
|
|
188
|
+
RETURNS TABLE (claimed_id uuid, refusal text)
|
|
189
|
+
LANGUAGE plpgsql
|
|
190
|
+
VOLATILE
|
|
191
|
+
SECURITY DEFINER
|
|
192
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
193
|
+
AS $slotlock_claim_domain$
|
|
194
|
+
DECLARE
|
|
195
|
+
actor_role text;
|
|
196
|
+
existing slotlock.workspace_domains%ROWTYPE;
|
|
197
|
+
created uuid;
|
|
198
|
+
BEGIN
|
|
199
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
200
|
+
IF NOT FOUND THEN
|
|
201
|
+
RETURN QUERY SELECT NULL::uuid, 'not_found'::text;
|
|
202
|
+
RETURN;
|
|
203
|
+
END IF;
|
|
204
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
205
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
206
|
+
IF actor_role IS DISTINCT FROM 'owner' THEN
|
|
207
|
+
RETURN QUERY SELECT NULL::uuid, 'not_allowed'::text;
|
|
208
|
+
RETURN;
|
|
209
|
+
END IF;
|
|
210
|
+
IF requested_ascii IS NULL OR char_length(requested_ascii) NOT BETWEEN 3 AND 253 OR requested_ascii !~ '${DOMAIN}'
|
|
211
|
+
OR requested_unicode IS NULL OR char_length(requested_unicode) NOT BETWEEN 3 AND 253
|
|
212
|
+
OR requested_unicode ~ '[[:space:][:cntrl:]@/<>]' OR requested_token IS NULL OR requested_token !~ '^[a-z2-7]{26}$' THEN
|
|
213
|
+
RETURN QUERY SELECT NULL::uuid, 'invalid_domain'::text;
|
|
214
|
+
RETURN;
|
|
215
|
+
END IF;
|
|
216
|
+
-- Whether another team holds it is not said here, where anyone with a team could ask of any
|
|
217
|
+
-- domain: only to a team that publishes its own record (record_domain_check's domain_taken).
|
|
218
|
+
SELECT * INTO existing FROM slotlock.workspace_domains claim
|
|
219
|
+
WHERE claim.tenant_ref = requested_tenant AND claim.domain_ascii = requested_ascii
|
|
220
|
+
AND claim.status IN ('pending', 'verified', 'lapsing')
|
|
221
|
+
FOR UPDATE;
|
|
222
|
+
IF FOUND THEN
|
|
223
|
+
IF existing.status = 'pending' AND existing.created_at <= statement_timestamp() - interval '7 days' THEN
|
|
224
|
+
UPDATE slotlock.workspace_domains claim
|
|
225
|
+
SET token = requested_token, domain_unicode = requested_unicode, created_by = requested_actor,
|
|
226
|
+
created_at = statement_timestamp(), last_checked_at = NULL, error_since = NULL
|
|
227
|
+
WHERE claim.id = existing.id;
|
|
228
|
+
RETURN QUERY SELECT existing.id, NULL::text;
|
|
229
|
+
RETURN;
|
|
230
|
+
END IF;
|
|
231
|
+
RETURN QUERY SELECT existing.id, 'already_claimed'::text;
|
|
232
|
+
RETURN;
|
|
233
|
+
END IF;
|
|
234
|
+
IF (SELECT count(*) FROM slotlock.workspace_domains claim
|
|
235
|
+
WHERE claim.tenant_ref = requested_tenant AND claim.status IN ('pending', 'verified', 'lapsing'))
|
|
236
|
+
>= ${SLOTLOCK_WORKSPACE_DOMAIN_LIMIT} THEN
|
|
237
|
+
RETURN QUERY SELECT NULL::uuid, 'domain_limit'::text;
|
|
238
|
+
RETURN;
|
|
239
|
+
END IF;
|
|
240
|
+
INSERT INTO slotlock.workspace_domains (tenant_ref, domain_ascii, domain_unicode, token, created_by)
|
|
241
|
+
VALUES (requested_tenant, requested_ascii, requested_unicode, requested_token, requested_actor)
|
|
242
|
+
RETURNING id INTO created;
|
|
243
|
+
RETURN QUERY SELECT created, NULL::text;
|
|
244
|
+
END
|
|
245
|
+
$slotlock_claim_domain$;
|
|
246
|
+
REVOKE ALL ON FUNCTION ${fn[0]} FROM PUBLIC;
|
|
247
|
+
|
|
248
|
+
-- A team's domains as its Identity page shows them: claims pending (or past their seven days),
|
|
249
|
+
-- verified and lapsing ones. The token is public: it is the record the team publishes.
|
|
250
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_domains_list(requested_tenant text)
|
|
251
|
+
RETURNS TABLE (domain_id uuid, domain_ascii text, domain_unicode text, status text, token text,
|
|
252
|
+
join_policy text, created_at timestamptz, verified_at timestamptz,
|
|
253
|
+
last_checked_at timestamptz, missing_since timestamptz, error_since timestamptz,
|
|
254
|
+
lapse_after timestamptz, expired boolean)
|
|
255
|
+
LANGUAGE sql
|
|
256
|
+
STABLE
|
|
257
|
+
SECURITY DEFINER
|
|
258
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
259
|
+
AS $slotlock_workspace_domains_list$
|
|
260
|
+
SELECT claim.id, claim.domain_ascii, claim.domain_unicode, claim.status, claim.token, claim.join_policy,
|
|
261
|
+
claim.created_at, claim.verified_at, claim.last_checked_at, claim.missing_since, claim.error_since,
|
|
262
|
+
claim.lapse_after,
|
|
263
|
+
claim.status = 'pending' AND claim.created_at <= statement_timestamp() - interval '7 days'
|
|
264
|
+
FROM slotlock.workspace_domains claim
|
|
265
|
+
WHERE claim.tenant_ref = requested_tenant AND claim.status IN ('pending', 'verified', 'lapsing')
|
|
266
|
+
ORDER BY claim.domain_ascii;
|
|
267
|
+
$slotlock_workspace_domains_list$;
|
|
268
|
+
REVOKE ALL ON FUNCTION ${fn[1]} FROM PUBLIC;
|
|
269
|
+
|
|
270
|
+
-- What a lookup of a claim's record found, applied: by the daily check (no actor), or by an Owner's
|
|
271
|
+
-- "Verify now" (at most once a minute). requested_checked_token is the token the lookup asked for:
|
|
272
|
+
-- if the claim's has changed since, nothing changes. requested_primary is the first resolver's
|
|
273
|
+
-- verdict, requested_secondary the second's (null with one resolver). requested_next_token replaces
|
|
274
|
+
-- the token when the domain starts to lapse. Returns what happened, and the claim's team and domain
|
|
275
|
+
-- for the audit log.
|
|
276
|
+
CREATE OR REPLACE FUNCTION slotlock.record_domain_check(
|
|
277
|
+
requested_domain uuid, requested_actor text, requested_checked_token text, requested_primary text,
|
|
278
|
+
requested_secondary text, requested_next_token text)
|
|
279
|
+
RETURNS TABLE (outcome text, team_ref text, domain_name text)
|
|
280
|
+
LANGUAGE plpgsql
|
|
281
|
+
VOLATILE
|
|
282
|
+
SECURITY DEFINER
|
|
283
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
284
|
+
AS $slotlock_record_domain_check$
|
|
285
|
+
DECLARE
|
|
286
|
+
claim slotlock.workspace_domains%ROWTYPE;
|
|
287
|
+
actor_role text;
|
|
288
|
+
both_absent boolean;
|
|
289
|
+
BEGIN
|
|
290
|
+
IF requested_primary IS NULL OR requested_primary NOT IN ('present', 'absent', 'error')
|
|
291
|
+
OR (requested_secondary IS NOT NULL AND requested_secondary NOT IN ('present', 'absent', 'error')) THEN
|
|
292
|
+
RAISE EXCEPTION 'Slotlock domains: a verdict is present, absent or error' USING ERRCODE = '22023';
|
|
293
|
+
END IF;
|
|
294
|
+
SELECT * INTO claim FROM slotlock.workspace_domains found WHERE found.id = requested_domain FOR UPDATE;
|
|
295
|
+
IF NOT FOUND OR claim.status NOT IN ('pending', 'verified', 'lapsing') THEN
|
|
296
|
+
RETURN QUERY SELECT 'not_found'::text, NULL::text, NULL::text;
|
|
297
|
+
RETURN;
|
|
298
|
+
END IF;
|
|
299
|
+
IF requested_actor IS NOT NULL THEN
|
|
300
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
301
|
+
WHERE member.tenant_ref = claim.tenant_ref AND member.person_ref = requested_actor AND member.status = 'active';
|
|
302
|
+
IF actor_role IS DISTINCT FROM 'owner' THEN
|
|
303
|
+
RETURN QUERY SELECT 'not_allowed'::text, NULL::text, NULL::text;
|
|
304
|
+
RETURN;
|
|
305
|
+
END IF;
|
|
306
|
+
IF claim.last_checked_at > statement_timestamp() - interval '1 minute' THEN
|
|
307
|
+
RETURN QUERY SELECT 'too_soon'::text, claim.tenant_ref, claim.domain_ascii;
|
|
308
|
+
RETURN;
|
|
309
|
+
END IF;
|
|
310
|
+
END IF;
|
|
311
|
+
IF claim.status = 'pending' AND claim.created_at <= statement_timestamp() - interval '7 days' THEN
|
|
312
|
+
RETURN QUERY SELECT 'expired'::text, claim.tenant_ref, claim.domain_ascii;
|
|
313
|
+
RETURN;
|
|
314
|
+
END IF;
|
|
315
|
+
IF requested_checked_token IS DISTINCT FROM claim.token THEN
|
|
316
|
+
RETURN QUERY SELECT 'unchanged'::text, claim.tenant_ref, claim.domain_ascii;
|
|
317
|
+
RETURN;
|
|
318
|
+
END IF;
|
|
319
|
+
UPDATE slotlock.workspace_domains found SET last_checked_at = statement_timestamp() WHERE found.id = claim.id;
|
|
320
|
+
both_absent := requested_primary = 'absent' AND coalesce(requested_secondary, 'absent') = 'absent';
|
|
321
|
+
|
|
322
|
+
IF claim.status = 'pending' THEN
|
|
323
|
+
IF requested_primary = 'present' THEN
|
|
324
|
+
BEGIN
|
|
325
|
+
UPDATE slotlock.workspace_domains found
|
|
326
|
+
SET status = 'verified', verified_at = statement_timestamp(), last_seen_at = statement_timestamp(),
|
|
327
|
+
missing_since = NULL, error_since = NULL
|
|
328
|
+
WHERE found.id = claim.id;
|
|
329
|
+
EXCEPTION WHEN unique_violation THEN
|
|
330
|
+
-- Another team verified it first: the claim stays pending, and says only that it is taken.
|
|
331
|
+
RETURN QUERY SELECT 'domain_taken'::text, claim.tenant_ref, claim.domain_ascii;
|
|
332
|
+
RETURN;
|
|
333
|
+
END;
|
|
334
|
+
RETURN QUERY SELECT 'verified'::text, claim.tenant_ref, claim.domain_ascii;
|
|
335
|
+
RETURN;
|
|
336
|
+
END IF;
|
|
337
|
+
UPDATE slotlock.workspace_domains found
|
|
338
|
+
SET error_since = CASE WHEN requested_primary = 'error' THEN coalesce(found.error_since, statement_timestamp()) END
|
|
339
|
+
WHERE found.id = claim.id;
|
|
340
|
+
RETURN QUERY SELECT CASE WHEN requested_primary = 'error' THEN 'error' ELSE 'absent' END, claim.tenant_ref, claim.domain_ascii;
|
|
341
|
+
RETURN;
|
|
342
|
+
END IF;
|
|
343
|
+
|
|
344
|
+
IF claim.status = 'verified' THEN
|
|
345
|
+
IF requested_primary = 'present' THEN
|
|
346
|
+
UPDATE slotlock.workspace_domains found
|
|
347
|
+
SET last_seen_at = statement_timestamp(), missing_since = NULL, error_since = NULL
|
|
348
|
+
WHERE found.id = claim.id;
|
|
349
|
+
RETURN QUERY SELECT 'seen'::text, claim.tenant_ref, claim.domain_ascii;
|
|
350
|
+
RETURN;
|
|
351
|
+
END IF;
|
|
352
|
+
IF both_absent THEN
|
|
353
|
+
IF claim.missing_since IS NULL THEN
|
|
354
|
+
UPDATE slotlock.workspace_domains found SET missing_since = statement_timestamp(), error_since = NULL
|
|
355
|
+
WHERE found.id = claim.id;
|
|
356
|
+
RETURN QUERY SELECT 'missing'::text, claim.tenant_ref, claim.domain_ascii;
|
|
357
|
+
RETURN;
|
|
358
|
+
END IF;
|
|
359
|
+
IF claim.missing_since <= statement_timestamp() - interval '1 day' THEN
|
|
360
|
+
IF requested_next_token IS NULL OR requested_next_token !~ '^[a-z2-7]{26}$' OR requested_next_token = claim.token THEN
|
|
361
|
+
RAISE EXCEPTION 'Slotlock domains: a lapsing domain needs a new token' USING ERRCODE = '22023';
|
|
362
|
+
END IF;
|
|
363
|
+
-- Gone a day: lapsing, with a new token. The old one is never accepted again, so a record
|
|
364
|
+
-- copied from DNS history by whoever holds the domain next restores nothing.
|
|
365
|
+
UPDATE slotlock.workspace_domains found
|
|
366
|
+
SET status = 'lapsing', token = requested_next_token, lapse_after = statement_timestamp() + interval '14 days',
|
|
367
|
+
error_since = NULL
|
|
368
|
+
WHERE found.id = claim.id;
|
|
369
|
+
RETURN QUERY SELECT 'lapsing'::text, claim.tenant_ref, claim.domain_ascii;
|
|
370
|
+
RETURN;
|
|
371
|
+
END IF;
|
|
372
|
+
RETURN QUERY SELECT 'missing'::text, claim.tenant_ref, claim.domain_ascii;
|
|
373
|
+
RETURN;
|
|
374
|
+
END IF;
|
|
375
|
+
IF requested_primary = 'error' OR requested_secondary = 'error' THEN
|
|
376
|
+
-- Fourteen days of nothing but errors (a zone deleted, its delegation dangling): lapsing, with
|
|
377
|
+
-- a new token, as if the record had gone, so whoever takes the delegation over cannot revive it.
|
|
378
|
+
IF claim.error_since IS NOT NULL AND claim.error_since <= statement_timestamp() - interval '14 days'
|
|
379
|
+
AND requested_primary <> 'present' AND coalesce(requested_secondary, 'error') <> 'present' THEN
|
|
380
|
+
IF requested_next_token IS NULL OR requested_next_token !~ '^[a-z2-7]{26}$' OR requested_next_token = claim.token THEN
|
|
381
|
+
RAISE EXCEPTION 'Slotlock domains: a lapsing domain needs a new token' USING ERRCODE = '22023';
|
|
382
|
+
END IF;
|
|
383
|
+
UPDATE slotlock.workspace_domains found
|
|
384
|
+
SET status = 'lapsing', token = requested_next_token, lapse_after = statement_timestamp() + interval '14 days'
|
|
385
|
+
WHERE found.id = claim.id;
|
|
386
|
+
RETURN QUERY SELECT 'lapsing'::text, claim.tenant_ref, claim.domain_ascii;
|
|
387
|
+
RETURN;
|
|
388
|
+
END IF;
|
|
389
|
+
UPDATE slotlock.workspace_domains found SET error_since = coalesce(found.error_since, statement_timestamp())
|
|
390
|
+
WHERE found.id = claim.id;
|
|
391
|
+
RETURN QUERY SELECT 'error'::text, claim.tenant_ref, claim.domain_ascii;
|
|
392
|
+
RETURN;
|
|
393
|
+
END IF;
|
|
394
|
+
-- One resolver has it and the other not: nothing changes.
|
|
395
|
+
RETURN QUERY SELECT 'unchanged'::text, claim.tenant_ref, claim.domain_ascii;
|
|
396
|
+
RETURN;
|
|
397
|
+
END IF;
|
|
398
|
+
|
|
399
|
+
-- Lapsing: the new token's record restores it; fourteen days without, it has lapsed.
|
|
400
|
+
IF requested_primary = 'present' THEN
|
|
401
|
+
UPDATE slotlock.workspace_domains found
|
|
402
|
+
SET status = 'verified', lapse_after = NULL, missing_since = NULL, error_since = NULL,
|
|
403
|
+
last_seen_at = statement_timestamp()
|
|
404
|
+
WHERE found.id = claim.id;
|
|
405
|
+
RETURN QUERY SELECT 'restored'::text, claim.tenant_ref, claim.domain_ascii;
|
|
406
|
+
RETURN;
|
|
407
|
+
END IF;
|
|
408
|
+
-- Past its fourteen days it lapses only on what lapsed it: both resolvers without the record, or
|
|
409
|
+
-- fourteen days of nothing but errors. One resolver failing, or one still finding it, waits.
|
|
410
|
+
IF requested_primary = 'error' OR requested_secondary = 'error' THEN
|
|
411
|
+
UPDATE slotlock.workspace_domains found SET error_since = coalesce(found.error_since, statement_timestamp())
|
|
412
|
+
WHERE found.id = claim.id;
|
|
413
|
+
ELSE
|
|
414
|
+
UPDATE slotlock.workspace_domains found SET error_since = NULL WHERE found.id = claim.id;
|
|
415
|
+
END IF;
|
|
416
|
+
IF claim.lapse_after <= statement_timestamp()
|
|
417
|
+
AND (both_absent
|
|
418
|
+
OR (requested_primary <> 'present' AND coalesce(requested_secondary, 'error') <> 'present'
|
|
419
|
+
AND claim.error_since IS NOT NULL AND claim.error_since <= statement_timestamp() - interval '14 days')) THEN
|
|
420
|
+
UPDATE slotlock.workspace_domains found
|
|
421
|
+
SET status = 'lapsed', lapse_after = NULL, error_since = NULL, ended_at = statement_timestamp()
|
|
422
|
+
WHERE found.id = claim.id;
|
|
423
|
+
UPDATE slotlock.workspace_join_requests pending SET status = 'expired'
|
|
424
|
+
WHERE pending.domain_id = claim.id AND pending.status = 'pending';
|
|
425
|
+
RETURN QUERY SELECT 'lapsed'::text, claim.tenant_ref, claim.domain_ascii;
|
|
426
|
+
RETURN;
|
|
427
|
+
END IF;
|
|
428
|
+
RETURN QUERY SELECT CASE WHEN requested_primary = 'error' THEN 'error' ELSE 'unchanged' END, claim.tenant_ref, claim.domain_ascii;
|
|
429
|
+
END
|
|
430
|
+
$slotlock_record_domain_check$;
|
|
431
|
+
REVOKE ALL ON FUNCTION ${fn[2]} FROM PUBLIC;
|
|
432
|
+
|
|
433
|
+
-- The claims to look up now, oldest check first: pending ones hourly for their first three days
|
|
434
|
+
-- (after that, only "Verify now"), verified and lapsing ones daily, and lapsing ones past their
|
|
435
|
+
-- fourteen days at once. Teams being deleted are left alone.
|
|
436
|
+
CREATE OR REPLACE FUNCTION slotlock.domains_due(requested_limit integer)
|
|
437
|
+
RETURNS TABLE (domain_id uuid, team_ref text, domain_ascii text, token text, status text, missing_since timestamptz)
|
|
438
|
+
LANGUAGE sql
|
|
439
|
+
STABLE
|
|
440
|
+
SECURITY DEFINER
|
|
441
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
442
|
+
AS $slotlock_domains_due$
|
|
443
|
+
SELECT claim.id, claim.tenant_ref, claim.domain_ascii, claim.token, claim.status, claim.missing_since
|
|
444
|
+
FROM slotlock.workspace_domains claim
|
|
445
|
+
JOIN slotlock.workspaces ws ON ws.tenant_ref = claim.tenant_ref AND ws.status = 'active'
|
|
446
|
+
WHERE (claim.status = 'pending' AND claim.created_at > statement_timestamp() - interval '72 hours'
|
|
447
|
+
AND (claim.last_checked_at IS NULL OR claim.last_checked_at <= statement_timestamp() - interval '50 minutes'))
|
|
448
|
+
OR (claim.status IN ('verified', 'lapsing')
|
|
449
|
+
AND (claim.last_checked_at IS NULL OR claim.last_checked_at <= statement_timestamp() - interval '1 day'))
|
|
450
|
+
OR (claim.status = 'lapsing' AND claim.lapse_after <= statement_timestamp())
|
|
451
|
+
ORDER BY claim.last_checked_at NULLS FIRST
|
|
452
|
+
LIMIT greatest(1, least(coalesce(requested_limit, 50), 500));
|
|
453
|
+
$slotlock_domains_due$;
|
|
454
|
+
REVOKE ALL ON FUNCTION ${fn[3]} FROM PUBLIC;
|
|
455
|
+
|
|
456
|
+
-- Give up a domain (or withdraw a claim), by the team's Owner. Not the last verified domain while
|
|
457
|
+
-- invitations are held to them: the Owner turns that off first.
|
|
458
|
+
CREATE OR REPLACE FUNCTION slotlock.release_domain(requested_tenant text, requested_actor text, requested_domain uuid)
|
|
459
|
+
RETURNS text
|
|
460
|
+
LANGUAGE plpgsql
|
|
461
|
+
VOLATILE
|
|
462
|
+
SECURITY DEFINER
|
|
463
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
464
|
+
AS $slotlock_release_domain$
|
|
465
|
+
DECLARE
|
|
466
|
+
actor_role text;
|
|
467
|
+
restricted boolean;
|
|
468
|
+
claim slotlock.workspace_domains%ROWTYPE;
|
|
469
|
+
BEGIN
|
|
470
|
+
SELECT ws.invites_domain_restricted INTO restricted FROM slotlock.workspaces ws
|
|
471
|
+
WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
472
|
+
IF NOT FOUND THEN
|
|
473
|
+
RETURN 'not_found';
|
|
474
|
+
END IF;
|
|
475
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
476
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
477
|
+
IF actor_role IS DISTINCT FROM 'owner' THEN
|
|
478
|
+
RETURN 'not_allowed';
|
|
479
|
+
END IF;
|
|
480
|
+
SELECT * INTO claim FROM slotlock.workspace_domains found
|
|
481
|
+
WHERE found.id = requested_domain AND found.tenant_ref = requested_tenant
|
|
482
|
+
AND found.status IN ('pending', 'verified', 'lapsing')
|
|
483
|
+
FOR UPDATE;
|
|
484
|
+
IF NOT FOUND THEN
|
|
485
|
+
RETURN 'not_found';
|
|
486
|
+
END IF;
|
|
487
|
+
IF restricted AND claim.status IN ('verified', 'lapsing') AND NOT EXISTS (
|
|
488
|
+
SELECT 1 FROM slotlock.workspace_domains other
|
|
489
|
+
WHERE other.tenant_ref = requested_tenant AND other.id <> claim.id AND other.status IN ('verified', 'lapsing')
|
|
490
|
+
) THEN
|
|
491
|
+
RETURN 'restricted';
|
|
492
|
+
END IF;
|
|
493
|
+
UPDATE slotlock.workspace_domains found
|
|
494
|
+
SET status = 'released', lapse_after = NULL, ended_at = statement_timestamp()
|
|
495
|
+
WHERE found.id = claim.id;
|
|
496
|
+
UPDATE slotlock.workspace_join_requests pending SET status = 'expired'
|
|
497
|
+
WHERE pending.domain_id = claim.id AND pending.status = 'pending';
|
|
498
|
+
RETURN NULL;
|
|
499
|
+
END
|
|
500
|
+
$slotlock_release_domain$;
|
|
501
|
+
REVOKE ALL ON FUNCTION ${fn[4]} FROM PUBLIC;
|
|
502
|
+
|
|
503
|
+
-- Hold invitations to addresses at the team's verified domains, or stop: by its Owner, and only
|
|
504
|
+
-- with a verified domain to hold them to. Turning it on withdraws the open invitations it refuses.
|
|
505
|
+
CREATE OR REPLACE FUNCTION slotlock.set_invite_domain_restriction(requested_tenant text, requested_actor text, requested_on boolean)
|
|
506
|
+
RETURNS text
|
|
507
|
+
LANGUAGE plpgsql
|
|
508
|
+
VOLATILE
|
|
509
|
+
SECURITY DEFINER
|
|
510
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
511
|
+
AS $slotlock_set_invite_domain_restriction$
|
|
512
|
+
DECLARE
|
|
513
|
+
actor_role text;
|
|
514
|
+
BEGIN
|
|
515
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
516
|
+
IF NOT FOUND THEN
|
|
517
|
+
RETURN 'not_found';
|
|
518
|
+
END IF;
|
|
519
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
520
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
521
|
+
IF actor_role IS DISTINCT FROM 'owner' THEN
|
|
522
|
+
RETURN 'not_allowed';
|
|
523
|
+
END IF;
|
|
524
|
+
IF requested_on AND NOT EXISTS (
|
|
525
|
+
SELECT 1 FROM slotlock.workspace_domains claim
|
|
526
|
+
WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified'
|
|
527
|
+
) THEN
|
|
528
|
+
RETURN 'no_verified_domain';
|
|
529
|
+
END IF;
|
|
530
|
+
UPDATE slotlock.workspaces ws SET invites_domain_restricted = coalesce(requested_on, false)
|
|
531
|
+
WHERE ws.tenant_ref = requested_tenant;
|
|
532
|
+
-- Held from now on, invitations already open to anyone else are withdrawn too.
|
|
533
|
+
IF requested_on THEN
|
|
534
|
+
UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
|
|
535
|
+
WHERE invitation.tenant_ref = requested_tenant AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
|
|
536
|
+
AND ${SLOTLOCK_INVITE_DOMAIN_REFUSED('requested_tenant', 'invitation.email', 'invitation.github_ref')};
|
|
537
|
+
END IF;
|
|
538
|
+
RETURN NULL;
|
|
539
|
+
END
|
|
540
|
+
$slotlock_set_invite_domain_restriction$;
|
|
541
|
+
REVOKE ALL ON FUNCTION ${fn[5]} FROM PUBLIC;
|
|
542
|
+
|
|
543
|
+
-- What a domain lets the people at it do: by the team's Owner. Turning it off withdraws the
|
|
544
|
+
-- requests waiting through it.
|
|
545
|
+
CREATE OR REPLACE FUNCTION slotlock.set_domain_join_policy(
|
|
546
|
+
requested_tenant text, requested_actor text, requested_domain uuid, requested_policy text)
|
|
547
|
+
RETURNS text
|
|
548
|
+
LANGUAGE plpgsql
|
|
549
|
+
VOLATILE
|
|
550
|
+
SECURITY DEFINER
|
|
551
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
552
|
+
AS $slotlock_set_domain_join_policy$
|
|
553
|
+
DECLARE
|
|
554
|
+
actor_role text;
|
|
555
|
+
BEGIN
|
|
556
|
+
IF requested_policy IS NULL OR requested_policy NOT IN ('off', 'request', 'auto') THEN
|
|
557
|
+
RAISE EXCEPTION 'Slotlock domains: a join policy is off, request or auto' USING ERRCODE = '22023';
|
|
558
|
+
END IF;
|
|
559
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
560
|
+
IF NOT FOUND THEN
|
|
561
|
+
RETURN 'not_found';
|
|
562
|
+
END IF;
|
|
563
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
564
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
565
|
+
IF actor_role IS DISTINCT FROM 'owner' THEN
|
|
566
|
+
RETURN 'not_allowed';
|
|
567
|
+
END IF;
|
|
568
|
+
UPDATE slotlock.workspace_domains claim SET join_policy = requested_policy
|
|
569
|
+
WHERE claim.id = requested_domain AND claim.tenant_ref = requested_tenant
|
|
570
|
+
AND claim.status IN ('pending', 'verified', 'lapsing');
|
|
571
|
+
IF NOT FOUND THEN
|
|
572
|
+
RETURN 'not_found';
|
|
573
|
+
END IF;
|
|
574
|
+
IF requested_policy = 'off' THEN
|
|
575
|
+
UPDATE slotlock.workspace_join_requests pending SET status = 'expired'
|
|
576
|
+
WHERE pending.domain_id = requested_domain AND pending.status = 'pending';
|
|
577
|
+
END IF;
|
|
578
|
+
RETURN NULL;
|
|
579
|
+
END
|
|
580
|
+
$slotlock_set_domain_join_policy$;
|
|
581
|
+
REVOKE ALL ON FUNCTION ${fn[6]} FROM PUBLIC;
|
|
582
|
+
|
|
583
|
+
-- The teams a person may ask to join (or join): those with a verified domain, open to joining, that
|
|
584
|
+
-- one of their verified addresses is at, exactly. Name and handle only, and whether they asked.
|
|
585
|
+
CREATE OR REPLACE FUNCTION slotlock.discoverable_workspaces(requested_person text, requested_emails text[])
|
|
586
|
+
RETURNS TABLE (team_ref text, handle text, name text, domain text, domain_key text, join_policy text, requested boolean)
|
|
587
|
+
LANGUAGE sql
|
|
588
|
+
STABLE
|
|
589
|
+
SECURITY DEFINER
|
|
590
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
591
|
+
AS $slotlock_discoverable_workspaces$
|
|
592
|
+
SELECT ws.tenant_ref, ws.handle, ws.name, claim.domain_unicode, claim.domain_ascii, claim.join_policy,
|
|
593
|
+
EXISTS (SELECT 1 FROM slotlock.workspace_join_requests asked
|
|
594
|
+
WHERE asked.tenant_ref = ws.tenant_ref AND asked.person_ref = requested_person
|
|
595
|
+
AND asked.status = 'pending' AND asked.expires_at > statement_timestamp())
|
|
596
|
+
FROM slotlock.workspace_domains claim
|
|
597
|
+
JOIN slotlock.workspaces ws ON ws.tenant_ref = claim.tenant_ref AND ws.status = 'active'
|
|
598
|
+
WHERE requested_person ~ '${PERSON}'
|
|
599
|
+
AND claim.status = 'verified' AND claim.join_policy IN ('request', 'auto')
|
|
600
|
+
AND claim.domain_ascii IN ${EMAIL_DOMAINS('requested_emails')}
|
|
601
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
602
|
+
WHERE member.tenant_ref = ws.tenant_ref AND member.person_ref = requested_person
|
|
603
|
+
AND member.status = 'active')
|
|
604
|
+
ORDER BY ws.name, ws.handle
|
|
605
|
+
LIMIT 20;
|
|
606
|
+
$slotlock_discoverable_workspaces$;
|
|
607
|
+
REVOKE ALL ON FUNCTION ${fn[7]} FROM PUBLIC;
|
|
608
|
+
|
|
609
|
+
-- Ask to join a team by a verified address at one of its domains: joined at once as a Viewer where
|
|
610
|
+
-- the domain says so, the address was verified by an account of that domain (a Google Workspace
|
|
611
|
+
-- account: evidence google_workspace), the person was never a member and the address was never
|
|
612
|
+
-- removed or declined (someone removed asks again, under any account); else a request that waits
|
|
613
|
+
-- seven days. Every case the person may not see answers not_found.
|
|
614
|
+
CREATE OR REPLACE FUNCTION slotlock.request_to_join(
|
|
615
|
+
requested_tenant text, requested_person text, requested_emails text[], requested_evidence text,
|
|
616
|
+
requested_member_cap integer)
|
|
617
|
+
RETURNS TABLE (outcome text, refusal text)
|
|
618
|
+
LANGUAGE plpgsql
|
|
619
|
+
VOLATILE
|
|
620
|
+
SECURITY DEFINER
|
|
621
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
622
|
+
AS $slotlock_request_to_join$
|
|
623
|
+
DECLARE
|
|
624
|
+
matched slotlock.workspace_domains%ROWTYPE;
|
|
625
|
+
matched_email text;
|
|
626
|
+
occupied integer;
|
|
627
|
+
BEGIN
|
|
628
|
+
IF requested_person IS NULL OR requested_person !~ '${PERSON}' THEN
|
|
629
|
+
RETURN QUERY SELECT NULL::text, 'not_found'::text;
|
|
630
|
+
RETURN;
|
|
631
|
+
END IF;
|
|
632
|
+
IF requested_evidence IS NULL OR requested_evidence NOT IN ('google_workspace', 'google', 'github') THEN
|
|
633
|
+
RAISE EXCEPTION 'Slotlock teams: evidence is google_workspace, google or github' USING ERRCODE = '22023';
|
|
634
|
+
END IF;
|
|
635
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
636
|
+
IF NOT FOUND THEN
|
|
637
|
+
RETURN QUERY SELECT NULL::text, 'not_found'::text;
|
|
638
|
+
RETURN;
|
|
639
|
+
END IF;
|
|
640
|
+
SELECT * INTO matched FROM slotlock.workspace_domains claim
|
|
641
|
+
WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified' AND claim.join_policy IN ('request', 'auto')
|
|
642
|
+
AND claim.domain_ascii IN ${EMAIL_DOMAINS('requested_emails')}
|
|
643
|
+
ORDER BY claim.join_policy = 'auto' DESC, claim.domain_ascii
|
|
644
|
+
LIMIT 1;
|
|
645
|
+
IF NOT FOUND THEN
|
|
646
|
+
RETURN QUERY SELECT NULL::text, 'not_found'::text;
|
|
647
|
+
RETURN;
|
|
648
|
+
END IF;
|
|
649
|
+
SELECT lower(btrim(given)) INTO matched_email FROM unnest(requested_emails) given
|
|
650
|
+
WHERE lower(substring(btrim(given) FROM '@([^@]+)$')) = matched.domain_ascii
|
|
651
|
+
LIMIT 1;
|
|
652
|
+
IF EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
653
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active') THEN
|
|
654
|
+
RETURN QUERY SELECT NULL::text, 'already_member'::text;
|
|
655
|
+
RETURN;
|
|
656
|
+
END IF;
|
|
657
|
+
UPDATE slotlock.workspace_join_requests stale SET status = 'expired'
|
|
658
|
+
WHERE stale.tenant_ref = requested_tenant AND stale.person_ref = requested_person
|
|
659
|
+
AND stale.status = 'pending' AND stale.expires_at <= statement_timestamp();
|
|
660
|
+
IF matched.join_policy = 'auto' AND requested_evidence = 'google_workspace'
|
|
661
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
662
|
+
WHERE member.tenant_ref = requested_tenant
|
|
663
|
+
AND (member.person_ref = requested_person OR member.joined_email = matched_email))
|
|
664
|
+
AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_join_blocks blocked
|
|
665
|
+
WHERE blocked.tenant_ref = requested_tenant
|
|
666
|
+
AND blocked.email_sha256 = sha256(convert_to(matched_email, 'UTF8'))) THEN
|
|
667
|
+
IF requested_member_cap IS NOT NULL THEN
|
|
668
|
+
SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
|
|
669
|
+
WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
|
|
670
|
+
+ (SELECT count(*) FROM slotlock.workspace_invitations pending
|
|
671
|
+
WHERE pending.tenant_ref = requested_tenant AND pending.accepted_at IS NULL
|
|
672
|
+
AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp())
|
|
673
|
+
INTO occupied;
|
|
674
|
+
IF occupied >= requested_member_cap THEN
|
|
675
|
+
RETURN QUERY SELECT NULL::text, 'member_cap'::text;
|
|
676
|
+
RETURN;
|
|
677
|
+
END IF;
|
|
678
|
+
END IF;
|
|
679
|
+
INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source, joined_via_domain, joined_email)
|
|
680
|
+
VALUES (requested_tenant, requested_person, 'viewer', 'domain', matched.id, matched_email);
|
|
681
|
+
UPDATE slotlock.workspace_join_requests asked SET status = 'expired'
|
|
682
|
+
WHERE asked.tenant_ref = requested_tenant AND asked.person_ref = requested_person AND asked.status = 'pending';
|
|
683
|
+
RETURN QUERY SELECT 'joined'::text, NULL::text;
|
|
684
|
+
RETURN;
|
|
685
|
+
END IF;
|
|
686
|
+
IF EXISTS (SELECT 1 FROM slotlock.workspace_join_requests asked
|
|
687
|
+
WHERE asked.tenant_ref = requested_tenant AND asked.person_ref = requested_person AND asked.status = 'pending') THEN
|
|
688
|
+
RETURN QUERY SELECT NULL::text, 'pending'::text;
|
|
689
|
+
RETURN;
|
|
690
|
+
END IF;
|
|
691
|
+
IF (SELECT count(*) FROM slotlock.workspace_join_requests asked
|
|
692
|
+
WHERE asked.person_ref = requested_person AND asked.created_at > statement_timestamp() - interval '1 day')
|
|
693
|
+
>= ${SLOTLOCK_JOIN_REQUEST_DAILY_LIMIT} THEN
|
|
694
|
+
RETURN QUERY SELECT NULL::text, 'request_limit'::text;
|
|
695
|
+
RETURN;
|
|
696
|
+
END IF;
|
|
697
|
+
INSERT INTO slotlock.workspace_join_requests (tenant_ref, domain_id, person_ref, email, evidence, expires_at)
|
|
698
|
+
VALUES (requested_tenant, matched.id, requested_person, matched_email, requested_evidence,
|
|
699
|
+
statement_timestamp() + interval '7 days');
|
|
700
|
+
RETURN QUERY SELECT 'requested'::text, NULL::text;
|
|
701
|
+
END
|
|
702
|
+
$slotlock_request_to_join$;
|
|
703
|
+
REVOKE ALL ON FUNCTION ${fn[8]} FROM PUBLIC;
|
|
704
|
+
|
|
705
|
+
-- The requests waiting on a team: who asked (as they are shown), by which address, through which domain.
|
|
706
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_join_requests_list(requested_tenant text)
|
|
707
|
+
RETURNS TABLE (request_id uuid, person_ref text, display text, email text, domain text,
|
|
708
|
+
created_at timestamptz, expires_at timestamptz, evidence text)
|
|
709
|
+
LANGUAGE sql
|
|
710
|
+
STABLE
|
|
711
|
+
SECURITY DEFINER
|
|
712
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
713
|
+
AS $slotlock_workspace_join_requests_list$
|
|
714
|
+
SELECT asked.id, asked.person_ref, person.display, asked.email, claim.domain_unicode, asked.created_at, asked.expires_at,
|
|
715
|
+
asked.evidence
|
|
716
|
+
FROM slotlock.workspace_join_requests asked
|
|
717
|
+
JOIN slotlock.workspace_domains claim ON claim.id = asked.domain_id
|
|
718
|
+
LEFT JOIN slotlock.dashboard_people person ON person.person_ref = asked.person_ref
|
|
719
|
+
WHERE asked.tenant_ref = requested_tenant AND asked.status = 'pending' AND asked.expires_at > statement_timestamp()
|
|
720
|
+
ORDER BY asked.created_at
|
|
721
|
+
LIMIT 200;
|
|
722
|
+
$slotlock_workspace_join_requests_list$;
|
|
723
|
+
REVOKE ALL ON FUNCTION ${fn[9]} FROM PUBLIC;
|
|
724
|
+
|
|
725
|
+
-- Approve a request (as Viewer, Member or Developer, never more) or decline it: by an Owner or an
|
|
726
|
+
-- Admin who may give the role, while the domain it came through is still verified.
|
|
727
|
+
CREATE OR REPLACE FUNCTION slotlock.decide_join_request(
|
|
728
|
+
requested_tenant text, requested_actor text, requested_request uuid, requested_decision text,
|
|
729
|
+
requested_role text, requested_member_cap integer)
|
|
730
|
+
RETURNS TABLE (refusal text, joined_person text)
|
|
731
|
+
LANGUAGE plpgsql
|
|
732
|
+
VOLATILE
|
|
733
|
+
SECURITY DEFINER
|
|
734
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
735
|
+
AS $slotlock_decide_join_request$
|
|
736
|
+
DECLARE
|
|
737
|
+
actor_role text;
|
|
738
|
+
asked slotlock.workspace_join_requests%ROWTYPE;
|
|
739
|
+
occupied integer;
|
|
740
|
+
BEGIN
|
|
741
|
+
IF requested_decision IS NULL OR requested_decision NOT IN ('approve', 'decline') THEN
|
|
742
|
+
RAISE EXCEPTION 'Slotlock teams: a decision is approve or decline' USING ERRCODE = '22023';
|
|
743
|
+
END IF;
|
|
744
|
+
PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
|
|
745
|
+
IF NOT FOUND THEN
|
|
746
|
+
RETURN QUERY SELECT 'not_found'::text, NULL::text;
|
|
747
|
+
RETURN;
|
|
748
|
+
END IF;
|
|
749
|
+
SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
|
|
750
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
|
|
751
|
+
IF actor_role IS NULL OR actor_role NOT IN ('owner', 'admin') THEN
|
|
752
|
+
RETURN QUERY SELECT 'not_allowed'::text, NULL::text;
|
|
753
|
+
RETURN;
|
|
754
|
+
END IF;
|
|
755
|
+
SELECT * INTO asked FROM slotlock.workspace_join_requests found
|
|
756
|
+
WHERE found.id = requested_request AND found.tenant_ref = requested_tenant AND found.status = 'pending'
|
|
757
|
+
FOR UPDATE;
|
|
758
|
+
IF NOT FOUND THEN
|
|
759
|
+
RETURN QUERY SELECT 'not_found'::text, NULL::text;
|
|
760
|
+
RETURN;
|
|
761
|
+
END IF;
|
|
762
|
+
IF asked.expires_at <= statement_timestamp() THEN
|
|
763
|
+
UPDATE slotlock.workspace_join_requests found SET status = 'expired' WHERE found.id = asked.id;
|
|
764
|
+
RETURN QUERY SELECT 'expired'::text, asked.person_ref;
|
|
765
|
+
RETURN;
|
|
766
|
+
END IF;
|
|
767
|
+
IF requested_decision = 'decline' THEN
|
|
768
|
+
UPDATE slotlock.workspace_join_requests found
|
|
769
|
+
SET status = 'declined', decided_by = requested_actor, decided_at = statement_timestamp()
|
|
770
|
+
WHERE found.id = asked.id;
|
|
771
|
+
INSERT INTO slotlock.workspace_join_blocks (tenant_ref, email_sha256)
|
|
772
|
+
VALUES (requested_tenant, sha256(convert_to(asked.email, 'UTF8')))
|
|
773
|
+
ON CONFLICT ON CONSTRAINT slotlock_workspace_join_blocks_pkey DO NOTHING;
|
|
774
|
+
RETURN QUERY SELECT NULL::text, asked.person_ref;
|
|
775
|
+
RETURN;
|
|
776
|
+
END IF;
|
|
777
|
+
IF requested_role IS NULL OR requested_role NOT IN ('viewer', 'member', 'developer') THEN
|
|
778
|
+
RETURN QUERY SELECT 'role_not_allowed'::text, asked.person_ref;
|
|
779
|
+
RETURN;
|
|
780
|
+
END IF;
|
|
781
|
+
IF NOT slotlock.workspace_may_grant(actor_role, requested_role) THEN
|
|
782
|
+
RETURN QUERY SELECT 'not_allowed'::text, asked.person_ref;
|
|
783
|
+
RETURN;
|
|
784
|
+
END IF;
|
|
785
|
+
IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_domains claim
|
|
786
|
+
WHERE claim.id = asked.domain_id AND claim.status = 'verified' AND claim.join_policy <> 'off') THEN
|
|
787
|
+
RETURN QUERY SELECT 'domain_not_verified'::text, asked.person_ref;
|
|
788
|
+
RETURN;
|
|
789
|
+
END IF;
|
|
790
|
+
IF EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
|
|
791
|
+
WHERE member.tenant_ref = requested_tenant AND member.person_ref = asked.person_ref AND member.status = 'active') THEN
|
|
792
|
+
UPDATE slotlock.workspace_join_requests found SET status = 'expired' WHERE found.id = asked.id;
|
|
793
|
+
RETURN QUERY SELECT 'already_member'::text, asked.person_ref;
|
|
794
|
+
RETURN;
|
|
795
|
+
END IF;
|
|
796
|
+
IF requested_member_cap IS NOT NULL THEN
|
|
797
|
+
SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
|
|
798
|
+
WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
|
|
799
|
+
+ (SELECT count(*) FROM slotlock.workspace_invitations pending
|
|
800
|
+
WHERE pending.tenant_ref = requested_tenant AND pending.accepted_at IS NULL
|
|
801
|
+
AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp())
|
|
802
|
+
INTO occupied;
|
|
803
|
+
IF occupied >= requested_member_cap THEN
|
|
804
|
+
RETURN QUERY SELECT 'member_cap'::text, asked.person_ref;
|
|
805
|
+
RETURN;
|
|
806
|
+
END IF;
|
|
807
|
+
END IF;
|
|
808
|
+
INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source, joined_via_domain, joined_email)
|
|
809
|
+
VALUES (requested_tenant, asked.person_ref, requested_role, 'domain', asked.domain_id, asked.email)
|
|
810
|
+
ON CONFLICT ON CONSTRAINT slotlock_workspace_memberships_pkey DO UPDATE
|
|
811
|
+
SET role = excluded.role, status = 'active', source = 'domain', joined_via_domain = excluded.joined_via_domain, deactivated_by = NULL,
|
|
812
|
+
joined_email = excluded.joined_email, deactivated_at = NULL, updated_at = statement_timestamp();
|
|
813
|
+
-- Let in by a person who decided: the address is no longer one the team turned away.
|
|
814
|
+
DELETE FROM slotlock.workspace_join_blocks blocked
|
|
815
|
+
WHERE blocked.tenant_ref = requested_tenant AND blocked.email_sha256 = sha256(convert_to(asked.email, 'UTF8'));
|
|
816
|
+
UPDATE slotlock.workspace_join_requests found
|
|
817
|
+
SET status = 'approved', role = requested_role, decided_by = requested_actor, decided_at = statement_timestamp()
|
|
818
|
+
WHERE found.id = asked.id;
|
|
819
|
+
RETURN QUERY SELECT NULL::text, asked.person_ref;
|
|
820
|
+
END
|
|
821
|
+
$slotlock_decide_join_request$;
|
|
822
|
+
REVOKE ALL ON FUNCTION ${fn[10]} FROM PUBLIC;
|
|
823
|
+
|
|
824
|
+
-- Members a domain let in, with that domain and its standing now: a lapsed domain's people stay,
|
|
825
|
+
-- shown as joined through a domain the team no longer holds.
|
|
826
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_domain_members(requested_tenant text)
|
|
827
|
+
RETURNS TABLE (person_ref text, domain text, domain_status text)
|
|
828
|
+
LANGUAGE sql
|
|
829
|
+
STABLE
|
|
830
|
+
SECURITY DEFINER
|
|
831
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
832
|
+
AS $slotlock_workspace_domain_members$
|
|
833
|
+
SELECT member.person_ref, claim.domain_unicode, claim.status
|
|
834
|
+
FROM slotlock.workspace_memberships member
|
|
835
|
+
JOIN slotlock.workspace_domains claim ON claim.id = member.joined_via_domain
|
|
836
|
+
WHERE member.tenant_ref = requested_tenant AND member.status = 'active' AND member.source = 'domain';
|
|
837
|
+
$slotlock_workspace_domain_members$;
|
|
838
|
+
REVOKE ALL ON FUNCTION ${fn[11]} FROM PUBLIC;
|
|
839
|
+
|
|
840
|
+
-- Requests decided or expired 30 days ago, with the address they came by, deleted; claims that ended
|
|
841
|
+
-- (lapsed, released, or pending past their seven days) 90 days ago deleted; removals and refusals
|
|
842
|
+
-- remembered a year. How many went.
|
|
843
|
+
CREATE OR REPLACE FUNCTION slotlock.prune_workspace_domains()
|
|
844
|
+
RETURNS integer
|
|
845
|
+
LANGUAGE plpgsql
|
|
846
|
+
VOLATILE
|
|
847
|
+
SECURITY DEFINER
|
|
848
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
849
|
+
AS $slotlock_prune_workspace_domains$
|
|
850
|
+
DECLARE
|
|
851
|
+
requests integer;
|
|
852
|
+
claims integer;
|
|
853
|
+
blocks integer;
|
|
854
|
+
BEGIN
|
|
855
|
+
DELETE FROM slotlock.workspace_join_requests asked
|
|
856
|
+
WHERE (asked.status <> 'pending' AND coalesce(asked.decided_at, asked.expires_at) < statement_timestamp() - interval '30 days')
|
|
857
|
+
OR (asked.status = 'pending' AND asked.expires_at < statement_timestamp() - interval '30 days');
|
|
858
|
+
GET DIAGNOSTICS requests = ROW_COUNT;
|
|
859
|
+
DELETE FROM slotlock.workspace_domains claim
|
|
860
|
+
WHERE (claim.status IN ('lapsed', 'released') AND claim.ended_at < statement_timestamp() - interval '90 days')
|
|
861
|
+
OR (claim.status = 'pending' AND claim.created_at < statement_timestamp() - interval '97 days');
|
|
862
|
+
GET DIAGNOSTICS claims = ROW_COUNT;
|
|
863
|
+
DELETE FROM slotlock.workspace_join_blocks blocked WHERE blocked.created_at < statement_timestamp() - interval '365 days';
|
|
864
|
+
GET DIAGNOSTICS blocks = ROW_COUNT;
|
|
865
|
+
RETURN requests + claims + blocks;
|
|
866
|
+
END
|
|
867
|
+
$slotlock_prune_workspace_domains$;
|
|
868
|
+
REVOKE ALL ON FUNCTION ${fn[12]} FROM PUBLIC;
|
|
869
|
+
|
|
870
|
+
-- Whether a team's invitations are held to its verified domains.
|
|
871
|
+
CREATE OR REPLACE FUNCTION slotlock.workspace_invites_restricted(requested_tenant text)
|
|
872
|
+
RETURNS boolean
|
|
873
|
+
LANGUAGE sql
|
|
874
|
+
STABLE
|
|
875
|
+
SECURITY DEFINER
|
|
876
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
877
|
+
AS $slotlock_workspace_invites_restricted$
|
|
878
|
+
SELECT coalesce((SELECT ws.invites_domain_restricted FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant), false);
|
|
879
|
+
$slotlock_workspace_invites_restricted$;
|
|
880
|
+
REVOKE ALL ON FUNCTION ${fn[13]} FROM PUBLIC;
|
|
881
|
+
`;
|