@team-semicolon/semicolony-cli 4.18.104 → 4.18.105
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,85 @@
|
|
|
1
|
+
-- 198_meeting_record_canonical.sql
|
|
2
|
+
--
|
|
3
|
+
-- Make semo_ops.meetings the canonical meeting record.
|
|
4
|
+
--
|
|
5
|
+
-- The table was built in 2026-03 for a VITO STT + Notion pipeline and was
|
|
6
|
+
-- abandoned with it: 7 rows, none after 2026-04. The record moved to GitHub
|
|
7
|
+
-- Discussions, which the headless workstation cannot write — it holds no GitHub
|
|
8
|
+
-- credential, so 0 discussions were published after 2026-08-06 while the skill
|
|
9
|
+
-- correctly reported `not-published` and Slack thread delivery masked it. When
|
|
10
|
+
-- the router timed out on 2026-08-12 that surface went too and the minutes
|
|
11
|
+
-- survived only in the worker's in-memory job result.
|
|
12
|
+
--
|
|
13
|
+
-- This restores the table rather than adding a second one: `target_domain` is
|
|
14
|
+
-- already an ontology FK and `attendees` is already jsonb, which is the shape
|
|
15
|
+
-- the action-item linkage needs.
|
|
16
|
+
--
|
|
17
|
+
-- Design: docs/superpowers/specs/2026-08-12-meeting-record-db-design.md (semo-ops)
|
|
18
|
+
--
|
|
19
|
+
-- Safety: additive except for five dead VITO/Notion columns. `audio_data` is
|
|
20
|
+
-- deliberately NOT dropped — see the constraint at the bottom.
|
|
21
|
+
-- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
|
|
22
|
+
-- retargets the `semicolony.` qualifier to the active schema.
|
|
23
|
+
|
|
24
|
+
ALTER TABLE semicolony.meetings
|
|
25
|
+
ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'restricted',
|
|
26
|
+
ADD COLUMN IF NOT EXISTS record_body text,
|
|
27
|
+
ADD COLUMN IF NOT EXISTS record_source text;
|
|
28
|
+
|
|
29
|
+
-- The default is the closed value on purpose. A meeting wrongly hidden from the
|
|
30
|
+
-- team is recoverable; a client negotiation wrongly exposed to it is not.
|
|
31
|
+
ALTER TABLE semicolony.meetings
|
|
32
|
+
DROP CONSTRAINT IF EXISTS meetings_visibility_check;
|
|
33
|
+
ALTER TABLE semicolony.meetings
|
|
34
|
+
ADD CONSTRAINT meetings_visibility_check
|
|
35
|
+
CHECK (visibility IN ('team', 'restricted'));
|
|
36
|
+
|
|
37
|
+
ALTER TABLE semicolony.meetings
|
|
38
|
+
DROP CONSTRAINT IF EXISTS meetings_record_source_check;
|
|
39
|
+
ALTER TABLE semicolony.meetings
|
|
40
|
+
ADD CONSTRAINT meetings_record_source_check
|
|
41
|
+
CHECK (record_source IS NULL
|
|
42
|
+
OR record_source IN ('slack-worker', 'operator', 'backfill-discussion'));
|
|
43
|
+
|
|
44
|
+
-- The linkage this change exists for. Nullable: the 1,760 existing action items
|
|
45
|
+
-- predate the meetings table and must not be invented a source.
|
|
46
|
+
ALTER TABLE semicolony.action_items
|
|
47
|
+
ADD COLUMN IF NOT EXISTS meeting_id uuid REFERENCES semicolony.meetings(meeting_id);
|
|
48
|
+
|
|
49
|
+
-- Dead pipeline. VITO was replaced by the hive whisper backend, and Notion sync
|
|
50
|
+
-- was retired with the same pipeline.
|
|
51
|
+
ALTER TABLE semicolony.meetings
|
|
52
|
+
DROP COLUMN IF EXISTS vito_transcribe_id,
|
|
53
|
+
DROP COLUMN IF EXISTS notion_page_id,
|
|
54
|
+
DROP COLUMN IF EXISTS notion_url,
|
|
55
|
+
DROP COLUMN IF EXISTS notion_sync_status,
|
|
56
|
+
DROP COLUMN IF EXISTS notion_sync_error;
|
|
57
|
+
|
|
58
|
+
-- audio_data is NOT dropped.
|
|
59
|
+
--
|
|
60
|
+
-- The design called for dropping it: recordings are restricted material and the
|
|
61
|
+
-- worker deletes them once the transcript exists, so a column that stores them
|
|
62
|
+
-- permanently contradicts the policy it serves. But the pre-flight audit found
|
|
63
|
+
-- 2 of the 7 rows still holding bytes — 스페이스씨엘 회의 (2026-03-19, 7.6 MB) and
|
|
64
|
+
-- 스타스팟 회의 (2026-03-28, 6.6 MB), 14 MB total. Neither has a Discussion, so
|
|
65
|
+
-- these are the only copies in existence. Dropping the column would destroy
|
|
66
|
+
-- restricted material that no one has decided to dispose of.
|
|
67
|
+
--
|
|
68
|
+
-- The policy contradiction is closed without destroying anything: NOT VALID
|
|
69
|
+
-- enforces the rule on every future insert and update while tolerating the two
|
|
70
|
+
-- existing rows. Disposal stays a separate, explicit decision. Validate this
|
|
71
|
+
-- constraint and drop the column once those two recordings are dealt with.
|
|
72
|
+
ALTER TABLE semicolony.meetings
|
|
73
|
+
DROP CONSTRAINT IF EXISTS meetings_audio_data_not_stored;
|
|
74
|
+
ALTER TABLE semicolony.meetings
|
|
75
|
+
ADD CONSTRAINT meetings_audio_data_not_stored
|
|
76
|
+
CHECK (audio_data IS NULL) NOT VALID;
|
|
77
|
+
|
|
78
|
+
-- Idempotency key for record_meeting. A teammate re-requesting a meeting is
|
|
79
|
+
-- normal behaviour — on 2026-08-12 one did, and an already-successful 190-minute
|
|
80
|
+
-- transcription ran a second time. It must not also create a second row.
|
|
81
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_date_title
|
|
82
|
+
ON semicolony.meetings (meeting_date, lower(regexp_replace(title, '\s+', ' ', 'g')));
|
|
83
|
+
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_action_items_meeting
|
|
85
|
+
ON semicolony.action_items (meeting_id) WHERE meeting_id IS NOT NULL;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
-- 199_meeting_record_capability.sql
|
|
2
|
+
--
|
|
3
|
+
-- One narrowly-scoped write primitive for a meeting record, modelled on
|
|
4
|
+
-- `178_feedback_capture_capability.sql`. `sc_app` receives EXECUTE only: it
|
|
5
|
+
-- still has no general DML on meetings, action_items, or knowledge_base.
|
|
6
|
+
--
|
|
7
|
+
-- The function records the meeting, projects the body into the Knowledge Fabric,
|
|
8
|
+
-- and creates the meeting's action items in one transaction. Action items belong
|
|
9
|
+
-- here rather than behind a second endpoint because `semo action-items` needs a
|
|
10
|
+
-- DATABASE_URL and answers `DB 연결 실패` on the workstation — the headless path
|
|
11
|
+
-- has never created one, which is why 1,760 action items carry no meeting. Doing
|
|
12
|
+
-- both here also makes them atomic: a record cannot land with its follow-ups
|
|
13
|
+
-- silently missing.
|
|
14
|
+
--
|
|
15
|
+
-- Restriction reuses the existing mechanism rather than inventing one. The
|
|
16
|
+
-- `knowledge_base_hide_restricted` policy hides a row from sc_app when
|
|
17
|
+
-- `metadata -> 'classification' ->> 'sensitivity' = 'restricted'`, so a
|
|
18
|
+
-- restricted meeting is projected with exactly that shape.
|
|
19
|
+
|
|
20
|
+
CREATE OR REPLACE FUNCTION semicolony.record_meeting(
|
|
21
|
+
p_title text,
|
|
22
|
+
p_meeting_date date,
|
|
23
|
+
p_meeting_type text,
|
|
24
|
+
p_adhoc_subtype text,
|
|
25
|
+
p_visibility text,
|
|
26
|
+
p_target_domain text,
|
|
27
|
+
p_attendees jsonb,
|
|
28
|
+
p_record_body text,
|
|
29
|
+
p_transcript text,
|
|
30
|
+
p_action_items jsonb,
|
|
31
|
+
p_record_source text,
|
|
32
|
+
p_worker_id text,
|
|
33
|
+
p_device_id text
|
|
34
|
+
)
|
|
35
|
+
RETURNS TABLE(meeting_id uuid, kb_id bigint, action_item_ids uuid[], duplicate boolean)
|
|
36
|
+
LANGUAGE plpgsql
|
|
37
|
+
SECURITY DEFINER
|
|
38
|
+
SET search_path = pg_catalog
|
|
39
|
+
AS $function$
|
|
40
|
+
DECLARE
|
|
41
|
+
v_meeting_id uuid;
|
|
42
|
+
v_kb_id bigint;
|
|
43
|
+
v_existing uuid;
|
|
44
|
+
v_norm text;
|
|
45
|
+
v_sensitivity text;
|
|
46
|
+
v_sub_key text;
|
|
47
|
+
BEGIN
|
|
48
|
+
IF p_title IS NULL OR length(btrim(p_title)) < 3 THEN
|
|
49
|
+
RAISE EXCEPTION 'title must be at least 3 characters';
|
|
50
|
+
END IF;
|
|
51
|
+
IF p_meeting_date IS NULL THEN
|
|
52
|
+
RAISE EXCEPTION 'meeting_date is required';
|
|
53
|
+
END IF;
|
|
54
|
+
IF p_visibility IS NULL OR p_visibility NOT IN ('team', 'restricted') THEN
|
|
55
|
+
RAISE EXCEPTION 'visibility must be team or restricted, got %', p_visibility;
|
|
56
|
+
END IF;
|
|
57
|
+
IF p_record_source IS NULL
|
|
58
|
+
OR p_record_source NOT IN ('slack-worker', 'operator', 'backfill-discussion') THEN
|
|
59
|
+
RAISE EXCEPTION 'invalid record_source %', p_record_source;
|
|
60
|
+
END IF;
|
|
61
|
+
IF p_record_body IS NULL OR length(btrim(p_record_body)) = 0 THEN
|
|
62
|
+
RAISE EXCEPTION 'record_body is required';
|
|
63
|
+
END IF;
|
|
64
|
+
|
|
65
|
+
v_norm := lower(regexp_replace(btrim(p_title), '\s+', ' ', 'g'));
|
|
66
|
+
v_sensitivity := CASE WHEN p_visibility = 'restricted' THEN 'restricted' ELSE 'internal' END;
|
|
67
|
+
|
|
68
|
+
SELECT m.meeting_id INTO v_existing
|
|
69
|
+
FROM semicolony.meetings m
|
|
70
|
+
WHERE m.meeting_date = p_meeting_date
|
|
71
|
+
AND lower(regexp_replace(m.title, '\s+', ' ', 'g')) = v_norm;
|
|
72
|
+
|
|
73
|
+
IF v_existing IS NOT NULL THEN
|
|
74
|
+
UPDATE semicolony.meetings m
|
|
75
|
+
SET record_body = COALESCE(p_record_body, m.record_body),
|
|
76
|
+
mapped_transcript = COALESCE(p_transcript, m.mapped_transcript),
|
|
77
|
+
visibility = p_visibility,
|
|
78
|
+
record_source = p_record_source,
|
|
79
|
+
target_domain = COALESCE(p_target_domain, m.target_domain),
|
|
80
|
+
attendees = COALESCE(p_attendees, m.attendees),
|
|
81
|
+
updated_at = now()
|
|
82
|
+
WHERE m.meeting_id = v_existing;
|
|
83
|
+
v_meeting_id := v_existing;
|
|
84
|
+
ELSE
|
|
85
|
+
INSERT INTO semicolony.meetings (
|
|
86
|
+
meeting_id, title, meeting_type, adhoc_subtype, meeting_date, attendees,
|
|
87
|
+
visibility, record_body, mapped_transcript, record_source, target_domain,
|
|
88
|
+
transcription_status, speaker_map, created_at, updated_at
|
|
89
|
+
) VALUES (
|
|
90
|
+
gen_random_uuid(), btrim(p_title), p_meeting_type, p_adhoc_subtype, p_meeting_date,
|
|
91
|
+
COALESCE(p_attendees, '[]'::jsonb), p_visibility, p_record_body,
|
|
92
|
+
p_transcript, p_record_source, p_target_domain,
|
|
93
|
+
'completed', '{}'::jsonb, now(), now()
|
|
94
|
+
) RETURNING meetings.meeting_id INTO v_meeting_id;
|
|
95
|
+
END IF;
|
|
96
|
+
|
|
97
|
+
v_sub_key := to_char(p_meeting_date, 'YYYY-MM-DD') || '-' || left(v_norm, 60);
|
|
98
|
+
|
|
99
|
+
INSERT INTO semicolony.knowledge_base (
|
|
100
|
+
domain, key, sub_key, content, metadata, created_by, created_at, updated_at
|
|
101
|
+
) VALUES (
|
|
102
|
+
COALESCE(p_target_domain, 'semicolon'),
|
|
103
|
+
'meeting',
|
|
104
|
+
v_sub_key,
|
|
105
|
+
p_record_body,
|
|
106
|
+
jsonb_build_object(
|
|
107
|
+
'classification', jsonb_build_object('sensitivity', v_sensitivity),
|
|
108
|
+
'meeting_id', v_meeting_id,
|
|
109
|
+
'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
|
|
110
|
+
'record_source', p_record_source
|
|
111
|
+
),
|
|
112
|
+
COALESCE(p_worker_id, 'meeting-worker'),
|
|
113
|
+
now(), now()
|
|
114
|
+
)
|
|
115
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE
|
|
116
|
+
SET content = EXCLUDED.content,
|
|
117
|
+
metadata = semicolony.knowledge_base.metadata || EXCLUDED.metadata,
|
|
118
|
+
updated_at = now()
|
|
119
|
+
RETURNING semicolony.knowledge_base.kb_id INTO v_kb_id;
|
|
120
|
+
|
|
121
|
+
-- Re-recording the same meeting must not duplicate its follow-ups. Existing
|
|
122
|
+
-- machine-created items for this meeting are replaced; anything a human filed
|
|
123
|
+
-- separately carries no meeting_id and is untouched.
|
|
124
|
+
IF p_action_items IS NOT NULL AND jsonb_typeof(p_action_items) = 'array'
|
|
125
|
+
AND jsonb_array_length(p_action_items) > 0 THEN
|
|
126
|
+
DELETE FROM semicolony.action_items a WHERE a.meeting_id = v_meeting_id;
|
|
127
|
+
|
|
128
|
+
INSERT INTO semicolony.action_items (
|
|
129
|
+
owner_domain, target_domain, description, assignee, deadline,
|
|
130
|
+
priority, status, source, meeting_id, created_at, updated_at
|
|
131
|
+
)
|
|
132
|
+
SELECT
|
|
133
|
+
NULLIF(btrim(item->>'owner'), ''),
|
|
134
|
+
NULLIF(btrim(item->>'target'), ''),
|
|
135
|
+
btrim(item->>'description'),
|
|
136
|
+
NULLIF(btrim(item->>'assignee'), ''),
|
|
137
|
+
NULLIF(btrim(item->>'deadline'), '')::date,
|
|
138
|
+
COALESCE(NULLIF(btrim(item->>'priority'), ''), 'normal'),
|
|
139
|
+
'open',
|
|
140
|
+
'meeting',
|
|
141
|
+
v_meeting_id,
|
|
142
|
+
now(), now()
|
|
143
|
+
FROM jsonb_array_elements(p_action_items) AS item
|
|
144
|
+
WHERE COALESCE(btrim(item->>'description'), '') <> '';
|
|
145
|
+
END IF;
|
|
146
|
+
|
|
147
|
+
RETURN QUERY
|
|
148
|
+
SELECT v_meeting_id,
|
|
149
|
+
v_kb_id,
|
|
150
|
+
COALESCE(ARRAY(SELECT a.action_item_id
|
|
151
|
+
FROM semicolony.action_items a
|
|
152
|
+
WHERE a.meeting_id = v_meeting_id
|
|
153
|
+
ORDER BY a.created_at), '{}'::uuid[]),
|
|
154
|
+
(v_existing IS NOT NULL);
|
|
155
|
+
END;
|
|
156
|
+
$function$;
|
|
157
|
+
|
|
158
|
+
REVOKE ALL ON FUNCTION semicolony.record_meeting(
|
|
159
|
+
text, date, text, text, text, text, jsonb, text, text, jsonb, text, text, text
|
|
160
|
+
) FROM PUBLIC;
|
|
161
|
+
|
|
162
|
+
GRANT EXECUTE ON FUNCTION semicolony.record_meeting(
|
|
163
|
+
text, date, text, text, text, text, jsonb, text, text, jsonb, text, text, text
|
|
164
|
+
) TO sc_app;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
-- 200_meeting_credential_scope.sql
|
|
2
|
+
--
|
|
3
|
+
-- Allow `meetings:write` on worker gateway credentials and grant it to the one
|
|
4
|
+
-- device that runs the meeting worker.
|
|
5
|
+
--
|
|
6
|
+
-- The workstation records a meeting through the gateway because it holds no
|
|
7
|
+
-- DATABASE_URL by policy and no GitHub credential at all. Without this scope the
|
|
8
|
+
-- capability added in 199 is unreachable from the only host that needs it.
|
|
9
|
+
--
|
|
10
|
+
-- Rollout follows `179_worker_feedback_credential_scope.sql`: extend the scope
|
|
11
|
+
-- allowlist on both governed entry points, then backfill the specific binding.
|
|
12
|
+
-- Unlike 179 the backfill is deliberately narrowed to a single device — only the
|
|
13
|
+
-- workstation runs the meeting worker, and no other worker should gain the
|
|
14
|
+
-- ability to write a meeting record.
|
|
15
|
+
--
|
|
16
|
+
-- The two function bodies below are the live definitions with `meetings:write`
|
|
17
|
+
-- added to the scope allowlist and nothing else changed. They were generated
|
|
18
|
+
-- from pg_get_functiondef rather than retyped so the ~10KB of unrelated
|
|
19
|
+
-- validation logic cannot drift. Replacing a function preserves its existing
|
|
20
|
+
-- privileges, so the ACLs are intentionally left untouched.
|
|
21
|
+
--
|
|
22
|
+
-- Observed while writing this, not fixed here: `workspace:assets-write` is held
|
|
23
|
+
-- by two live credentials but is absent from both allowlists — 192 grants it
|
|
24
|
+
-- through its own dedicated function instead. That makes
|
|
25
|
+
-- `amend_worker_gateway_credential_scopes` unusable on any credential holding it
|
|
26
|
+
-- without silently dropping the scope. Worth closing separately; widening
|
|
27
|
+
-- credential authz beyond this change's need would be the wrong place for it.
|
|
28
|
+
--
|
|
29
|
+
-- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
|
|
30
|
+
-- retargets the `semicolony.` qualifier to the active schema.
|
|
31
|
+
|
|
32
|
+
CREATE OR REPLACE FUNCTION semicolony.issue_worker_gateway_credential(p_team_tenant_id uuid, p_worker_id text, p_device_id text, p_token_hash text, p_token_prefix text, p_scopes text[], p_issued_by text, p_expires_days integer, p_workspace_grants jsonb)
|
|
33
|
+
RETURNS uuid
|
|
34
|
+
LANGUAGE plpgsql
|
|
35
|
+
SECURITY DEFINER
|
|
36
|
+
SET search_path TO 'pg_catalog'
|
|
37
|
+
AS $function$
|
|
38
|
+
DECLARE
|
|
39
|
+
v_credential_id uuid;
|
|
40
|
+
v_previous_credential_id uuid;
|
|
41
|
+
v_workspace_grants jsonb := coalesce(p_workspace_grants, '[]'::jsonb);
|
|
42
|
+
v_grant jsonb;
|
|
43
|
+
v_project_id text;
|
|
44
|
+
v_capability text;
|
|
45
|
+
v_environments text[];
|
|
46
|
+
v_grant_key text;
|
|
47
|
+
v_seen_grant_keys text[] := ARRAY[]::text[];
|
|
48
|
+
v_has_credential_grant boolean := false;
|
|
49
|
+
v_has_asset_grant boolean := false;
|
|
50
|
+
BEGIN
|
|
51
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
52
|
+
OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
|
|
53
|
+
RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
|
|
54
|
+
END IF;
|
|
55
|
+
IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' THEN
|
|
56
|
+
RAISE EXCEPTION 'invalid worker credential token hash' USING ERRCODE = '22023';
|
|
57
|
+
END IF;
|
|
58
|
+
IF p_token_prefix IS NULL
|
|
59
|
+
OR p_token_prefix !~ ('^wck_' || p_worker_id || '_[A-Za-z0-9_-]{6}$') THEN
|
|
60
|
+
RAISE EXCEPTION 'invalid worker credential token prefix' USING ERRCODE = '22023';
|
|
61
|
+
END IF;
|
|
62
|
+
IF p_issued_by IS NULL OR p_issued_by <> btrim(p_issued_by)
|
|
63
|
+
OR btrim(p_issued_by) = '' OR char_length(p_issued_by) > 128
|
|
64
|
+
OR p_issued_by ~ '[[:cntrl:]]' THEN
|
|
65
|
+
RAISE EXCEPTION 'invalid credential issuer' USING ERRCODE = '22023';
|
|
66
|
+
END IF;
|
|
67
|
+
IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
|
|
68
|
+
RAISE EXCEPTION 'worker credential expiry must be between 1 and 90 days' USING ERRCODE = '22023';
|
|
69
|
+
END IF;
|
|
70
|
+
IF p_scopes IS NULL OR cardinality(p_scopes) = 0
|
|
71
|
+
OR EXISTS (
|
|
72
|
+
SELECT 1
|
|
73
|
+
FROM unnest(p_scopes) AS requested(scope)
|
|
74
|
+
WHERE requested.scope IS NULL
|
|
75
|
+
OR requested.scope <> ALL (ARRAY[
|
|
76
|
+
'kb:read',
|
|
77
|
+
'feedback:write',
|
|
78
|
+
'meetings:write',
|
|
79
|
+
'skills:read',
|
|
80
|
+
'skills:write',
|
|
81
|
+
'workspace:credentials-read',
|
|
82
|
+
'workspace:assets-read',
|
|
83
|
+
'worker:release:read',
|
|
84
|
+
'worker:release:report'
|
|
85
|
+
]::text[])
|
|
86
|
+
)
|
|
87
|
+
OR cardinality(p_scopes) <> (
|
|
88
|
+
SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
|
|
89
|
+
)
|
|
90
|
+
OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
|
|
91
|
+
RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
|
|
92
|
+
END IF;
|
|
93
|
+
IF jsonb_typeof(v_workspace_grants) <> 'array' THEN
|
|
94
|
+
RAISE EXCEPTION 'workspace grants must be a JSON array' USING ERRCODE = '22023';
|
|
95
|
+
END IF;
|
|
96
|
+
|
|
97
|
+
FOR v_grant IN SELECT item.value FROM jsonb_array_elements(v_workspace_grants) AS item(value) LOOP
|
|
98
|
+
IF jsonb_typeof(v_grant) <> 'object'
|
|
99
|
+
OR EXISTS (
|
|
100
|
+
SELECT 1
|
|
101
|
+
FROM jsonb_object_keys(v_grant) AS object_key(key)
|
|
102
|
+
WHERE object_key.key NOT IN ('projectId', 'capability', 'environments')
|
|
103
|
+
) THEN
|
|
104
|
+
RAISE EXCEPTION 'invalid workspace grant shape' USING ERRCODE = '22023';
|
|
105
|
+
END IF;
|
|
106
|
+
v_project_id := v_grant ->> 'projectId';
|
|
107
|
+
v_capability := v_grant ->> 'capability';
|
|
108
|
+
IF v_project_id IS NULL OR v_project_id !~ '^[a-z][a-z0-9-]{0,99}$'
|
|
109
|
+
OR v_capability NOT IN ('credentials-read', 'assets-read')
|
|
110
|
+
OR jsonb_typeof(v_grant -> 'environments') <> 'array' THEN
|
|
111
|
+
RAISE EXCEPTION 'invalid workspace grant' USING ERRCODE = '22023';
|
|
112
|
+
END IF;
|
|
113
|
+
v_environments := ARRAY(
|
|
114
|
+
SELECT jsonb_array_elements_text(v_grant -> 'environments')
|
|
115
|
+
);
|
|
116
|
+
IF cardinality(v_environments) <> (
|
|
117
|
+
SELECT count(DISTINCT environment.value)::integer
|
|
118
|
+
FROM unnest(v_environments) AS environment(value)
|
|
119
|
+
) OR NOT (v_environments <@ ARRAY['dev', 'e2e']::text[])
|
|
120
|
+
OR (v_capability = 'credentials-read' AND cardinality(v_environments) = 0)
|
|
121
|
+
OR (v_capability = 'assets-read' AND cardinality(v_environments) <> 0) THEN
|
|
122
|
+
RAISE EXCEPTION 'invalid workspace grant environments' USING ERRCODE = '22023';
|
|
123
|
+
END IF;
|
|
124
|
+
v_grant_key := v_project_id || ':' || v_capability;
|
|
125
|
+
IF v_grant_key = ANY(v_seen_grant_keys) THEN
|
|
126
|
+
RAISE EXCEPTION 'duplicate workspace grant' USING ERRCODE = '22023';
|
|
127
|
+
END IF;
|
|
128
|
+
v_seen_grant_keys := array_append(v_seen_grant_keys, v_grant_key);
|
|
129
|
+
v_has_credential_grant := v_has_credential_grant OR v_capability = 'credentials-read';
|
|
130
|
+
v_has_asset_grant := v_has_asset_grant OR v_capability = 'assets-read';
|
|
131
|
+
END LOOP;
|
|
132
|
+
IF v_has_credential_grant <> ('workspace:credentials-read' = ANY(p_scopes))
|
|
133
|
+
OR v_has_asset_grant <> ('workspace:assets-read' = ANY(p_scopes)) THEN
|
|
134
|
+
RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
|
|
135
|
+
END IF;
|
|
136
|
+
|
|
137
|
+
PERFORM t.id
|
|
138
|
+
FROM public.tenants AS t
|
|
139
|
+
WHERE t.id = p_team_tenant_id
|
|
140
|
+
AND t.slug = 'team-semicolon'
|
|
141
|
+
AND t.tenant_type = 'team'
|
|
142
|
+
AND coalesce(t.metadata ->> 'status', 'active') = 'active';
|
|
143
|
+
IF NOT FOUND THEN
|
|
144
|
+
RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
|
|
145
|
+
END IF;
|
|
146
|
+
|
|
147
|
+
SELECT wd.gateway_credential_id
|
|
148
|
+
INTO v_previous_credential_id
|
|
149
|
+
FROM semicolony.worker_devices AS wd
|
|
150
|
+
WHERE wd.device_id = p_device_id
|
|
151
|
+
AND wd.worker_id = p_worker_id
|
|
152
|
+
AND wd.status = 'active'
|
|
153
|
+
AND wd.kb_access_mode = 'worker-gateway'
|
|
154
|
+
AND wd.last_doctor_status = 'pass'
|
|
155
|
+
AND wd.last_seen_at >= clock_timestamp() - interval '24 hours'
|
|
156
|
+
FOR UPDATE;
|
|
157
|
+
IF NOT FOUND THEN
|
|
158
|
+
RAISE EXCEPTION 'active worker device binding not found' USING ERRCODE = 'P0002';
|
|
159
|
+
END IF;
|
|
160
|
+
|
|
161
|
+
INSERT INTO semicolony.gateway_credentials (
|
|
162
|
+
tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
|
|
163
|
+
) VALUES (
|
|
164
|
+
p_team_tenant_id,
|
|
165
|
+
'team-semicolon',
|
|
166
|
+
p_token_hash,
|
|
167
|
+
p_token_prefix,
|
|
168
|
+
p_scopes,
|
|
169
|
+
p_issued_by,
|
|
170
|
+
clock_timestamp() + make_interval(days => p_expires_days),
|
|
171
|
+
jsonb_build_object(
|
|
172
|
+
'credential_kind', 'worker',
|
|
173
|
+
'worker_id', p_worker_id,
|
|
174
|
+
'device_id', p_device_id,
|
|
175
|
+
'tenant_anchor', 'team-semicolon',
|
|
176
|
+
'lineage_status', 'active'
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
RETURNING id INTO v_credential_id;
|
|
180
|
+
|
|
181
|
+
INSERT INTO semicolony.workspace_projection_grants (
|
|
182
|
+
gateway_credential_id, project_id, capability, environments
|
|
183
|
+
)
|
|
184
|
+
SELECT
|
|
185
|
+
v_credential_id,
|
|
186
|
+
item.value ->> 'projectId',
|
|
187
|
+
item.value ->> 'capability',
|
|
188
|
+
ARRAY(SELECT jsonb_array_elements_text(item.value -> 'environments'))
|
|
189
|
+
FROM jsonb_array_elements(v_workspace_grants) AS item(value);
|
|
190
|
+
|
|
191
|
+
IF v_previous_credential_id IS NOT NULL THEN
|
|
192
|
+
UPDATE semicolony.gateway_credentials
|
|
193
|
+
SET status = 'revoked',
|
|
194
|
+
revoked_at = clock_timestamp(),
|
|
195
|
+
revoked_reason = 'worker credential rotated'
|
|
196
|
+
WHERE id = v_previous_credential_id
|
|
197
|
+
AND status = 'active';
|
|
198
|
+
END IF;
|
|
199
|
+
|
|
200
|
+
UPDATE semicolony.worker_devices
|
|
201
|
+
SET gateway_credential_id = v_credential_id
|
|
202
|
+
WHERE device_id = p_device_id
|
|
203
|
+
AND worker_id = p_worker_id;
|
|
204
|
+
|
|
205
|
+
RETURN v_credential_id;
|
|
206
|
+
END;
|
|
207
|
+
$function$;
|
|
208
|
+
|
|
209
|
+
CREATE OR REPLACE FUNCTION semicolony.amend_worker_gateway_credential_scopes(p_credential_id uuid, p_scopes text[], p_amended_by text, p_reason text)
|
|
210
|
+
RETURNS uuid
|
|
211
|
+
LANGUAGE plpgsql
|
|
212
|
+
SECURITY DEFINER
|
|
213
|
+
SET search_path TO 'pg_catalog'
|
|
214
|
+
AS $function$
|
|
215
|
+
DECLARE
|
|
216
|
+
v_amendment_id uuid;
|
|
217
|
+
v_previous_scopes text[];
|
|
218
|
+
v_metadata jsonb;
|
|
219
|
+
v_has_credential_grant boolean;
|
|
220
|
+
v_has_asset_grant boolean;
|
|
221
|
+
BEGIN
|
|
222
|
+
IF p_credential_id IS NULL THEN
|
|
223
|
+
RAISE EXCEPTION 'credential id is required' USING ERRCODE = '22023';
|
|
224
|
+
END IF;
|
|
225
|
+
IF NULLIF(btrim(coalesce(p_amended_by, '')), '') IS NULL
|
|
226
|
+
OR NULLIF(btrim(coalesce(p_reason, '')), '') IS NULL THEN
|
|
227
|
+
RAISE EXCEPTION 'amended_by and reason are required' USING ERRCODE = '22023';
|
|
228
|
+
END IF;
|
|
229
|
+
|
|
230
|
+
-- Same scope contract as issuance. Kept literal rather than shared so a
|
|
231
|
+
-- change to one is a visible, reviewed change to the other.
|
|
232
|
+
IF p_scopes IS NULL OR cardinality(p_scopes) = 0
|
|
233
|
+
OR EXISTS (
|
|
234
|
+
SELECT 1
|
|
235
|
+
FROM unnest(p_scopes) AS requested(scope)
|
|
236
|
+
WHERE requested.scope IS NULL
|
|
237
|
+
OR requested.scope <> ALL (ARRAY[
|
|
238
|
+
'kb:read',
|
|
239
|
+
'feedback:write',
|
|
240
|
+
'meetings:write',
|
|
241
|
+
'skills:read',
|
|
242
|
+
'skills:write',
|
|
243
|
+
'workspace:credentials-read',
|
|
244
|
+
'workspace:assets-read',
|
|
245
|
+
'worker:release:read',
|
|
246
|
+
'worker:release:report'
|
|
247
|
+
]::text[])
|
|
248
|
+
)
|
|
249
|
+
OR cardinality(p_scopes) <> (
|
|
250
|
+
SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
|
|
251
|
+
)
|
|
252
|
+
OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
|
|
253
|
+
RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
|
|
254
|
+
END IF;
|
|
255
|
+
|
|
256
|
+
SELECT gc.scopes, gc.metadata
|
|
257
|
+
INTO v_previous_scopes, v_metadata
|
|
258
|
+
FROM semicolony.gateway_credentials AS gc
|
|
259
|
+
WHERE gc.id = p_credential_id
|
|
260
|
+
AND gc.status = 'active'
|
|
261
|
+
AND gc.revoked_at IS NULL
|
|
262
|
+
AND (gc.expires_at IS NULL OR gc.expires_at > clock_timestamp())
|
|
263
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
264
|
+
FOR UPDATE;
|
|
265
|
+
IF NOT FOUND THEN
|
|
266
|
+
RAISE EXCEPTION 'active worker credential not found' USING ERRCODE = 'P0002';
|
|
267
|
+
END IF;
|
|
268
|
+
|
|
269
|
+
-- Workspace scopes stay bound to real grants, exactly as at issuance.
|
|
270
|
+
SELECT
|
|
271
|
+
bool_or(wpg.capability = 'credentials-read'),
|
|
272
|
+
bool_or(wpg.capability = 'assets-read')
|
|
273
|
+
INTO v_has_credential_grant, v_has_asset_grant
|
|
274
|
+
FROM semicolony.workspace_projection_grants AS wpg
|
|
275
|
+
WHERE wpg.gateway_credential_id = p_credential_id;
|
|
276
|
+
|
|
277
|
+
IF coalesce(v_has_credential_grant, false) <> ('workspace:credentials-read' = ANY(p_scopes))
|
|
278
|
+
OR coalesce(v_has_asset_grant, false) <> ('workspace:assets-read' = ANY(p_scopes)) THEN
|
|
279
|
+
RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
|
|
280
|
+
END IF;
|
|
281
|
+
|
|
282
|
+
INSERT INTO semicolony.gateway_credential_scope_amendments (
|
|
283
|
+
credential_id, previous_scopes, next_scopes, reason, amended_by
|
|
284
|
+
) VALUES (
|
|
285
|
+
p_credential_id, v_previous_scopes, p_scopes, btrim(p_reason), btrim(p_amended_by)
|
|
286
|
+
)
|
|
287
|
+
RETURNING amendment_id INTO v_amendment_id;
|
|
288
|
+
|
|
289
|
+
UPDATE semicolony.gateway_credentials
|
|
290
|
+
SET scopes = p_scopes
|
|
291
|
+
WHERE id = p_credential_id;
|
|
292
|
+
|
|
293
|
+
RETURN v_amendment_id;
|
|
294
|
+
END
|
|
295
|
+
$function$;
|
|
296
|
+
|
|
297
|
+
-- Only the workstation runs the meeting worker. Narrow on purpose: matched by
|
|
298
|
+
-- device id, active worker-kind credential, not already holding the scope.
|
|
299
|
+
UPDATE semicolony.gateway_credentials AS gc
|
|
300
|
+
SET scopes = array_append(gc.scopes, 'meetings:write')
|
|
301
|
+
WHERE gc.status = 'active'
|
|
302
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
303
|
+
AND gc.metadata ->> 'device_id' = 'semicolony-agent'
|
|
304
|
+
AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
|
|
305
|
+
AND NOT ('meetings:write' = ANY(gc.scopes));
|
|
306
|
+
|
|
307
|
+
-- Exactly one credential may hold it. A second would mean the device filter
|
|
308
|
+
-- matched something unintended, and failing here is better than discovering it
|
|
309
|
+
-- when an unexpected host writes a meeting record.
|
|
310
|
+
DO $do$
|
|
311
|
+
DECLARE v_count int;
|
|
312
|
+
BEGIN
|
|
313
|
+
SELECT count(*) INTO v_count
|
|
314
|
+
FROM semicolony.gateway_credentials
|
|
315
|
+
WHERE status = 'active' AND 'meetings:write' = ANY(scopes);
|
|
316
|
+
IF v_count <> 1 THEN
|
|
317
|
+
RAISE EXCEPTION 'expected exactly 1 credential with meetings:write, found %', v_count;
|
|
318
|
+
END IF;
|
|
319
|
+
END
|
|
320
|
+
$do$;
|