@team-semicolon/semicolony-cli 4.18.59 → 4.18.60
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.
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
-- 167_worker_credential_operator_boundary.sql
|
|
2
|
+
--
|
|
3
|
+
-- Worker credential issuance is an owner-controlled operation. The CLI runs
|
|
4
|
+
-- through the non-owner sc_app role, so direct DML against worker_devices and
|
|
5
|
+
-- gateway_credentials deliberately fails. Keep the narrow operation here
|
|
6
|
+
-- rather than widening table grants or exposing credential hashes.
|
|
7
|
+
--
|
|
8
|
+
-- The migration runner supplies BEGIN/COMMIT. All object references are
|
|
9
|
+
-- schema-qualified and the SECURITY DEFINER search path is fixed.
|
|
10
|
+
|
|
11
|
+
CREATE OR REPLACE FUNCTION semicolony.issue_worker_gateway_credential(
|
|
12
|
+
p_team_tenant_id uuid,
|
|
13
|
+
p_worker_id text,
|
|
14
|
+
p_device_id text,
|
|
15
|
+
p_token_hash text,
|
|
16
|
+
p_token_prefix text,
|
|
17
|
+
p_scopes text[],
|
|
18
|
+
p_issued_by text,
|
|
19
|
+
p_expires_days integer,
|
|
20
|
+
p_workspace_grants jsonb
|
|
21
|
+
)
|
|
22
|
+
RETURNS uuid
|
|
23
|
+
LANGUAGE plpgsql
|
|
24
|
+
SECURITY DEFINER
|
|
25
|
+
SET search_path = pg_catalog
|
|
26
|
+
AS $function$
|
|
27
|
+
DECLARE
|
|
28
|
+
v_credential_id uuid;
|
|
29
|
+
v_previous_credential_id uuid;
|
|
30
|
+
v_workspace_grants jsonb := coalesce(p_workspace_grants, '[]'::jsonb);
|
|
31
|
+
v_grant jsonb;
|
|
32
|
+
v_project_id text;
|
|
33
|
+
v_capability text;
|
|
34
|
+
v_environments text[];
|
|
35
|
+
v_grant_key text;
|
|
36
|
+
v_seen_grant_keys text[] := ARRAY[]::text[];
|
|
37
|
+
v_has_credential_grant boolean := false;
|
|
38
|
+
v_has_asset_grant boolean := false;
|
|
39
|
+
BEGIN
|
|
40
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
41
|
+
OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
|
|
42
|
+
RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
|
|
43
|
+
END IF;
|
|
44
|
+
IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' THEN
|
|
45
|
+
RAISE EXCEPTION 'invalid worker credential token hash' USING ERRCODE = '22023';
|
|
46
|
+
END IF;
|
|
47
|
+
IF p_token_prefix IS NULL
|
|
48
|
+
OR p_token_prefix !~ ('^wck_' || p_worker_id || '_[A-Za-z0-9_-]{6}$') THEN
|
|
49
|
+
RAISE EXCEPTION 'invalid worker credential token prefix' USING ERRCODE = '22023';
|
|
50
|
+
END IF;
|
|
51
|
+
IF p_issued_by IS NULL OR p_issued_by <> btrim(p_issued_by)
|
|
52
|
+
OR btrim(p_issued_by) = '' OR char_length(p_issued_by) > 128
|
|
53
|
+
OR p_issued_by ~ '[[:cntrl:]]' THEN
|
|
54
|
+
RAISE EXCEPTION 'invalid credential issuer' USING ERRCODE = '22023';
|
|
55
|
+
END IF;
|
|
56
|
+
IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
|
|
57
|
+
RAISE EXCEPTION 'worker credential expiry must be between 1 and 90 days' USING ERRCODE = '22023';
|
|
58
|
+
END IF;
|
|
59
|
+
IF p_scopes IS NULL OR cardinality(p_scopes) = 0
|
|
60
|
+
OR EXISTS (
|
|
61
|
+
SELECT 1
|
|
62
|
+
FROM unnest(p_scopes) AS requested(scope)
|
|
63
|
+
WHERE requested.scope IS NULL
|
|
64
|
+
OR requested.scope <> ALL (ARRAY[
|
|
65
|
+
'kb:read',
|
|
66
|
+
'skills:read',
|
|
67
|
+
'skills:write',
|
|
68
|
+
'workspace:credentials-read',
|
|
69
|
+
'workspace:assets-read'
|
|
70
|
+
]::text[])
|
|
71
|
+
)
|
|
72
|
+
OR cardinality(p_scopes) <> (
|
|
73
|
+
SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
|
|
74
|
+
)
|
|
75
|
+
OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
|
|
76
|
+
RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
|
|
77
|
+
END IF;
|
|
78
|
+
IF jsonb_typeof(v_workspace_grants) <> 'array' THEN
|
|
79
|
+
RAISE EXCEPTION 'workspace grants must be a JSON array' USING ERRCODE = '22023';
|
|
80
|
+
END IF;
|
|
81
|
+
|
|
82
|
+
FOR v_grant IN SELECT item.value FROM jsonb_array_elements(v_workspace_grants) AS item(value) LOOP
|
|
83
|
+
IF jsonb_typeof(v_grant) <> 'object'
|
|
84
|
+
OR EXISTS (
|
|
85
|
+
SELECT 1
|
|
86
|
+
FROM jsonb_object_keys(v_grant) AS object_key(key)
|
|
87
|
+
WHERE object_key.key NOT IN ('projectId', 'capability', 'environments')
|
|
88
|
+
) THEN
|
|
89
|
+
RAISE EXCEPTION 'invalid workspace grant shape' USING ERRCODE = '22023';
|
|
90
|
+
END IF;
|
|
91
|
+
v_project_id := v_grant ->> 'projectId';
|
|
92
|
+
v_capability := v_grant ->> 'capability';
|
|
93
|
+
IF v_project_id IS NULL OR v_project_id !~ '^[a-z][a-z0-9-]{0,99}$'
|
|
94
|
+
OR v_capability NOT IN ('credentials-read', 'assets-read')
|
|
95
|
+
OR jsonb_typeof(v_grant -> 'environments') <> 'array' THEN
|
|
96
|
+
RAISE EXCEPTION 'invalid workspace grant' USING ERRCODE = '22023';
|
|
97
|
+
END IF;
|
|
98
|
+
v_environments := ARRAY(
|
|
99
|
+
SELECT jsonb_array_elements_text(v_grant -> 'environments')
|
|
100
|
+
);
|
|
101
|
+
IF cardinality(v_environments) <> (
|
|
102
|
+
SELECT count(DISTINCT environment.value)::integer
|
|
103
|
+
FROM unnest(v_environments) AS environment(value)
|
|
104
|
+
) OR NOT (v_environments <@ ARRAY['dev', 'e2e']::text[])
|
|
105
|
+
OR (v_capability = 'credentials-read' AND cardinality(v_environments) = 0)
|
|
106
|
+
OR (v_capability = 'assets-read' AND cardinality(v_environments) <> 0) THEN
|
|
107
|
+
RAISE EXCEPTION 'invalid workspace grant environments' USING ERRCODE = '22023';
|
|
108
|
+
END IF;
|
|
109
|
+
v_grant_key := v_project_id || ':' || v_capability;
|
|
110
|
+
IF v_grant_key = ANY(v_seen_grant_keys) THEN
|
|
111
|
+
RAISE EXCEPTION 'duplicate workspace grant' USING ERRCODE = '22023';
|
|
112
|
+
END IF;
|
|
113
|
+
v_seen_grant_keys := array_append(v_seen_grant_keys, v_grant_key);
|
|
114
|
+
v_has_credential_grant := v_has_credential_grant OR v_capability = 'credentials-read';
|
|
115
|
+
v_has_asset_grant := v_has_asset_grant OR v_capability = 'assets-read';
|
|
116
|
+
END LOOP;
|
|
117
|
+
IF v_has_credential_grant <> ('workspace:credentials-read' = ANY(p_scopes))
|
|
118
|
+
OR v_has_asset_grant <> ('workspace:assets-read' = ANY(p_scopes)) THEN
|
|
119
|
+
RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
PERFORM t.id
|
|
123
|
+
FROM public.tenants AS t
|
|
124
|
+
WHERE t.id = p_team_tenant_id
|
|
125
|
+
AND t.slug = 'team-semicolon'
|
|
126
|
+
AND t.tenant_type = 'team'
|
|
127
|
+
AND coalesce(t.metadata ->> 'status', 'active') = 'active';
|
|
128
|
+
IF NOT FOUND THEN
|
|
129
|
+
RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
SELECT wd.gateway_credential_id
|
|
133
|
+
INTO v_previous_credential_id
|
|
134
|
+
FROM semicolony.worker_devices AS wd
|
|
135
|
+
WHERE wd.device_id = p_device_id
|
|
136
|
+
AND wd.worker_id = p_worker_id
|
|
137
|
+
AND wd.status = 'active'
|
|
138
|
+
AND wd.kb_access_mode = 'worker-gateway'
|
|
139
|
+
AND wd.last_doctor_status = 'pass'
|
|
140
|
+
AND wd.last_seen_at >= clock_timestamp() - interval '24 hours'
|
|
141
|
+
FOR UPDATE;
|
|
142
|
+
IF NOT FOUND THEN
|
|
143
|
+
RAISE EXCEPTION 'active worker device binding not found' USING ERRCODE = 'P0002';
|
|
144
|
+
END IF;
|
|
145
|
+
|
|
146
|
+
INSERT INTO semicolony.gateway_credentials (
|
|
147
|
+
tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
|
|
148
|
+
) VALUES (
|
|
149
|
+
p_team_tenant_id,
|
|
150
|
+
'team-semicolon',
|
|
151
|
+
p_token_hash,
|
|
152
|
+
p_token_prefix,
|
|
153
|
+
p_scopes,
|
|
154
|
+
p_issued_by,
|
|
155
|
+
clock_timestamp() + make_interval(days => p_expires_days),
|
|
156
|
+
jsonb_build_object(
|
|
157
|
+
'credential_kind', 'worker',
|
|
158
|
+
'worker_id', p_worker_id,
|
|
159
|
+
'device_id', p_device_id,
|
|
160
|
+
'tenant_anchor', 'team-semicolon',
|
|
161
|
+
'lineage_status', 'active'
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
RETURNING id INTO v_credential_id;
|
|
165
|
+
|
|
166
|
+
INSERT INTO semicolony.workspace_projection_grants (
|
|
167
|
+
gateway_credential_id, project_id, capability, environments
|
|
168
|
+
)
|
|
169
|
+
SELECT
|
|
170
|
+
v_credential_id,
|
|
171
|
+
item.value ->> 'projectId',
|
|
172
|
+
item.value ->> 'capability',
|
|
173
|
+
ARRAY(SELECT jsonb_array_elements_text(item.value -> 'environments'))
|
|
174
|
+
FROM jsonb_array_elements(v_workspace_grants) AS item(value);
|
|
175
|
+
|
|
176
|
+
IF v_previous_credential_id IS NOT NULL THEN
|
|
177
|
+
UPDATE semicolony.gateway_credentials
|
|
178
|
+
SET status = 'revoked',
|
|
179
|
+
revoked_at = clock_timestamp(),
|
|
180
|
+
revoked_reason = 'worker credential rotated'
|
|
181
|
+
WHERE id = v_previous_credential_id
|
|
182
|
+
AND status = 'active';
|
|
183
|
+
END IF;
|
|
184
|
+
|
|
185
|
+
UPDATE semicolony.worker_devices
|
|
186
|
+
SET gateway_credential_id = v_credential_id
|
|
187
|
+
WHERE device_id = p_device_id
|
|
188
|
+
AND worker_id = p_worker_id;
|
|
189
|
+
|
|
190
|
+
RETURN v_credential_id;
|
|
191
|
+
END;
|
|
192
|
+
$function$;
|
|
193
|
+
|
|
194
|
+
CREATE OR REPLACE FUNCTION semicolony.list_worker_credential_metadata(p_worker_id text)
|
|
195
|
+
RETURNS TABLE (
|
|
196
|
+
id uuid,
|
|
197
|
+
token_prefix text,
|
|
198
|
+
scopes text[],
|
|
199
|
+
status text,
|
|
200
|
+
issued_at timestamptz,
|
|
201
|
+
expires_at timestamptz,
|
|
202
|
+
last_used_at timestamptz
|
|
203
|
+
)
|
|
204
|
+
LANGUAGE sql
|
|
205
|
+
SECURITY DEFINER
|
|
206
|
+
SET search_path = pg_catalog
|
|
207
|
+
AS $function$
|
|
208
|
+
SELECT gc.id,
|
|
209
|
+
gc.token_prefix,
|
|
210
|
+
gc.scopes,
|
|
211
|
+
gc.status,
|
|
212
|
+
gc.issued_at,
|
|
213
|
+
gc.expires_at,
|
|
214
|
+
gc.last_used_at
|
|
215
|
+
FROM semicolony.gateway_credentials AS gc
|
|
216
|
+
WHERE p_worker_id ~ '^[a-z][a-z0-9-]{0,63}$'
|
|
217
|
+
AND gc.tenant_slug = 'team-semicolon'
|
|
218
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
219
|
+
AND gc.metadata ->> 'worker_id' = p_worker_id
|
|
220
|
+
ORDER BY gc.issued_at DESC;
|
|
221
|
+
$function$;
|
|
222
|
+
|
|
223
|
+
REVOKE ALL ON FUNCTION semicolony.issue_worker_gateway_credential(
|
|
224
|
+
uuid, text, text, text, text, text[], text, integer, jsonb
|
|
225
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
226
|
+
REVOKE ALL ON FUNCTION semicolony.list_worker_credential_metadata(text)
|
|
227
|
+
FROM PUBLIC, sc_app, sc_provider;
|
|
228
|
+
GRANT EXECUTE ON FUNCTION semicolony.issue_worker_gateway_credential(
|
|
229
|
+
uuid, text, text, text, text, text[], text, integer, jsonb
|
|
230
|
+
) TO sc_app;
|
|
231
|
+
GRANT EXECUTE ON FUNCTION semicolony.list_worker_credential_metadata(text) TO sc_app;
|
|
232
|
+
|
|
233
|
+
-- Leases remain immutable audit rows, but the user-facing CLI cannot be
|
|
234
|
+
-- expected to connect as the schema owner. These narrow routines preserve the
|
|
235
|
+
-- owner boundary while re-validating every authority field at execution time.
|
|
236
|
+
CREATE OR REPLACE FUNCTION semicolony.issue_skill_capability_lease(
|
|
237
|
+
p_credential_id uuid,
|
|
238
|
+
p_worker_id text,
|
|
239
|
+
p_device_id text,
|
|
240
|
+
p_task_id text,
|
|
241
|
+
p_profile text,
|
|
242
|
+
p_operations text[],
|
|
243
|
+
p_resources text[],
|
|
244
|
+
p_expires_minutes integer,
|
|
245
|
+
p_approval_reference text
|
|
246
|
+
)
|
|
247
|
+
RETURNS TABLE (
|
|
248
|
+
lease_id uuid,
|
|
249
|
+
lease_family_id uuid,
|
|
250
|
+
lease_version integer,
|
|
251
|
+
not_before timestamptz,
|
|
252
|
+
expires_at timestamptz
|
|
253
|
+
)
|
|
254
|
+
LANGUAGE plpgsql
|
|
255
|
+
SECURITY DEFINER
|
|
256
|
+
SET search_path = pg_catalog
|
|
257
|
+
AS $function$
|
|
258
|
+
DECLARE
|
|
259
|
+
v_issued_at timestamptz;
|
|
260
|
+
v_lease_id uuid;
|
|
261
|
+
v_lease_family_id uuid;
|
|
262
|
+
v_lease_version integer;
|
|
263
|
+
v_expires_at timestamptz;
|
|
264
|
+
BEGIN
|
|
265
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
266
|
+
OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$'
|
|
267
|
+
OR p_task_id IS NULL OR p_task_id = '' OR p_task_id <> btrim(p_task_id)
|
|
268
|
+
OR char_length(p_task_id) > 160 OR p_task_id ~ '[[:cntrl:]]' THEN
|
|
269
|
+
RAISE EXCEPTION 'invalid skill lease identity' USING ERRCODE = '22023';
|
|
270
|
+
END IF;
|
|
271
|
+
IF p_profile IS NULL OR p_profile NOT IN ('read', 'make', 'operate')
|
|
272
|
+
OR p_operations IS NULL OR cardinality(p_operations) = 0 OR cardinality(p_operations) > 32
|
|
273
|
+
OR p_resources IS NULL OR cardinality(p_resources) = 0 OR cardinality(p_resources) > 32
|
|
274
|
+
OR EXISTS (
|
|
275
|
+
SELECT 1
|
|
276
|
+
FROM unnest(p_operations) AS operation(value)
|
|
277
|
+
WHERE operation.value IS NULL OR operation.value = '*' OR operation.value <> btrim(operation.value)
|
|
278
|
+
OR char_length(operation.value) > 512 OR operation.value ~ '[[:cntrl:]]'
|
|
279
|
+
)
|
|
280
|
+
OR EXISTS (
|
|
281
|
+
SELECT 1
|
|
282
|
+
FROM unnest(p_resources) AS resource(value)
|
|
283
|
+
WHERE resource.value IS NULL OR resource.value = '*' OR resource.value <> btrim(resource.value)
|
|
284
|
+
OR char_length(resource.value) > 512 OR resource.value ~ '[[:cntrl:]]'
|
|
285
|
+
)
|
|
286
|
+
OR cardinality(p_operations) <> (
|
|
287
|
+
SELECT count(DISTINCT operation.value)::integer FROM unnest(p_operations) AS operation(value)
|
|
288
|
+
)
|
|
289
|
+
OR cardinality(p_resources) <> (
|
|
290
|
+
SELECT count(DISTINCT resource.value)::integer FROM unnest(p_resources) AS resource(value)
|
|
291
|
+
)
|
|
292
|
+
OR p_expires_minutes IS NULL OR p_expires_minutes < 1 OR p_expires_minutes > 60 THEN
|
|
293
|
+
RAISE EXCEPTION 'invalid skill lease request' USING ERRCODE = '22023';
|
|
294
|
+
END IF;
|
|
295
|
+
IF (p_profile = 'read' AND NOT (p_operations <@ ARRAY[
|
|
296
|
+
'skills.health.read', 'skills.release.read'
|
|
297
|
+
]::text[]))
|
|
298
|
+
OR (p_profile = 'make' AND NOT (p_operations <@ ARRAY[
|
|
299
|
+
'skills.health.read', 'skills.release.read', 'skills.proposal.write',
|
|
300
|
+
'skills.projection.reconcile', 'skills.project.write', 'skills.release.stage'
|
|
301
|
+
]::text[]))
|
|
302
|
+
OR (p_profile = 'operate' AND NOT (p_operations <@ ARRAY[
|
|
303
|
+
'skills.health.read', 'skills.release.read', 'skills.proposal.write',
|
|
304
|
+
'skills.projection.reconcile', 'skills.project.write', 'skills.release.stage',
|
|
305
|
+
'skills.project.publish', 'skills.release.activate', 'skills.audience.manage',
|
|
306
|
+
'skills.release.retire'
|
|
307
|
+
]::text[])) THEN
|
|
308
|
+
RAISE EXCEPTION 'operation_not_allowed_by_profile' USING ERRCODE = '22023';
|
|
309
|
+
END IF;
|
|
310
|
+
IF (p_approval_reference IS NOT NULL
|
|
311
|
+
AND (p_approval_reference = ''
|
|
312
|
+
OR p_approval_reference <> btrim(p_approval_reference)
|
|
313
|
+
OR char_length(p_approval_reference) > 160
|
|
314
|
+
OR p_approval_reference ~ '[[:cntrl:]]'))
|
|
315
|
+
OR (p_profile <> 'read' AND p_approval_reference IS NULL) THEN
|
|
316
|
+
RAISE EXCEPTION 'mutation_approval_required' USING ERRCODE = '22023';
|
|
317
|
+
END IF;
|
|
318
|
+
|
|
319
|
+
SELECT clock_timestamp()
|
|
320
|
+
INTO v_issued_at
|
|
321
|
+
FROM semicolony.gateway_credentials AS credential
|
|
322
|
+
JOIN semicolony.worker_devices AS device
|
|
323
|
+
ON device.gateway_credential_id = credential.id
|
|
324
|
+
AND device.device_id = p_device_id
|
|
325
|
+
AND device.worker_id = p_worker_id
|
|
326
|
+
AND device.status = 'active'
|
|
327
|
+
AND device.kb_access_mode = 'worker-gateway'
|
|
328
|
+
WHERE credential.id = p_credential_id
|
|
329
|
+
AND credential.tenant_slug = 'team-semicolon'
|
|
330
|
+
AND credential.status = 'active'
|
|
331
|
+
AND credential.scopes @> ARRAY['skills:read']::text[]
|
|
332
|
+
AND (p_profile = 'read' OR credential.scopes @> ARRAY['skills:write']::text[])
|
|
333
|
+
AND credential.metadata ->> 'credential_kind' = 'worker'
|
|
334
|
+
AND credential.metadata ->> 'worker_id' = p_worker_id
|
|
335
|
+
AND credential.metadata ->> 'device_id' = p_device_id
|
|
336
|
+
AND (credential.expires_at IS NULL OR credential.expires_at > clock_timestamp())
|
|
337
|
+
AND device.last_doctor_status = 'pass'
|
|
338
|
+
AND device.last_seen_at >= clock_timestamp() - interval '24 hours'
|
|
339
|
+
FOR SHARE OF credential, device;
|
|
340
|
+
IF NOT FOUND THEN
|
|
341
|
+
RAISE EXCEPTION 'active_worker_device_binding_not_found' USING ERRCODE = 'P0002';
|
|
342
|
+
END IF;
|
|
343
|
+
|
|
344
|
+
INSERT INTO semicolony.skill_capability_leases AS lease (
|
|
345
|
+
lease_family_id, lease_version, credential_id, principal_id, worker_id,
|
|
346
|
+
device_id, task_id, profile, operations, resources, not_before,
|
|
347
|
+
expires_at, approval_reference, issued_by_principal
|
|
348
|
+
) VALUES (
|
|
349
|
+
gen_random_uuid(),
|
|
350
|
+
1,
|
|
351
|
+
p_credential_id,
|
|
352
|
+
'worker:' || p_worker_id,
|
|
353
|
+
p_worker_id,
|
|
354
|
+
p_device_id,
|
|
355
|
+
p_task_id,
|
|
356
|
+
p_profile,
|
|
357
|
+
p_operations,
|
|
358
|
+
p_resources,
|
|
359
|
+
v_issued_at,
|
|
360
|
+
v_issued_at + make_interval(mins => p_expires_minutes),
|
|
361
|
+
NULLIF(p_approval_reference, ''),
|
|
362
|
+
'db-session:' || session_user::text
|
|
363
|
+
)
|
|
364
|
+
RETURNING
|
|
365
|
+
lease.lease_id,
|
|
366
|
+
lease.lease_family_id,
|
|
367
|
+
lease.lease_version,
|
|
368
|
+
lease.not_before,
|
|
369
|
+
lease.expires_at
|
|
370
|
+
INTO v_lease_id, v_lease_family_id, v_lease_version, v_issued_at, v_expires_at;
|
|
371
|
+
|
|
372
|
+
RETURN QUERY SELECT v_lease_id, v_lease_family_id, v_lease_version, v_issued_at, v_expires_at;
|
|
373
|
+
END;
|
|
374
|
+
$function$;
|
|
375
|
+
|
|
376
|
+
CREATE OR REPLACE FUNCTION semicolony.revoke_skill_capability_lease(
|
|
377
|
+
p_lease_id uuid,
|
|
378
|
+
p_reason text
|
|
379
|
+
)
|
|
380
|
+
RETURNS TABLE (
|
|
381
|
+
revocation_id uuid,
|
|
382
|
+
lease_family_id uuid,
|
|
383
|
+
revoked_through_version integer,
|
|
384
|
+
revoked_at timestamptz
|
|
385
|
+
)
|
|
386
|
+
LANGUAGE plpgsql
|
|
387
|
+
SECURITY DEFINER
|
|
388
|
+
SET search_path = pg_catalog
|
|
389
|
+
AS $function$
|
|
390
|
+
BEGIN
|
|
391
|
+
IF p_reason IS NULL OR p_reason = '' OR p_reason <> btrim(p_reason)
|
|
392
|
+
OR char_length(p_reason) > 160 OR p_reason ~ '[[:cntrl:]]' THEN
|
|
393
|
+
RAISE EXCEPTION 'invalid_revocation_reason' USING ERRCODE = '22023';
|
|
394
|
+
END IF;
|
|
395
|
+
|
|
396
|
+
RETURN QUERY
|
|
397
|
+
INSERT INTO semicolony.skill_capability_lease_revocations AS revocation (
|
|
398
|
+
lease_family_id, revoked_through_version, reason, revoked_by_principal
|
|
399
|
+
)
|
|
400
|
+
SELECT
|
|
401
|
+
lease.lease_family_id,
|
|
402
|
+
lease.lease_version,
|
|
403
|
+
p_reason,
|
|
404
|
+
'db-session:' || session_user::text
|
|
405
|
+
FROM semicolony.skill_capability_leases AS lease
|
|
406
|
+
WHERE lease.lease_id = p_lease_id
|
|
407
|
+
RETURNING
|
|
408
|
+
revocation.revocation_id,
|
|
409
|
+
revocation.lease_family_id,
|
|
410
|
+
revocation.revoked_through_version,
|
|
411
|
+
revocation.revoked_at;
|
|
412
|
+
END;
|
|
413
|
+
$function$;
|
|
414
|
+
|
|
415
|
+
REVOKE ALL ON FUNCTION semicolony.issue_skill_capability_lease(
|
|
416
|
+
uuid, text, text, text, text, text[], text[], integer, text
|
|
417
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
418
|
+
REVOKE ALL ON FUNCTION semicolony.revoke_skill_capability_lease(uuid, text)
|
|
419
|
+
FROM PUBLIC, sc_app, sc_provider;
|
|
420
|
+
GRANT EXECUTE ON FUNCTION semicolony.issue_skill_capability_lease(
|
|
421
|
+
uuid, text, text, text, text, text[], text[], integer, text
|
|
422
|
+
) TO sc_app;
|
|
423
|
+
GRANT EXECUTE ON FUNCTION semicolony.revoke_skill_capability_lease(uuid, text) TO sc_app;
|
|
424
|
+
|
|
425
|
+
-- The view intentionally omits gateway_credential_id and token material. It is
|
|
426
|
+
-- the read-only status surface used by `semo worker status`.
|
|
427
|
+
GRANT SELECT ON TABLE semicolony.v_worker_environment_status TO sc_app;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
-- 169_worker_profile_releases.sql
|
|
2
|
+
-- Immutable SEMO Ops worker profile releases and per-device rollout state.
|
|
3
|
+
--
|
|
4
|
+
-- Existing workspace_profiles.version and worker_devices.installed_profile_version
|
|
5
|
+
-- remain bootstrap compatibility data. They are not release-completion evidence.
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS semicolony.worker_profile_releases (
|
|
8
|
+
release_id UUID PRIMARY KEY,
|
|
9
|
+
profile_id TEXT NOT NULL REFERENCES semicolony.workspace_profiles(profile_id),
|
|
10
|
+
version TEXT NOT NULL,
|
|
11
|
+
status TEXT NOT NULL DEFAULT 'staged'
|
|
12
|
+
CHECK (status IN ('staged', 'active', 'superseded', 'withdrawn')),
|
|
13
|
+
manifest JSONB NOT NULL,
|
|
14
|
+
manifest_sha256 TEXT NOT NULL
|
|
15
|
+
CHECK (manifest_sha256 ~ '^[A-Fa-f0-9]{64}$'),
|
|
16
|
+
source_commit TEXT NOT NULL,
|
|
17
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18
|
+
activated_at TIMESTAMPTZ,
|
|
19
|
+
activated_by TEXT,
|
|
20
|
+
UNIQUE (profile_id, version),
|
|
21
|
+
CHECK (
|
|
22
|
+
status <> 'active'
|
|
23
|
+
OR (activated_at IS NOT NULL AND NULLIF(BTRIM(activated_by), '') IS NOT NULL)
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
COMMENT ON TABLE semicolony.worker_profile_releases IS
|
|
28
|
+
'Immutable desired-state releases for SEMO Ops workspace profiles.';
|
|
29
|
+
COMMENT ON COLUMN semicolony.worker_profile_releases.manifest_sha256 IS
|
|
30
|
+
'SHA-256 digest of the immutable release manifest.';
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_worker_profile_releases_profile_status_created
|
|
33
|
+
ON semicolony.worker_profile_releases (profile_id, status, created_at DESC);
|
|
34
|
+
|
|
35
|
+
CREATE OR REPLACE FUNCTION semicolony.enforce_worker_profile_release_immutable()
|
|
36
|
+
RETURNS TRIGGER AS $$
|
|
37
|
+
BEGIN
|
|
38
|
+
IF TG_OP = 'DELETE' THEN
|
|
39
|
+
RAISE EXCEPTION 'worker profile releases are immutable and cannot be deleted';
|
|
40
|
+
END IF;
|
|
41
|
+
|
|
42
|
+
IF NEW.release_id IS DISTINCT FROM OLD.release_id
|
|
43
|
+
OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
|
|
44
|
+
OR NEW.version IS DISTINCT FROM OLD.version
|
|
45
|
+
OR NEW.manifest IS DISTINCT FROM OLD.manifest
|
|
46
|
+
OR NEW.manifest_sha256 IS DISTINCT FROM OLD.manifest_sha256
|
|
47
|
+
OR NEW.source_commit IS DISTINCT FROM OLD.source_commit
|
|
48
|
+
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
|
49
|
+
RAISE EXCEPTION 'worker profile release provenance is immutable';
|
|
50
|
+
END IF;
|
|
51
|
+
|
|
52
|
+
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
|
53
|
+
IF NOT (
|
|
54
|
+
(OLD.status = 'staged' AND NEW.status IN ('active', 'withdrawn'))
|
|
55
|
+
OR (OLD.status = 'active' AND NEW.status IN ('superseded', 'withdrawn'))
|
|
56
|
+
) THEN
|
|
57
|
+
RAISE EXCEPTION 'invalid worker profile release status transition: % to %', OLD.status, NEW.status;
|
|
58
|
+
END IF;
|
|
59
|
+
|
|
60
|
+
IF OLD.status = 'active'
|
|
61
|
+
AND NEW.status <> 'active'
|
|
62
|
+
AND EXISTS (
|
|
63
|
+
SELECT 1
|
|
64
|
+
FROM semicolony.workspace_profiles wp
|
|
65
|
+
WHERE wp.active_release_id = OLD.release_id
|
|
66
|
+
) THEN
|
|
67
|
+
RAISE EXCEPTION 'cannot change status of a release selected by a workspace profile';
|
|
68
|
+
END IF;
|
|
69
|
+
END IF;
|
|
70
|
+
|
|
71
|
+
IF NEW.activated_at IS DISTINCT FROM OLD.activated_at
|
|
72
|
+
OR NEW.activated_by IS DISTINCT FROM OLD.activated_by THEN
|
|
73
|
+
IF NOT (OLD.status = 'staged' AND NEW.status = 'active') THEN
|
|
74
|
+
RAISE EXCEPTION 'worker profile release activation metadata is immutable after activation';
|
|
75
|
+
END IF;
|
|
76
|
+
END IF;
|
|
77
|
+
|
|
78
|
+
RETURN NEW;
|
|
79
|
+
END;
|
|
80
|
+
$$ LANGUAGE plpgsql;
|
|
81
|
+
|
|
82
|
+
DROP TRIGGER IF EXISTS trg_worker_profile_releases_immutable
|
|
83
|
+
ON semicolony.worker_profile_releases;
|
|
84
|
+
CREATE TRIGGER trg_worker_profile_releases_immutable
|
|
85
|
+
BEFORE UPDATE OR DELETE ON semicolony.worker_profile_releases
|
|
86
|
+
FOR EACH ROW EXECUTE FUNCTION semicolony.enforce_worker_profile_release_immutable();
|
|
87
|
+
|
|
88
|
+
ALTER TABLE semicolony.workspace_profiles
|
|
89
|
+
ADD COLUMN IF NOT EXISTS active_release_id UUID;
|
|
90
|
+
|
|
91
|
+
ALTER TABLE semicolony.workspace_profiles
|
|
92
|
+
ADD CONSTRAINT workspace_profiles_active_release_id_fkey
|
|
93
|
+
FOREIGN KEY (active_release_id)
|
|
94
|
+
REFERENCES semicolony.worker_profile_releases(release_id)
|
|
95
|
+
ON DELETE SET NULL;
|
|
96
|
+
|
|
97
|
+
CREATE OR REPLACE FUNCTION semicolony.assert_workspace_profile_active_release()
|
|
98
|
+
RETURNS TRIGGER AS $$
|
|
99
|
+
DECLARE
|
|
100
|
+
selected_profile_id TEXT;
|
|
101
|
+
selected_status TEXT;
|
|
102
|
+
BEGIN
|
|
103
|
+
IF NEW.active_release_id IS NULL THEN
|
|
104
|
+
RETURN NEW;
|
|
105
|
+
END IF;
|
|
106
|
+
|
|
107
|
+
SELECT r.profile_id, r.status
|
|
108
|
+
INTO selected_profile_id, selected_status
|
|
109
|
+
FROM semicolony.worker_profile_releases r
|
|
110
|
+
WHERE r.release_id = NEW.active_release_id;
|
|
111
|
+
|
|
112
|
+
IF NOT FOUND
|
|
113
|
+
OR selected_profile_id IS DISTINCT FROM NEW.profile_id
|
|
114
|
+
OR selected_status <> 'active' THEN
|
|
115
|
+
RAISE EXCEPTION 'active_release_id must reference an active release for the same workspace profile';
|
|
116
|
+
END IF;
|
|
117
|
+
|
|
118
|
+
RETURN NEW;
|
|
119
|
+
END;
|
|
120
|
+
$$ LANGUAGE plpgsql;
|
|
121
|
+
|
|
122
|
+
DROP TRIGGER IF EXISTS trg_workspace_profiles_active_release
|
|
123
|
+
ON semicolony.workspace_profiles;
|
|
124
|
+
CREATE TRIGGER trg_workspace_profiles_active_release
|
|
125
|
+
BEFORE INSERT OR UPDATE OF profile_id, active_release_id ON semicolony.workspace_profiles
|
|
126
|
+
FOR EACH ROW EXECUTE FUNCTION semicolony.assert_workspace_profile_active_release();
|
|
127
|
+
|
|
128
|
+
ALTER TABLE semicolony.worker_devices
|
|
129
|
+
ADD COLUMN IF NOT EXISTS applied_release_id UUID,
|
|
130
|
+
ADD COLUMN IF NOT EXISTS applied_release_version TEXT,
|
|
131
|
+
ADD COLUMN IF NOT EXISTS applied_at TIMESTAMPTZ,
|
|
132
|
+
ADD CONSTRAINT worker_devices_applied_release_state_check CHECK (
|
|
133
|
+
(applied_release_id IS NULL AND applied_release_version IS NULL AND applied_at IS NULL)
|
|
134
|
+
OR (applied_release_id IS NOT NULL AND applied_release_version IS NOT NULL AND applied_at IS NOT NULL)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
ALTER TABLE semicolony.worker_devices
|
|
138
|
+
ADD CONSTRAINT worker_devices_applied_release_id_fkey
|
|
139
|
+
FOREIGN KEY (applied_release_id)
|
|
140
|
+
REFERENCES semicolony.worker_profile_releases(release_id)
|
|
141
|
+
ON DELETE SET NULL;
|
|
142
|
+
|
|
143
|
+
CREATE OR REPLACE FUNCTION semicolony.assert_worker_device_applied_release()
|
|
144
|
+
RETURNS TRIGGER AS $$
|
|
145
|
+
DECLARE
|
|
146
|
+
selected_profile_id TEXT;
|
|
147
|
+
selected_version TEXT;
|
|
148
|
+
BEGIN
|
|
149
|
+
IF NEW.applied_release_id IS NULL THEN
|
|
150
|
+
RETURN NEW;
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
SELECT r.profile_id, r.version
|
|
154
|
+
INTO selected_profile_id, selected_version
|
|
155
|
+
FROM semicolony.worker_profile_releases r
|
|
156
|
+
WHERE r.release_id = NEW.applied_release_id;
|
|
157
|
+
|
|
158
|
+
IF NOT FOUND
|
|
159
|
+
OR selected_profile_id IS DISTINCT FROM NEW.installed_profile_id
|
|
160
|
+
OR selected_version IS DISTINCT FROM NEW.applied_release_version THEN
|
|
161
|
+
RAISE EXCEPTION 'applied release must match the worker device profile and release version';
|
|
162
|
+
END IF;
|
|
163
|
+
|
|
164
|
+
RETURN NEW;
|
|
165
|
+
END;
|
|
166
|
+
$$ LANGUAGE plpgsql;
|
|
167
|
+
|
|
168
|
+
DROP TRIGGER IF EXISTS trg_worker_devices_applied_release
|
|
169
|
+
ON semicolony.worker_devices;
|
|
170
|
+
CREATE TRIGGER trg_worker_devices_applied_release
|
|
171
|
+
BEFORE INSERT OR UPDATE OF installed_profile_id, applied_release_id, applied_release_version, applied_at
|
|
172
|
+
ON semicolony.worker_devices
|
|
173
|
+
FOR EACH ROW EXECUTE FUNCTION semicolony.assert_worker_device_applied_release();
|
|
174
|
+
|
|
175
|
+
CREATE OR REPLACE VIEW semicolony.v_worker_environment_status AS
|
|
176
|
+
SELECT
|
|
177
|
+
wd.device_id,
|
|
178
|
+
wd.worker_id,
|
|
179
|
+
wd.display_name,
|
|
180
|
+
wd.hostname,
|
|
181
|
+
wd.os,
|
|
182
|
+
wd.workspace_root,
|
|
183
|
+
wd.installed_profile_id,
|
|
184
|
+
wd.installed_profile_version,
|
|
185
|
+
wd.tool_adapters_enabled,
|
|
186
|
+
wd.kb_access_mode,
|
|
187
|
+
wd.last_seen_at,
|
|
188
|
+
wd.last_doctor_status,
|
|
189
|
+
wd.last_doctor_summary,
|
|
190
|
+
wd.setup_progress,
|
|
191
|
+
wd.status,
|
|
192
|
+
wd.updated_at,
|
|
193
|
+
COALESCE(
|
|
194
|
+
jsonb_agg(
|
|
195
|
+
jsonb_build_object(
|
|
196
|
+
'local_hive_id', lh.local_hive_id,
|
|
197
|
+
'workload_type', lh.workload_type,
|
|
198
|
+
'status', lh.status,
|
|
199
|
+
'migration_target', lh.migration_target,
|
|
200
|
+
'expiry_at', lh.expiry_at,
|
|
201
|
+
'last_seen_at', lh.last_seen_at
|
|
202
|
+
)
|
|
203
|
+
) FILTER (WHERE lh.local_hive_id IS NOT NULL),
|
|
204
|
+
'[]'::jsonb
|
|
205
|
+
) AS local_hives,
|
|
206
|
+
wp.active_release_id,
|
|
207
|
+
active_release.version AS active_release_version,
|
|
208
|
+
wd.applied_release_id,
|
|
209
|
+
wd.applied_release_version,
|
|
210
|
+
wd.applied_at
|
|
211
|
+
FROM semicolony.worker_devices wd
|
|
212
|
+
LEFT JOIN semicolony.local_hives lh
|
|
213
|
+
ON lh.device_id = wd.device_id
|
|
214
|
+
LEFT JOIN semicolony.workspace_profiles wp
|
|
215
|
+
ON wp.profile_id = wd.installed_profile_id
|
|
216
|
+
LEFT JOIN semicolony.worker_profile_releases active_release
|
|
217
|
+
ON active_release.release_id = wp.active_release_id
|
|
218
|
+
GROUP BY wd.device_id, wp.active_release_id, active_release.version;
|
|
219
|
+
|
|
220
|
+
COMMENT ON COLUMN semicolony.workspace_profiles.active_release_id IS
|
|
221
|
+
'Desired immutable release. Null until an operator stages and activates the first release.';
|
|
222
|
+
COMMENT ON COLUMN semicolony.worker_devices.applied_release_id IS
|
|
223
|
+
'Last release a device successfully applied; failed updates must not advance this value.';
|
|
224
|
+
COMMENT ON COLUMN semicolony.worker_devices.applied_release_version IS
|
|
225
|
+
'Version recorded with the last successfully applied release.';
|
|
226
|
+
COMMENT ON COLUMN semicolony.worker_devices.applied_at IS
|
|
227
|
+
'Time the device last successfully applied its recorded release.';
|