@team-semicolon/semicolony-cli 4.18.89 → 4.18.91
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,157 @@
|
|
|
1
|
+
-- 190_worker_credential_scope_amendment.sql
|
|
2
|
+
--
|
|
3
|
+
-- Amend a worker credential's scopes without reissuing it.
|
|
4
|
+
--
|
|
5
|
+
-- Until now the only governed way to change a scope was
|
|
6
|
+
-- `issue_worker_gateway_credential`, which mints a new credential and requires
|
|
7
|
+
-- the target device to be active, doctor-passing, and seen within 24 hours.
|
|
8
|
+
-- That is the right bar for handing out a new secret. It is the wrong bar for
|
|
9
|
+
-- adjusting what an existing secret may do: on 2026-08-06 two workers needed
|
|
10
|
+
-- `skills:write` after skill staging went lease-free, both devices had been
|
|
11
|
+
-- offline for over a week, and the only way through was a direct UPDATE that
|
|
12
|
+
-- bypassed the operator boundary entirely and left no issuance audit.
|
|
13
|
+
--
|
|
14
|
+
-- This function closes that gap. It mints no secret, so it does not demand a
|
|
15
|
+
-- fresh device binding; it re-validates every scope invariant the issuance
|
|
16
|
+
-- path enforces, and every amendment is recorded append-only.
|
|
17
|
+
--
|
|
18
|
+
-- Deliberately kept from the issuance path:
|
|
19
|
+
-- * the exact scope allowlist, no duplicates, non-empty
|
|
20
|
+
-- * `skills:write` requires `skills:read`
|
|
21
|
+
-- * workspace scopes must exactly match the credential's workspace grants
|
|
22
|
+
-- * worker credentials only, active and unexpired
|
|
23
|
+
--
|
|
24
|
+
-- Deliberately dropped: device liveness. Amending cannot leak a new secret,
|
|
25
|
+
-- and the credential remains revocable at any time.
|
|
26
|
+
--
|
|
27
|
+
-- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
|
|
28
|
+
-- retargets the `semicolony.` qualifier to the active schema.
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS semicolony.gateway_credential_scope_amendments (
|
|
31
|
+
amendment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
32
|
+
credential_id uuid NOT NULL REFERENCES semicolony.gateway_credentials (id),
|
|
33
|
+
previous_scopes text[] NOT NULL,
|
|
34
|
+
next_scopes text[] NOT NULL,
|
|
35
|
+
reason text NOT NULL CHECK (NULLIF(btrim(reason), '') IS NOT NULL),
|
|
36
|
+
amended_by text NOT NULL CHECK (NULLIF(btrim(amended_by), '') IS NOT NULL),
|
|
37
|
+
amended_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE INDEX IF NOT EXISTS gateway_credential_scope_amendments_lookup
|
|
41
|
+
ON semicolony.gateway_credential_scope_amendments (credential_id, amended_at DESC);
|
|
42
|
+
|
|
43
|
+
CREATE OR REPLACE FUNCTION semicolony.reject_scope_amendment_mutation()
|
|
44
|
+
RETURNS trigger
|
|
45
|
+
LANGUAGE plpgsql
|
|
46
|
+
AS $function$
|
|
47
|
+
BEGIN
|
|
48
|
+
RAISE EXCEPTION 'scope amendments are append-only' USING ERRCODE = '22023';
|
|
49
|
+
END
|
|
50
|
+
$function$;
|
|
51
|
+
|
|
52
|
+
DROP TRIGGER IF EXISTS protect_gateway_credential_scope_amendments
|
|
53
|
+
ON semicolony.gateway_credential_scope_amendments;
|
|
54
|
+
CREATE TRIGGER protect_gateway_credential_scope_amendments
|
|
55
|
+
BEFORE UPDATE OR DELETE ON semicolony.gateway_credential_scope_amendments
|
|
56
|
+
FOR EACH ROW EXECUTE FUNCTION semicolony.reject_scope_amendment_mutation();
|
|
57
|
+
|
|
58
|
+
CREATE OR REPLACE FUNCTION semicolony.amend_worker_gateway_credential_scopes(
|
|
59
|
+
p_credential_id uuid,
|
|
60
|
+
p_scopes text[],
|
|
61
|
+
p_amended_by text,
|
|
62
|
+
p_reason text
|
|
63
|
+
)
|
|
64
|
+
RETURNS uuid
|
|
65
|
+
LANGUAGE plpgsql
|
|
66
|
+
SECURITY DEFINER
|
|
67
|
+
SET search_path = pg_catalog
|
|
68
|
+
AS $function$
|
|
69
|
+
DECLARE
|
|
70
|
+
v_amendment_id uuid;
|
|
71
|
+
v_previous_scopes text[];
|
|
72
|
+
v_metadata jsonb;
|
|
73
|
+
v_has_credential_grant boolean;
|
|
74
|
+
v_has_asset_grant boolean;
|
|
75
|
+
BEGIN
|
|
76
|
+
IF p_credential_id IS NULL THEN
|
|
77
|
+
RAISE EXCEPTION 'credential id is required' USING ERRCODE = '22023';
|
|
78
|
+
END IF;
|
|
79
|
+
IF NULLIF(btrim(coalesce(p_amended_by, '')), '') IS NULL
|
|
80
|
+
OR NULLIF(btrim(coalesce(p_reason, '')), '') IS NULL THEN
|
|
81
|
+
RAISE EXCEPTION 'amended_by and reason are required' USING ERRCODE = '22023';
|
|
82
|
+
END IF;
|
|
83
|
+
|
|
84
|
+
-- Same scope contract as issuance. Kept literal rather than shared so a
|
|
85
|
+
-- change to one is a visible, reviewed change to the other.
|
|
86
|
+
IF p_scopes IS NULL OR cardinality(p_scopes) = 0
|
|
87
|
+
OR EXISTS (
|
|
88
|
+
SELECT 1
|
|
89
|
+
FROM unnest(p_scopes) AS requested(scope)
|
|
90
|
+
WHERE requested.scope IS NULL
|
|
91
|
+
OR requested.scope <> ALL (ARRAY[
|
|
92
|
+
'kb:read',
|
|
93
|
+
'feedback:write',
|
|
94
|
+
'skills:read',
|
|
95
|
+
'skills:write',
|
|
96
|
+
'workspace:credentials-read',
|
|
97
|
+
'workspace:assets-read',
|
|
98
|
+
'worker:release:read',
|
|
99
|
+
'worker:release:report'
|
|
100
|
+
]::text[])
|
|
101
|
+
)
|
|
102
|
+
OR cardinality(p_scopes) <> (
|
|
103
|
+
SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
|
|
104
|
+
)
|
|
105
|
+
OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
|
|
106
|
+
RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
|
|
107
|
+
END IF;
|
|
108
|
+
|
|
109
|
+
SELECT gc.scopes, gc.metadata
|
|
110
|
+
INTO v_previous_scopes, v_metadata
|
|
111
|
+
FROM semicolony.gateway_credentials AS gc
|
|
112
|
+
WHERE gc.id = p_credential_id
|
|
113
|
+
AND gc.status = 'active'
|
|
114
|
+
AND gc.revoked_at IS NULL
|
|
115
|
+
AND (gc.expires_at IS NULL OR gc.expires_at > clock_timestamp())
|
|
116
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
117
|
+
FOR UPDATE;
|
|
118
|
+
IF NOT FOUND THEN
|
|
119
|
+
RAISE EXCEPTION 'active worker credential not found' USING ERRCODE = 'P0002';
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
-- Workspace scopes stay bound to real grants, exactly as at issuance.
|
|
123
|
+
SELECT
|
|
124
|
+
bool_or(wpg.capability = 'credentials-read'),
|
|
125
|
+
bool_or(wpg.capability = 'assets-read')
|
|
126
|
+
INTO v_has_credential_grant, v_has_asset_grant
|
|
127
|
+
FROM semicolony.workspace_projection_grants AS wpg
|
|
128
|
+
WHERE wpg.gateway_credential_id = p_credential_id;
|
|
129
|
+
|
|
130
|
+
IF coalesce(v_has_credential_grant, false) <> ('workspace:credentials-read' = ANY(p_scopes))
|
|
131
|
+
OR coalesce(v_has_asset_grant, false) <> ('workspace:assets-read' = ANY(p_scopes)) THEN
|
|
132
|
+
RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
|
|
133
|
+
END IF;
|
|
134
|
+
|
|
135
|
+
INSERT INTO semicolony.gateway_credential_scope_amendments (
|
|
136
|
+
credential_id, previous_scopes, next_scopes, reason, amended_by
|
|
137
|
+
) VALUES (
|
|
138
|
+
p_credential_id, v_previous_scopes, p_scopes, btrim(p_reason), btrim(p_amended_by)
|
|
139
|
+
)
|
|
140
|
+
RETURNING amendment_id INTO v_amendment_id;
|
|
141
|
+
|
|
142
|
+
UPDATE semicolony.gateway_credentials
|
|
143
|
+
SET scopes = p_scopes
|
|
144
|
+
WHERE id = p_credential_id;
|
|
145
|
+
|
|
146
|
+
RETURN v_amendment_id;
|
|
147
|
+
END
|
|
148
|
+
$function$;
|
|
149
|
+
|
|
150
|
+
REVOKE ALL ON FUNCTION semicolony.amend_worker_gateway_credential_scopes(uuid, text[], text, text)
|
|
151
|
+
FROM PUBLIC, sc_app, sc_provider;
|
|
152
|
+
GRANT EXECUTE ON FUNCTION semicolony.amend_worker_gateway_credential_scopes(uuid, text[], text, text)
|
|
153
|
+
TO sc_app;
|
|
154
|
+
|
|
155
|
+
REVOKE ALL ON TABLE semicolony.gateway_credential_scope_amendments
|
|
156
|
+
FROM PUBLIC, sc_app, sc_provider;
|
|
157
|
+
GRANT SELECT ON TABLE semicolony.gateway_credential_scope_amendments TO sc_app;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
-- 191_activation_allows_lease_free_stage.sql
|
|
2
|
+
--
|
|
3
|
+
-- `validate_skill_release_activation` used `created_under_lease_id IS NULL` as
|
|
4
|
+
-- its "the staged release does not exist" test, because before migration 189
|
|
5
|
+
-- that column was NOT NULL and a missing row was the only way to see NULL from
|
|
6
|
+
-- `SELECT ... INTO`. Making staging lease-free left every lease-free release
|
|
7
|
+
-- permanently unactivatable: it stages fine, then activation raises
|
|
8
|
+
-- `activation_release_identity_mismatch` on a release that plainly exists.
|
|
9
|
+
--
|
|
10
|
+
-- Separate the two questions the old test conflated:
|
|
11
|
+
-- * does the staged release exist -> NOT FOUND
|
|
12
|
+
-- * is the activation lease distinct -> only when a stage lease exists
|
|
13
|
+
--
|
|
14
|
+
-- The distinct-lease rule still holds wherever a stage lease was recorded, so
|
|
15
|
+
-- pre-189 releases keep exactly the protection they had. Every other check —
|
|
16
|
+
-- audience equality, sealed tree, previous-release ordering — is carried over
|
|
17
|
+
-- unchanged.
|
|
18
|
+
--
|
|
19
|
+
-- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
|
|
20
|
+
-- retargets the `semicolony.` qualifier to the active schema.
|
|
21
|
+
|
|
22
|
+
CREATE OR REPLACE FUNCTION semicolony.validate_skill_release_activation()
|
|
23
|
+
RETURNS trigger
|
|
24
|
+
LANGUAGE plpgsql
|
|
25
|
+
AS $function$
|
|
26
|
+
|
|
27
|
+
DECLARE
|
|
28
|
+
staged_lease_id UUID;
|
|
29
|
+
staged_audiences TEXT[];
|
|
30
|
+
staged_file_count INTEGER;
|
|
31
|
+
staged_total_bytes BIGINT;
|
|
32
|
+
actual_file_count BIGINT;
|
|
33
|
+
actual_total_bytes BIGINT;
|
|
34
|
+
has_skill_md BOOLEAN;
|
|
35
|
+
current_release_id UUID;
|
|
36
|
+
BEGIN
|
|
37
|
+
PERFORM pg_advisory_xact_lock(
|
|
38
|
+
hashtextextended(NEW.channel || ':' || NEW.skill_name, 0)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
SELECT
|
|
42
|
+
release.created_under_lease_id,
|
|
43
|
+
release.audiences,
|
|
44
|
+
release.file_count,
|
|
45
|
+
release.total_bytes
|
|
46
|
+
INTO
|
|
47
|
+
staged_lease_id,
|
|
48
|
+
staged_audiences,
|
|
49
|
+
staged_file_count,
|
|
50
|
+
staged_total_bytes
|
|
51
|
+
FROM semicolony.skill_releases AS release
|
|
52
|
+
WHERE release.release_id = NEW.release_id
|
|
53
|
+
AND release.channel = NEW.channel
|
|
54
|
+
AND release.skill_name = NEW.skill_name;
|
|
55
|
+
|
|
56
|
+
IF NOT FOUND THEN
|
|
57
|
+
RAISE EXCEPTION 'activation_release_identity_mismatch'
|
|
58
|
+
USING ERRCODE = '23503';
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
IF staged_lease_id IS NOT NULL AND NEW.lease_id = staged_lease_id THEN
|
|
62
|
+
RAISE EXCEPTION 'activation_lease_must_differ_from_stage_lease'
|
|
63
|
+
USING ERRCODE = '23514';
|
|
64
|
+
END IF;
|
|
65
|
+
|
|
66
|
+
IF NEW.audiences IS DISTINCT FROM staged_audiences THEN
|
|
67
|
+
RAISE EXCEPTION 'activation_audiences_must_match_release'
|
|
68
|
+
USING ERRCODE = '23514';
|
|
69
|
+
END IF;
|
|
70
|
+
|
|
71
|
+
SELECT
|
|
72
|
+
COUNT(*),
|
|
73
|
+
COALESCE(SUM(file.size), 0),
|
|
74
|
+
COALESCE(BOOL_OR(file.relative_path = 'SKILL.md'), false)
|
|
75
|
+
INTO
|
|
76
|
+
actual_file_count,
|
|
77
|
+
actual_total_bytes,
|
|
78
|
+
has_skill_md
|
|
79
|
+
FROM semicolony.skill_release_files AS file
|
|
80
|
+
WHERE file.release_id = NEW.release_id;
|
|
81
|
+
|
|
82
|
+
IF actual_file_count <> staged_file_count
|
|
83
|
+
OR actual_total_bytes <> staged_total_bytes
|
|
84
|
+
OR NOT has_skill_md
|
|
85
|
+
THEN
|
|
86
|
+
RAISE EXCEPTION 'activation_release_tree_is_not_sealed'
|
|
87
|
+
USING ERRCODE = '23514';
|
|
88
|
+
END IF;
|
|
89
|
+
|
|
90
|
+
SELECT activation.release_id
|
|
91
|
+
INTO current_release_id
|
|
92
|
+
FROM semicolony.skill_release_activations AS activation
|
|
93
|
+
WHERE activation.channel = NEW.channel
|
|
94
|
+
AND activation.skill_name = NEW.skill_name
|
|
95
|
+
ORDER BY activation.activated_at DESC, activation.activation_id DESC
|
|
96
|
+
LIMIT 1;
|
|
97
|
+
|
|
98
|
+
IF NEW.previous_release_id IS DISTINCT FROM current_release_id THEN
|
|
99
|
+
RAISE EXCEPTION 'activation_previous_release_must_match_current'
|
|
100
|
+
USING ERRCODE = '23514';
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
RETURN NEW;
|
|
104
|
+
END;
|
|
105
|
+
$function$;
|