@team-semicolon/semicolony-cli 4.18.72 → 4.18.73
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/dist/bundle.js +1126 -1118
- package/migrations/178_feedback_capture_capability.sql +137 -0
- package/migrations/179_worker_feedback_credential_scope.sql +209 -0
- package/migrations/180_worker_dashboard_owner_credentials.sql +264 -0
- package/migrations/181_db_authoritative_common_skills.sql +57 -0
- package/migrations/README.md +16 -14
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
-- 178_feedback_capture_capability.sql
|
|
2
|
+
--
|
|
3
|
+
-- Give the Gateway one narrowly-scoped write primitive for SEMO Ops feedback.
|
|
4
|
+
-- sc_app receives EXECUTE only: it still has no general DML on knowledge_base
|
|
5
|
+
-- or action_items. The function fixes domain/key/source values, validates the
|
|
6
|
+
-- remaining input, serializes same-slug writes, and creates the KB record and
|
|
7
|
+
-- its open action-item handle in the caller's transaction.
|
|
8
|
+
|
|
9
|
+
CREATE OR REPLACE FUNCTION semicolony.record_feedback(
|
|
10
|
+
p_slug text,
|
|
11
|
+
p_summary text,
|
|
12
|
+
p_content text,
|
|
13
|
+
p_metadata jsonb,
|
|
14
|
+
p_embedding text,
|
|
15
|
+
p_worker_id text,
|
|
16
|
+
p_device_id text
|
|
17
|
+
)
|
|
18
|
+
RETURNS TABLE(kb_id bigint, action_item_id uuid, duplicate boolean)
|
|
19
|
+
LANGUAGE plpgsql
|
|
20
|
+
SECURITY DEFINER
|
|
21
|
+
SET search_path = pg_catalog
|
|
22
|
+
AS $function$
|
|
23
|
+
DECLARE
|
|
24
|
+
v_kb_id bigint;
|
|
25
|
+
v_action_item_id uuid;
|
|
26
|
+
v_duplicate boolean := false;
|
|
27
|
+
v_metadata jsonb;
|
|
28
|
+
v_priority text;
|
|
29
|
+
BEGIN
|
|
30
|
+
IF p_slug IS NULL
|
|
31
|
+
OR char_length(p_slug) < 3
|
|
32
|
+
OR char_length(p_slug) > 60
|
|
33
|
+
OR p_slug !~ '^[[:alnum:]가-힣][[:alnum:]가-힣-]*$' THEN
|
|
34
|
+
RAISE EXCEPTION 'invalid feedback slug' USING ERRCODE = '22023';
|
|
35
|
+
END IF;
|
|
36
|
+
IF p_summary IS NULL OR char_length(btrim(p_summary)) < 8 OR char_length(p_summary) > 500 THEN
|
|
37
|
+
RAISE EXCEPTION 'invalid feedback summary' USING ERRCODE = '22023';
|
|
38
|
+
END IF;
|
|
39
|
+
IF p_content IS NULL OR char_length(p_content) < 30 OR char_length(p_content) > 30000 THEN
|
|
40
|
+
RAISE EXCEPTION 'invalid feedback content' USING ERRCODE = '22023';
|
|
41
|
+
END IF;
|
|
42
|
+
IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
|
|
43
|
+
RAISE EXCEPTION 'invalid feedback metadata' USING ERRCODE = '22023';
|
|
44
|
+
END IF;
|
|
45
|
+
IF p_metadata ->> 'category' NOT IN ('context-failure', 'env-drift', 'policy-gap', 'tooling') THEN
|
|
46
|
+
RAISE EXCEPTION 'invalid feedback category' USING ERRCODE = '22023';
|
|
47
|
+
END IF;
|
|
48
|
+
v_priority := coalesce(p_metadata ->> 'severity', 'normal');
|
|
49
|
+
IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
|
|
50
|
+
RAISE EXCEPTION 'invalid feedback severity' USING ERRCODE = '22023';
|
|
51
|
+
END IF;
|
|
52
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
53
|
+
OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
|
|
54
|
+
RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
|
|
55
|
+
END IF;
|
|
56
|
+
|
|
57
|
+
-- The cast also validates dimensionality against the target vector column.
|
|
58
|
+
PERFORM p_embedding::public.vector;
|
|
59
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('feedback:' || p_slug, 0));
|
|
60
|
+
|
|
61
|
+
v_metadata := p_metadata || jsonb_build_object(
|
|
62
|
+
'status', 'open',
|
|
63
|
+
'source', 'session-hook',
|
|
64
|
+
'worker_id', p_worker_id,
|
|
65
|
+
'device_id', p_device_id
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
INSERT INTO semicolony.knowledge_base AS kb (
|
|
69
|
+
domain, key, sub_key, content, created_by, embedding, metadata
|
|
70
|
+
) VALUES (
|
|
71
|
+
'semicolon',
|
|
72
|
+
'feedback',
|
|
73
|
+
p_slug,
|
|
74
|
+
p_content,
|
|
75
|
+
'feedback-pipeline:' || p_worker_id,
|
|
76
|
+
p_embedding::public.vector,
|
|
77
|
+
v_metadata
|
|
78
|
+
)
|
|
79
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
80
|
+
content = EXCLUDED.content,
|
|
81
|
+
embedding = EXCLUDED.embedding,
|
|
82
|
+
metadata = coalesce(kb.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
|
83
|
+
archived = false,
|
|
84
|
+
updated_at = clock_timestamp()
|
|
85
|
+
RETURNING kb.kb_id INTO v_kb_id;
|
|
86
|
+
|
|
87
|
+
SELECT ai.action_item_id
|
|
88
|
+
INTO v_action_item_id
|
|
89
|
+
FROM semicolony.action_items AS ai
|
|
90
|
+
WHERE ai.owner_domain = 'semicolon'
|
|
91
|
+
AND ai.source = 'feedback'
|
|
92
|
+
AND ai.status = 'open'
|
|
93
|
+
AND ai.metadata ->> 'feedback_slug' = p_slug
|
|
94
|
+
ORDER BY ai.created_at DESC
|
|
95
|
+
LIMIT 1
|
|
96
|
+
FOR UPDATE;
|
|
97
|
+
|
|
98
|
+
IF FOUND THEN
|
|
99
|
+
v_duplicate := true;
|
|
100
|
+
ELSE
|
|
101
|
+
INSERT INTO semicolony.action_items (
|
|
102
|
+
owner_domain,
|
|
103
|
+
target_domain,
|
|
104
|
+
description,
|
|
105
|
+
priority,
|
|
106
|
+
category,
|
|
107
|
+
source,
|
|
108
|
+
related_url,
|
|
109
|
+
metadata
|
|
110
|
+
) VALUES (
|
|
111
|
+
'semicolon',
|
|
112
|
+
'semicolon',
|
|
113
|
+
'[SEMO Ops feedback] ' || btrim(p_summary),
|
|
114
|
+
v_priority,
|
|
115
|
+
'feedback',
|
|
116
|
+
'feedback',
|
|
117
|
+
'semo kb get semicolon feedback ' || p_slug,
|
|
118
|
+
jsonb_build_object(
|
|
119
|
+
'feedback_slug', p_slug,
|
|
120
|
+
'failed_rule', coalesce(p_metadata ->> 'failed_rule', 'general'),
|
|
121
|
+
'worker_id', p_worker_id,
|
|
122
|
+
'device_id', p_device_id
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
RETURNING action_items.action_item_id INTO v_action_item_id;
|
|
126
|
+
END IF;
|
|
127
|
+
|
|
128
|
+
RETURN QUERY SELECT v_kb_id, v_action_item_id, v_duplicate;
|
|
129
|
+
END;
|
|
130
|
+
$function$;
|
|
131
|
+
|
|
132
|
+
REVOKE ALL ON FUNCTION semicolony.record_feedback(
|
|
133
|
+
text, text, text, jsonb, text, text, text
|
|
134
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
135
|
+
GRANT EXECUTE ON FUNCTION semicolony.record_feedback(
|
|
136
|
+
text, text, text, jsonb, text, text, text
|
|
137
|
+
) TO sc_app;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
-- 179_worker_feedback_credential_scope.sql
|
|
2
|
+
--
|
|
3
|
+
-- Allow the dedicated feedback:write capability in future worker credential
|
|
4
|
+
-- rotations, then add it to currently device-bound active credentials. This
|
|
5
|
+
-- does not add kb:write and does not change any table DML grant.
|
|
6
|
+
|
|
7
|
+
CREATE OR REPLACE FUNCTION semicolony.issue_worker_gateway_credential(
|
|
8
|
+
p_team_tenant_id uuid,
|
|
9
|
+
p_worker_id text,
|
|
10
|
+
p_device_id text,
|
|
11
|
+
p_token_hash text,
|
|
12
|
+
p_token_prefix text,
|
|
13
|
+
p_scopes text[],
|
|
14
|
+
p_issued_by text,
|
|
15
|
+
p_expires_days integer,
|
|
16
|
+
p_workspace_grants jsonb
|
|
17
|
+
)
|
|
18
|
+
RETURNS uuid
|
|
19
|
+
LANGUAGE plpgsql
|
|
20
|
+
SECURITY DEFINER
|
|
21
|
+
SET search_path = pg_catalog
|
|
22
|
+
AS $function$
|
|
23
|
+
DECLARE
|
|
24
|
+
v_credential_id uuid;
|
|
25
|
+
v_previous_credential_id uuid;
|
|
26
|
+
v_workspace_grants jsonb := coalesce(p_workspace_grants, '[]'::jsonb);
|
|
27
|
+
v_grant jsonb;
|
|
28
|
+
v_project_id text;
|
|
29
|
+
v_capability text;
|
|
30
|
+
v_environments text[];
|
|
31
|
+
v_grant_key text;
|
|
32
|
+
v_seen_grant_keys text[] := ARRAY[]::text[];
|
|
33
|
+
v_has_credential_grant boolean := false;
|
|
34
|
+
v_has_asset_grant boolean := false;
|
|
35
|
+
BEGIN
|
|
36
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
37
|
+
OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
|
|
38
|
+
RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
|
|
39
|
+
END IF;
|
|
40
|
+
IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' THEN
|
|
41
|
+
RAISE EXCEPTION 'invalid worker credential token hash' USING ERRCODE = '22023';
|
|
42
|
+
END IF;
|
|
43
|
+
IF p_token_prefix IS NULL
|
|
44
|
+
OR p_token_prefix !~ ('^wck_' || p_worker_id || '_[A-Za-z0-9_-]{6}$') THEN
|
|
45
|
+
RAISE EXCEPTION 'invalid worker credential token prefix' USING ERRCODE = '22023';
|
|
46
|
+
END IF;
|
|
47
|
+
IF p_issued_by IS NULL OR p_issued_by <> btrim(p_issued_by)
|
|
48
|
+
OR btrim(p_issued_by) = '' OR char_length(p_issued_by) > 128
|
|
49
|
+
OR p_issued_by ~ '[[:cntrl:]]' THEN
|
|
50
|
+
RAISE EXCEPTION 'invalid credential issuer' USING ERRCODE = '22023';
|
|
51
|
+
END IF;
|
|
52
|
+
IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
|
|
53
|
+
RAISE EXCEPTION 'worker credential expiry must be between 1 and 90 days' USING ERRCODE = '22023';
|
|
54
|
+
END IF;
|
|
55
|
+
IF p_scopes IS NULL OR cardinality(p_scopes) = 0
|
|
56
|
+
OR EXISTS (
|
|
57
|
+
SELECT 1
|
|
58
|
+
FROM unnest(p_scopes) AS requested(scope)
|
|
59
|
+
WHERE requested.scope IS NULL
|
|
60
|
+
OR requested.scope <> ALL (ARRAY[
|
|
61
|
+
'kb:read',
|
|
62
|
+
'feedback:write',
|
|
63
|
+
'skills:read',
|
|
64
|
+
'skills:write',
|
|
65
|
+
'workspace:credentials-read',
|
|
66
|
+
'workspace:assets-read',
|
|
67
|
+
'worker:release:read',
|
|
68
|
+
'worker:release:report'
|
|
69
|
+
]::text[])
|
|
70
|
+
)
|
|
71
|
+
OR cardinality(p_scopes) <> (
|
|
72
|
+
SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
|
|
73
|
+
)
|
|
74
|
+
OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
|
|
75
|
+
RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
|
|
76
|
+
END IF;
|
|
77
|
+
IF jsonb_typeof(v_workspace_grants) <> 'array' THEN
|
|
78
|
+
RAISE EXCEPTION 'workspace grants must be a JSON array' USING ERRCODE = '22023';
|
|
79
|
+
END IF;
|
|
80
|
+
|
|
81
|
+
FOR v_grant IN SELECT item.value FROM jsonb_array_elements(v_workspace_grants) AS item(value) LOOP
|
|
82
|
+
IF jsonb_typeof(v_grant) <> 'object'
|
|
83
|
+
OR EXISTS (
|
|
84
|
+
SELECT 1
|
|
85
|
+
FROM jsonb_object_keys(v_grant) AS object_key(key)
|
|
86
|
+
WHERE object_key.key NOT IN ('projectId', 'capability', 'environments')
|
|
87
|
+
) THEN
|
|
88
|
+
RAISE EXCEPTION 'invalid workspace grant shape' USING ERRCODE = '22023';
|
|
89
|
+
END IF;
|
|
90
|
+
v_project_id := v_grant ->> 'projectId';
|
|
91
|
+
v_capability := v_grant ->> 'capability';
|
|
92
|
+
IF v_project_id IS NULL OR v_project_id !~ '^[a-z][a-z0-9-]{0,99}$'
|
|
93
|
+
OR v_capability NOT IN ('credentials-read', 'assets-read')
|
|
94
|
+
OR jsonb_typeof(v_grant -> 'environments') <> 'array' THEN
|
|
95
|
+
RAISE EXCEPTION 'invalid workspace grant' USING ERRCODE = '22023';
|
|
96
|
+
END IF;
|
|
97
|
+
v_environments := ARRAY(
|
|
98
|
+
SELECT jsonb_array_elements_text(v_grant -> 'environments')
|
|
99
|
+
);
|
|
100
|
+
IF cardinality(v_environments) <> (
|
|
101
|
+
SELECT count(DISTINCT environment.value)::integer
|
|
102
|
+
FROM unnest(v_environments) AS environment(value)
|
|
103
|
+
) OR NOT (v_environments <@ ARRAY['dev', 'e2e']::text[])
|
|
104
|
+
OR (v_capability = 'credentials-read' AND cardinality(v_environments) = 0)
|
|
105
|
+
OR (v_capability = 'assets-read' AND cardinality(v_environments) <> 0) THEN
|
|
106
|
+
RAISE EXCEPTION 'invalid workspace grant environments' USING ERRCODE = '22023';
|
|
107
|
+
END IF;
|
|
108
|
+
v_grant_key := v_project_id || ':' || v_capability;
|
|
109
|
+
IF v_grant_key = ANY(v_seen_grant_keys) THEN
|
|
110
|
+
RAISE EXCEPTION 'duplicate workspace grant' USING ERRCODE = '22023';
|
|
111
|
+
END IF;
|
|
112
|
+
v_seen_grant_keys := array_append(v_seen_grant_keys, v_grant_key);
|
|
113
|
+
v_has_credential_grant := v_has_credential_grant OR v_capability = 'credentials-read';
|
|
114
|
+
v_has_asset_grant := v_has_asset_grant OR v_capability = 'assets-read';
|
|
115
|
+
END LOOP;
|
|
116
|
+
IF v_has_credential_grant <> ('workspace:credentials-read' = ANY(p_scopes))
|
|
117
|
+
OR v_has_asset_grant <> ('workspace:assets-read' = ANY(p_scopes)) THEN
|
|
118
|
+
RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
|
|
119
|
+
END IF;
|
|
120
|
+
|
|
121
|
+
PERFORM t.id
|
|
122
|
+
FROM public.tenants AS t
|
|
123
|
+
WHERE t.id = p_team_tenant_id
|
|
124
|
+
AND t.slug = 'team-semicolon'
|
|
125
|
+
AND t.tenant_type = 'team'
|
|
126
|
+
AND coalesce(t.metadata ->> 'status', 'active') = 'active';
|
|
127
|
+
IF NOT FOUND THEN
|
|
128
|
+
RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
|
|
129
|
+
END IF;
|
|
130
|
+
|
|
131
|
+
SELECT wd.gateway_credential_id
|
|
132
|
+
INTO v_previous_credential_id
|
|
133
|
+
FROM semicolony.worker_devices AS wd
|
|
134
|
+
WHERE wd.device_id = p_device_id
|
|
135
|
+
AND wd.worker_id = p_worker_id
|
|
136
|
+
AND wd.status = 'active'
|
|
137
|
+
AND wd.kb_access_mode = 'worker-gateway'
|
|
138
|
+
AND wd.last_doctor_status = 'pass'
|
|
139
|
+
AND wd.last_seen_at >= clock_timestamp() - interval '24 hours'
|
|
140
|
+
FOR UPDATE;
|
|
141
|
+
IF NOT FOUND THEN
|
|
142
|
+
RAISE EXCEPTION 'active worker device binding not found' USING ERRCODE = 'P0002';
|
|
143
|
+
END IF;
|
|
144
|
+
|
|
145
|
+
INSERT INTO semicolony.gateway_credentials (
|
|
146
|
+
tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
|
|
147
|
+
) VALUES (
|
|
148
|
+
p_team_tenant_id,
|
|
149
|
+
'team-semicolon',
|
|
150
|
+
p_token_hash,
|
|
151
|
+
p_token_prefix,
|
|
152
|
+
p_scopes,
|
|
153
|
+
p_issued_by,
|
|
154
|
+
clock_timestamp() + make_interval(days => p_expires_days),
|
|
155
|
+
jsonb_build_object(
|
|
156
|
+
'credential_kind', 'worker',
|
|
157
|
+
'worker_id', p_worker_id,
|
|
158
|
+
'device_id', p_device_id,
|
|
159
|
+
'tenant_anchor', 'team-semicolon',
|
|
160
|
+
'lineage_status', 'active'
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
RETURNING id INTO v_credential_id;
|
|
164
|
+
|
|
165
|
+
INSERT INTO semicolony.workspace_projection_grants (
|
|
166
|
+
gateway_credential_id, project_id, capability, environments
|
|
167
|
+
)
|
|
168
|
+
SELECT
|
|
169
|
+
v_credential_id,
|
|
170
|
+
item.value ->> 'projectId',
|
|
171
|
+
item.value ->> 'capability',
|
|
172
|
+
ARRAY(SELECT jsonb_array_elements_text(item.value -> 'environments'))
|
|
173
|
+
FROM jsonb_array_elements(v_workspace_grants) AS item(value);
|
|
174
|
+
|
|
175
|
+
IF v_previous_credential_id IS NOT NULL THEN
|
|
176
|
+
UPDATE semicolony.gateway_credentials
|
|
177
|
+
SET status = 'revoked',
|
|
178
|
+
revoked_at = clock_timestamp(),
|
|
179
|
+
revoked_reason = 'worker credential rotated'
|
|
180
|
+
WHERE id = v_previous_credential_id
|
|
181
|
+
AND status = 'active';
|
|
182
|
+
END IF;
|
|
183
|
+
|
|
184
|
+
UPDATE semicolony.worker_devices
|
|
185
|
+
SET gateway_credential_id = v_credential_id
|
|
186
|
+
WHERE device_id = p_device_id
|
|
187
|
+
AND worker_id = p_worker_id;
|
|
188
|
+
|
|
189
|
+
RETURN v_credential_id;
|
|
190
|
+
END;
|
|
191
|
+
$function$;
|
|
192
|
+
|
|
193
|
+
REVOKE ALL ON FUNCTION semicolony.issue_worker_gateway_credential(
|
|
194
|
+
uuid, text, text, text, text, text[], text, integer, jsonb
|
|
195
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
196
|
+
GRANT EXECUTE ON FUNCTION semicolony.issue_worker_gateway_credential(
|
|
197
|
+
uuid, text, text, text, text, text[], text, integer, jsonb
|
|
198
|
+
) TO sc_app;
|
|
199
|
+
|
|
200
|
+
UPDATE semicolony.gateway_credentials AS gc
|
|
201
|
+
SET scopes = array_append(gc.scopes, 'feedback:write')
|
|
202
|
+
FROM semicolony.worker_devices AS wd
|
|
203
|
+
WHERE wd.gateway_credential_id = gc.id
|
|
204
|
+
AND wd.status = 'active'
|
|
205
|
+
AND wd.kb_access_mode = 'worker-gateway'
|
|
206
|
+
AND gc.status = 'active'
|
|
207
|
+
AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
|
|
208
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
209
|
+
AND NOT ('feedback:write' = ANY(gc.scopes));
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
-- 180_worker_dashboard_owner_credentials.sql
|
|
2
|
+
--
|
|
3
|
+
-- SEMO Ops dashboard self-service worker registration and credential access.
|
|
4
|
+
--
|
|
5
|
+
-- Policy:
|
|
6
|
+
-- - Approved admins manage every worker.
|
|
7
|
+
-- - Approved members manage only devices bound to their Supabase auth user id.
|
|
8
|
+
-- - New dashboard-issued worker keys remain viewable until credential sealing lands.
|
|
9
|
+
--
|
|
10
|
+
-- Existing credentials only contain a SHA-256 hash and cannot be recovered. They are
|
|
11
|
+
-- intentionally left without a secret row and must be rotated from the dashboard.
|
|
12
|
+
-- Safety: additive except for extending the fleet audit CHECK constraint.
|
|
13
|
+
|
|
14
|
+
ALTER TABLE semo_ops.worker_devices
|
|
15
|
+
ADD COLUMN IF NOT EXISTS owner_auth_user_id UUID;
|
|
16
|
+
|
|
17
|
+
COMMENT ON COLUMN semo_ops.worker_devices.owner_auth_user_id IS
|
|
18
|
+
'Supabase auth user that owns this device. NULL means admin-only legacy/unclaimed device.';
|
|
19
|
+
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_worker_devices_owner
|
|
21
|
+
ON semo_ops.worker_devices (owner_auth_user_id, status, device_id);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS semo_ops.worker_credential_secrets (
|
|
24
|
+
credential_id UUID PRIMARY KEY
|
|
25
|
+
REFERENCES semo_ops.gateway_credentials(id) ON DELETE CASCADE,
|
|
26
|
+
device_id TEXT NOT NULL
|
|
27
|
+
REFERENCES semo_ops.worker_devices(device_id) ON DELETE CASCADE,
|
|
28
|
+
plaintext_token TEXT NOT NULL CHECK (plaintext_token ~ '^wck_[a-z][a-z0-9-]{0,63}_[A-Za-z0-9_-]{32,64}$'),
|
|
29
|
+
created_by TEXT NOT NULL,
|
|
30
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
31
|
+
sealed_at TIMESTAMPTZ
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
COMMENT ON TABLE semo_ops.worker_credential_secrets IS
|
|
35
|
+
'Temporary unsealed dashboard worker-key store. Replace plaintext_token with sealed material in the planned sealing migration.';
|
|
36
|
+
COMMENT ON COLUMN semo_ops.worker_credential_secrets.plaintext_token IS
|
|
37
|
+
'Explicitly viewable to the authenticated owner/admin until credential sealing is implemented.';
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_worker_credential_secrets_device
|
|
40
|
+
ON semo_ops.worker_credential_secrets (device_id, created_at DESC);
|
|
41
|
+
|
|
42
|
+
-- Extend the append-only audit vocabulary for the new dashboard operations.
|
|
43
|
+
ALTER TABLE semo_ops.worker_fleet_audits
|
|
44
|
+
DROP CONSTRAINT IF EXISTS worker_fleet_audits_action_check;
|
|
45
|
+
ALTER TABLE semo_ops.worker_fleet_audits
|
|
46
|
+
ADD CONSTRAINT worker_fleet_audits_action_check CHECK (
|
|
47
|
+
action IN (
|
|
48
|
+
'device.status', 'local_hive.status', 'local_hive.expiry', 'release.activate',
|
|
49
|
+
'worker.register', 'worker.owner', 'credential.issue'
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
ALTER TABLE semo_ops.worker_fleet_audits
|
|
54
|
+
DROP CONSTRAINT IF EXISTS worker_fleet_audits_target_kind_check;
|
|
55
|
+
ALTER TABLE semo_ops.worker_fleet_audits
|
|
56
|
+
ADD CONSTRAINT worker_fleet_audits_target_kind_check CHECK (
|
|
57
|
+
target_kind IN ('device', 'local_hive', 'profile', 'credential')
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE OR REPLACE FUNCTION semo_ops.register_dashboard_worker(
|
|
61
|
+
p_actor_user_id UUID,
|
|
62
|
+
p_actor_is_admin BOOLEAN,
|
|
63
|
+
p_actor_email TEXT,
|
|
64
|
+
p_owner_auth_user_id UUID,
|
|
65
|
+
p_worker_id TEXT,
|
|
66
|
+
p_device_id TEXT,
|
|
67
|
+
p_display_name TEXT,
|
|
68
|
+
p_hostname TEXT,
|
|
69
|
+
p_os TEXT
|
|
70
|
+
)
|
|
71
|
+
RETURNS VOID
|
|
72
|
+
LANGUAGE plpgsql
|
|
73
|
+
SECURITY DEFINER
|
|
74
|
+
SET search_path = pg_catalog
|
|
75
|
+
AS $function$
|
|
76
|
+
BEGIN
|
|
77
|
+
IF p_actor_user_id IS NULL OR p_owner_auth_user_id IS NULL
|
|
78
|
+
OR (NOT p_actor_is_admin AND p_actor_user_id <> p_owner_auth_user_id) THEN
|
|
79
|
+
RAISE EXCEPTION 'worker owner is outside the actor scope' USING ERRCODE = '42501';
|
|
80
|
+
END IF;
|
|
81
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
82
|
+
OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
|
|
83
|
+
RAISE EXCEPTION 'invalid worker/device id' USING ERRCODE = '22023';
|
|
84
|
+
END IF;
|
|
85
|
+
IF p_actor_email IS NULL OR btrim(p_actor_email) = '' OR char_length(p_actor_email) > 320 THEN
|
|
86
|
+
RAISE EXCEPTION 'invalid actor email' USING ERRCODE = '22023';
|
|
87
|
+
END IF;
|
|
88
|
+
|
|
89
|
+
INSERT INTO semo_ops.worker_devices (
|
|
90
|
+
device_id, worker_id, display_name, hostname, os, owner_auth_user_id,
|
|
91
|
+
installed_profile_id, status, last_doctor_status, metadata
|
|
92
|
+
) VALUES (
|
|
93
|
+
p_device_id, p_worker_id, nullif(btrim(p_display_name), ''),
|
|
94
|
+
nullif(btrim(p_hostname), ''), nullif(btrim(p_os), ''), p_owner_auth_user_id,
|
|
95
|
+
'semo-ops-worker', 'pending-setup', 'pending',
|
|
96
|
+
jsonb_build_object('registered_via', 'dashboard')
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
INSERT INTO semo_ops.worker_fleet_audits
|
|
100
|
+
(actor_email, action, target_kind, target_id, before, after)
|
|
101
|
+
VALUES (
|
|
102
|
+
p_actor_email, 'worker.register', 'device', p_device_id, '{}'::jsonb,
|
|
103
|
+
jsonb_build_object('worker_id', p_worker_id, 'owner_auth_user_id', p_owner_auth_user_id)
|
|
104
|
+
);
|
|
105
|
+
END;
|
|
106
|
+
$function$;
|
|
107
|
+
|
|
108
|
+
CREATE OR REPLACE FUNCTION semo_ops.set_dashboard_worker_owner(
|
|
109
|
+
p_actor_is_admin BOOLEAN,
|
|
110
|
+
p_actor_email TEXT,
|
|
111
|
+
p_device_id TEXT,
|
|
112
|
+
p_owner_auth_user_id UUID
|
|
113
|
+
)
|
|
114
|
+
RETURNS VOID
|
|
115
|
+
LANGUAGE plpgsql
|
|
116
|
+
SECURITY DEFINER
|
|
117
|
+
SET search_path = pg_catalog
|
|
118
|
+
AS $function$
|
|
119
|
+
DECLARE
|
|
120
|
+
v_previous_owner UUID;
|
|
121
|
+
BEGIN
|
|
122
|
+
IF NOT p_actor_is_admin THEN
|
|
123
|
+
RAISE EXCEPTION 'admin role required' USING ERRCODE = '42501';
|
|
124
|
+
END IF;
|
|
125
|
+
SELECT owner_auth_user_id INTO v_previous_owner
|
|
126
|
+
FROM semo_ops.worker_devices
|
|
127
|
+
WHERE device_id = p_device_id
|
|
128
|
+
FOR UPDATE;
|
|
129
|
+
IF NOT FOUND THEN
|
|
130
|
+
RAISE EXCEPTION 'device not found' USING ERRCODE = 'P0002';
|
|
131
|
+
END IF;
|
|
132
|
+
|
|
133
|
+
UPDATE semo_ops.worker_devices
|
|
134
|
+
SET owner_auth_user_id = p_owner_auth_user_id
|
|
135
|
+
WHERE device_id = p_device_id;
|
|
136
|
+
|
|
137
|
+
INSERT INTO semo_ops.worker_fleet_audits
|
|
138
|
+
(actor_email, action, target_kind, target_id, before, after)
|
|
139
|
+
VALUES (
|
|
140
|
+
p_actor_email, 'worker.owner', 'device', p_device_id,
|
|
141
|
+
jsonb_build_object('owner_auth_user_id', v_previous_owner),
|
|
142
|
+
jsonb_build_object('owner_auth_user_id', p_owner_auth_user_id)
|
|
143
|
+
);
|
|
144
|
+
END;
|
|
145
|
+
$function$;
|
|
146
|
+
|
|
147
|
+
CREATE OR REPLACE FUNCTION semo_ops.issue_dashboard_worker_credential(
|
|
148
|
+
p_actor_user_id UUID,
|
|
149
|
+
p_actor_is_admin BOOLEAN,
|
|
150
|
+
p_actor_email TEXT,
|
|
151
|
+
p_device_id TEXT,
|
|
152
|
+
p_plaintext_token TEXT,
|
|
153
|
+
p_token_hash TEXT,
|
|
154
|
+
p_token_prefix TEXT,
|
|
155
|
+
p_expires_days INTEGER
|
|
156
|
+
)
|
|
157
|
+
RETURNS UUID
|
|
158
|
+
LANGUAGE plpgsql
|
|
159
|
+
SECURITY DEFINER
|
|
160
|
+
SET search_path = pg_catalog
|
|
161
|
+
AS $function$
|
|
162
|
+
DECLARE
|
|
163
|
+
v_worker_id TEXT;
|
|
164
|
+
v_previous_credential_id UUID;
|
|
165
|
+
v_credential_id UUID;
|
|
166
|
+
v_tenant_id UUID;
|
|
167
|
+
v_scopes TEXT[] := ARRAY[
|
|
168
|
+
'kb:read', 'feedback:write', 'skills:read', 'worker:release:read', 'worker:release:report'
|
|
169
|
+
]::TEXT[];
|
|
170
|
+
BEGIN
|
|
171
|
+
IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$'
|
|
172
|
+
OR p_plaintext_token IS NULL
|
|
173
|
+
OR p_plaintext_token !~ '^wck_[a-z][a-z0-9-]{0,63}_[A-Za-z0-9_-]{32,64}$'
|
|
174
|
+
OR p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
|
|
175
|
+
RAISE EXCEPTION 'invalid worker credential material' USING ERRCODE = '22023';
|
|
176
|
+
END IF;
|
|
177
|
+
|
|
178
|
+
SELECT worker_id, gateway_credential_id
|
|
179
|
+
INTO v_worker_id, v_previous_credential_id
|
|
180
|
+
FROM semo_ops.worker_devices
|
|
181
|
+
WHERE device_id = p_device_id
|
|
182
|
+
AND status NOT IN ('revoked', 'retired')
|
|
183
|
+
AND (p_actor_is_admin OR owner_auth_user_id = p_actor_user_id)
|
|
184
|
+
FOR UPDATE;
|
|
185
|
+
IF NOT FOUND THEN
|
|
186
|
+
RAISE EXCEPTION 'manageable worker device not found' USING ERRCODE = '42501';
|
|
187
|
+
END IF;
|
|
188
|
+
|
|
189
|
+
IF p_token_prefix !~ ('^wck_' || v_worker_id || '_[A-Za-z0-9_-]{6}$')
|
|
190
|
+
OR p_plaintext_token !~ ('^wck_' || v_worker_id || '_[A-Za-z0-9_-]{32,64}$') THEN
|
|
191
|
+
RAISE EXCEPTION 'credential is not bound to the worker' USING ERRCODE = '22023';
|
|
192
|
+
END IF;
|
|
193
|
+
|
|
194
|
+
SELECT id INTO v_tenant_id
|
|
195
|
+
FROM public.tenants
|
|
196
|
+
WHERE slug = 'team-semicolon'
|
|
197
|
+
AND tenant_type = 'team'
|
|
198
|
+
AND coalesce(metadata ->> 'status', 'active') = 'active';
|
|
199
|
+
IF NOT FOUND THEN
|
|
200
|
+
RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
|
|
201
|
+
END IF;
|
|
202
|
+
|
|
203
|
+
INSERT INTO semo_ops.gateway_credentials (
|
|
204
|
+
tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
|
|
205
|
+
) VALUES (
|
|
206
|
+
v_tenant_id, 'team-semicolon', p_token_hash, p_token_prefix, v_scopes,
|
|
207
|
+
p_actor_email, clock_timestamp() + make_interval(days => p_expires_days),
|
|
208
|
+
jsonb_build_object(
|
|
209
|
+
'credential_kind', 'worker', 'worker_id', v_worker_id,
|
|
210
|
+
'device_id', p_device_id, 'tenant_anchor', 'team-semicolon',
|
|
211
|
+
'lineage_status', 'active', 'issued_via', 'dashboard'
|
|
212
|
+
)
|
|
213
|
+
) RETURNING id INTO v_credential_id;
|
|
214
|
+
|
|
215
|
+
INSERT INTO semo_ops.worker_credential_secrets
|
|
216
|
+
(credential_id, device_id, plaintext_token, created_by)
|
|
217
|
+
VALUES (v_credential_id, p_device_id, p_plaintext_token, p_actor_email);
|
|
218
|
+
|
|
219
|
+
IF v_previous_credential_id IS NOT NULL THEN
|
|
220
|
+
UPDATE semo_ops.gateway_credentials
|
|
221
|
+
SET status = 'revoked', revoked_at = clock_timestamp(),
|
|
222
|
+
revoked_reason = 'worker credential rotated from dashboard'
|
|
223
|
+
WHERE id = v_previous_credential_id AND status = 'active';
|
|
224
|
+
END IF;
|
|
225
|
+
|
|
226
|
+
UPDATE semo_ops.worker_devices
|
|
227
|
+
SET gateway_credential_id = v_credential_id,
|
|
228
|
+
status = CASE WHEN status = 'pending-auth' THEN 'pending-setup' ELSE status END
|
|
229
|
+
WHERE device_id = p_device_id;
|
|
230
|
+
|
|
231
|
+
INSERT INTO semo_ops.worker_fleet_audits
|
|
232
|
+
(actor_email, action, target_kind, target_id, before, after)
|
|
233
|
+
VALUES (
|
|
234
|
+
p_actor_email, 'credential.issue', 'credential', v_credential_id::TEXT,
|
|
235
|
+
jsonb_build_object('previous_credential_id', v_previous_credential_id),
|
|
236
|
+
jsonb_build_object('device_id', p_device_id, 'token_prefix', p_token_prefix)
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
RETURN v_credential_id;
|
|
240
|
+
END;
|
|
241
|
+
$function$;
|
|
242
|
+
|
|
243
|
+
REVOKE ALL ON TABLE semo_ops.worker_credential_secrets FROM PUBLIC, sc_app, sc_provider;
|
|
244
|
+
GRANT SELECT ON TABLE semo_ops.worker_credential_secrets TO sc_fleet_board;
|
|
245
|
+
|
|
246
|
+
REVOKE ALL ON FUNCTION semo_ops.register_dashboard_worker(
|
|
247
|
+
UUID, BOOLEAN, TEXT, UUID, TEXT, TEXT, TEXT, TEXT, TEXT
|
|
248
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
249
|
+
REVOKE ALL ON FUNCTION semo_ops.set_dashboard_worker_owner(
|
|
250
|
+
BOOLEAN, TEXT, TEXT, UUID
|
|
251
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
252
|
+
REVOKE ALL ON FUNCTION semo_ops.issue_dashboard_worker_credential(
|
|
253
|
+
UUID, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER
|
|
254
|
+
) FROM PUBLIC, sc_app, sc_provider;
|
|
255
|
+
|
|
256
|
+
GRANT EXECUTE ON FUNCTION semo_ops.register_dashboard_worker(
|
|
257
|
+
UUID, BOOLEAN, TEXT, UUID, TEXT, TEXT, TEXT, TEXT, TEXT
|
|
258
|
+
) TO sc_fleet_board;
|
|
259
|
+
GRANT EXECUTE ON FUNCTION semo_ops.set_dashboard_worker_owner(
|
|
260
|
+
BOOLEAN, TEXT, TEXT, UUID
|
|
261
|
+
) TO sc_fleet_board;
|
|
262
|
+
GRANT EXECUTE ON FUNCTION semo_ops.issue_dashboard_worker_credential(
|
|
263
|
+
UUID, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER
|
|
264
|
+
) TO sc_fleet_board;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- @schema-retarget: off
|
|
2
|
+
-- Make activated immutable DB releases the authoring authority for SEMO Ops
|
|
3
|
+
-- common skills while preserving existing Git-pinned releases as readable
|
|
4
|
+
-- legacy provenance. The migration runner owns the transaction boundary.
|
|
5
|
+
|
|
6
|
+
ALTER TABLE semicolony.skill_releases
|
|
7
|
+
ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'git-legacy',
|
|
8
|
+
ADD COLUMN base_release_id UUID,
|
|
9
|
+
ADD COLUMN authoring_task_id TEXT;
|
|
10
|
+
|
|
11
|
+
ALTER TABLE semicolony.skill_releases
|
|
12
|
+
ALTER COLUMN source_repository DROP NOT NULL,
|
|
13
|
+
ALTER COLUMN git_commit_sha DROP NOT NULL,
|
|
14
|
+
ALTER COLUMN git_tree_oid DROP NOT NULL;
|
|
15
|
+
|
|
16
|
+
ALTER TABLE semicolony.skill_releases
|
|
17
|
+
ADD CONSTRAINT skill_releases_source_kind_check
|
|
18
|
+
CHECK (source_kind IN ('db', 'git-legacy')),
|
|
19
|
+
ADD CONSTRAINT skill_releases_base_release_fk
|
|
20
|
+
FOREIGN KEY (base_release_id)
|
|
21
|
+
REFERENCES semicolony.skill_releases (release_id),
|
|
22
|
+
ADD CONSTRAINT skill_releases_not_own_base_check
|
|
23
|
+
CHECK (base_release_id IS NULL OR base_release_id <> release_id),
|
|
24
|
+
ADD CONSTRAINT skill_releases_provenance_check
|
|
25
|
+
CHECK (
|
|
26
|
+
(
|
|
27
|
+
source_kind = 'db'
|
|
28
|
+
AND source_repository IS NULL
|
|
29
|
+
AND git_commit_sha IS NULL
|
|
30
|
+
AND git_tree_oid IS NULL
|
|
31
|
+
AND NULLIF(btrim(authoring_task_id), '') IS NOT NULL
|
|
32
|
+
)
|
|
33
|
+
OR
|
|
34
|
+
(
|
|
35
|
+
source_kind = 'git-legacy'
|
|
36
|
+
AND NULLIF(btrim(source_repository), '') IS NOT NULL
|
|
37
|
+
AND git_commit_sha IS NOT NULL
|
|
38
|
+
AND git_tree_oid IS NOT NULL
|
|
39
|
+
AND base_release_id IS NULL
|
|
40
|
+
AND authoring_task_id IS NULL
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE UNIQUE INDEX skill_releases_db_authoring_task_unique
|
|
45
|
+
ON semicolony.skill_releases (channel, skill_name, authoring_task_id)
|
|
46
|
+
WHERE source_kind = 'db';
|
|
47
|
+
|
|
48
|
+
CREATE INDEX skill_releases_base_release_idx
|
|
49
|
+
ON semicolony.skill_releases (base_release_id)
|
|
50
|
+
WHERE base_release_id IS NOT NULL;
|
|
51
|
+
|
|
52
|
+
COMMENT ON COLUMN semicolony.skill_releases.source_kind IS
|
|
53
|
+
'git-legacy for pre-DB authoring provenance; db for immutable release-to-release authoring.';
|
|
54
|
+
COMMENT ON COLUMN semicolony.skill_releases.base_release_id IS
|
|
55
|
+
'Exact active DB release used as the edit base; NULL only for a DB-native create.';
|
|
56
|
+
COMMENT ON COLUMN semicolony.skill_releases.authoring_task_id IS
|
|
57
|
+
'Task identity that produced a DB-native common-skill release.';
|