@team-semicolon/semicolony-cli 4.18.123 → 4.18.124
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,117 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 220: a worker credential may be issued without an expiry.
|
|
3
|
+
--
|
|
4
|
+
-- Why
|
|
5
|
+
--
|
|
6
|
+
-- The 30-day default is the only automatic revocation a worker credential has.
|
|
7
|
+
-- It is also why worker devices die silently: nothing rotates the key, nothing
|
|
8
|
+
-- alerts, and the device simply answers `401 invalid or expired credential` on
|
|
9
|
+
-- every route until somebody complains. Measured 2026-09-07 -- both
|
|
10
|
+
-- non-operator human devices were dead this way, `jay` since 2026-08-22 and
|
|
11
|
+
-- `roki` since 2026-08-26, the second unnoticed for twelve days because only
|
|
12
|
+
-- `jay` happened to ask.
|
|
13
|
+
--
|
|
14
|
+
-- The runtime has always supported a credential that does not expire. Every
|
|
15
|
+
-- resolution path spells it `coalesce(expires_at, 'infinity'::timestamptz)`,
|
|
16
|
+
-- and the TypeScript resolver agrees:
|
|
17
|
+
-- `packages/kb-gateway/src/lib/tenant-credentials.ts` reads
|
|
18
|
+
-- `row.expires_at != null && Date.parse(row.expires_at) <= now`. Tenant
|
|
19
|
+
-- issuance has accepted an omitted expiry as indefinite since it was written
|
|
20
|
+
-- (`issueGatewayCredentialForTenant`; its `--expires-days` is documented as
|
|
21
|
+
-- "미지정 시 무기한"). The worker entry point was the lone holdout, and only
|
|
22
|
+
-- because of the guard rewritten below.
|
|
23
|
+
--
|
|
24
|
+
-- What changes
|
|
25
|
+
--
|
|
26
|
+
-- `p_expires_days` becomes optional instead of mandatory. A value still has to
|
|
27
|
+
-- be an integer in 1..90, so every previously accepted call behaves exactly as
|
|
28
|
+
-- before; NULL now stores `expires_at = NULL`, which every reader already
|
|
29
|
+
-- treats as "does not expire". The contract widens by exactly one case and
|
|
30
|
+
-- narrows nothing.
|
|
31
|
+
--
|
|
32
|
+
-- What does NOT change
|
|
33
|
+
--
|
|
34
|
+
-- Revocation. `revoke_gateway_credential`, the automatic revoke-previous on
|
|
35
|
+
-- reissue inside this same function, and the device-liveness operator boundary
|
|
36
|
+
-- are all untouched, and they stay the way a credential is taken away. A
|
|
37
|
+
-- credential with no expiry is not unrevocable -- it is only unexpiring, and
|
|
38
|
+
-- that distinction is the entire safety argument for this migration.
|
|
39
|
+
--
|
|
40
|
+
-- How
|
|
41
|
+
--
|
|
42
|
+
-- The two edits rewrite the installed definition in place (the 187/194/205
|
|
43
|
+
-- pattern). Copying the ~10KB body into this file would silently revert any
|
|
44
|
+
-- correction it has received since the copy was taken.
|
|
45
|
+
--
|
|
46
|
+
-- Edit 1 relaxes the guard. Edit 2 makes the resulting NULL explicit at the
|
|
47
|
+
-- INSERT: `make_interval` is STRICT, so `clock_timestamp() + make_interval(days
|
|
48
|
+
-- => NULL)` already evaluates to NULL, but a security boundary should not
|
|
49
|
+
-- depend on the reader knowing that.
|
|
50
|
+
-- ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
DO $worker_optional_expiry$
|
|
53
|
+
DECLARE
|
|
54
|
+
v_target constant text :=
|
|
55
|
+
'semicolony.issue_worker_gateway_credential(uuid,text,text,text,text,text[],text,integer,jsonb)';
|
|
56
|
+
|
|
57
|
+
v_guard_old constant text :=
|
|
58
|
+
E' IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN\n'
|
|
59
|
+
|| E' RAISE EXCEPTION ''worker credential expiry must be between 1 and 90 days'' USING ERRCODE = ''22023'';';
|
|
60
|
+
v_guard_new constant text :=
|
|
61
|
+
E' IF p_expires_days IS NOT NULL AND (p_expires_days < 1 OR p_expires_days > 90) THEN\n'
|
|
62
|
+
|| E' RAISE EXCEPTION ''worker credential expiry must be between 1 and 90 days when given'' USING ERRCODE = ''22023'';';
|
|
63
|
+
|
|
64
|
+
v_insert_old constant text := E' clock_timestamp() + make_interval(days => p_expires_days),';
|
|
65
|
+
v_insert_new constant text :=
|
|
66
|
+
E' CASE WHEN p_expires_days IS NULL THEN NULL\n'
|
|
67
|
+
|| E' ELSE clock_timestamp() + make_interval(days => p_expires_days) END,';
|
|
68
|
+
|
|
69
|
+
v_definition text;
|
|
70
|
+
v_guard_hits integer;
|
|
71
|
+
v_insert_hits integer;
|
|
72
|
+
BEGIN
|
|
73
|
+
SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
|
|
74
|
+
|
|
75
|
+
v_guard_hits :=
|
|
76
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_guard_old, '')))
|
|
77
|
+
/ char_length(v_guard_old);
|
|
78
|
+
v_insert_hits :=
|
|
79
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_insert_old, '')))
|
|
80
|
+
/ char_length(v_insert_old);
|
|
81
|
+
|
|
82
|
+
-- Already relaxed: re-running must be a no-op, not an exception.
|
|
83
|
+
IF v_guard_hits = 0
|
|
84
|
+
AND v_insert_hits = 0
|
|
85
|
+
AND position(v_guard_new IN v_definition) > 0
|
|
86
|
+
AND position(v_insert_new IN v_definition) > 0
|
|
87
|
+
THEN
|
|
88
|
+
RETURN;
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
IF v_guard_hits <> 1 THEN
|
|
92
|
+
RAISE EXCEPTION 'unexpected worker credential expiry guard in % (% occurrences)',
|
|
93
|
+
v_target, v_guard_hits;
|
|
94
|
+
END IF;
|
|
95
|
+
IF v_insert_hits <> 1 THEN
|
|
96
|
+
RAISE EXCEPTION 'unexpected worker credential expires_at expression in % (% occurrences)',
|
|
97
|
+
v_target, v_insert_hits;
|
|
98
|
+
END IF;
|
|
99
|
+
|
|
100
|
+
v_definition := replace(v_definition, v_guard_old, v_guard_new);
|
|
101
|
+
v_definition := replace(v_definition, v_insert_old, v_insert_new);
|
|
102
|
+
EXECUTE v_definition;
|
|
103
|
+
END;
|
|
104
|
+
$worker_optional_expiry$;
|
|
105
|
+
|
|
106
|
+
-- ---------------------------------------------------------------------------
|
|
107
|
+
-- `issue_worker_asset_gateway_credential` needs no edit: it carries no expiry
|
|
108
|
+
-- guard of its own and forwards `p_expires_days` to the function rewritten
|
|
109
|
+
-- above, so it inherits the relaxation. Verified against the installed
|
|
110
|
+
-- definition on 2026-09-07 -- `p_expires_days` appears there exactly once, as
|
|
111
|
+
-- a call argument, and `make_interval` not at all.
|
|
112
|
+
--
|
|
113
|
+
-- The worker-agent and dashboard issuers keep their mandatory 1..90 expiry.
|
|
114
|
+
-- They are unattended service credentials with their own rotation paths; this
|
|
115
|
+
-- migration is scoped to the human worker lane that actually stalls on a
|
|
116
|
+
-- refresh.
|
|
117
|
+
-- ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 221: stop gating worker credential issuance on device liveness, and stop
|
|
3
|
+
-- revoking the previous credential before the new one has ever worked.
|
|
4
|
+
--
|
|
5
|
+
-- Why
|
|
6
|
+
--
|
|
7
|
+
-- Two rules in `issue_worker_gateway_credential` turned an expired credential
|
|
8
|
+
-- into an outage that needed an operator to type its way out.
|
|
9
|
+
--
|
|
10
|
+
-- 1. The liveness gate (`last_doctor_status = 'pass'` and `last_seen_at`
|
|
11
|
+
-- within 24 hours) cannot be satisfied by the device that needs a
|
|
12
|
+
-- credential most. `last_seen_at` is written by the Gateway when a worker
|
|
13
|
+
-- reports, and a worker with a dead credential cannot report. The only way
|
|
14
|
+
-- through is `semo worker register`, which sets both fields directly. A
|
|
15
|
+
-- precondition an operator satisfies by typing a flag is not a control; it
|
|
16
|
+
-- is a detour, and on 2026-09-10 that detour overwrote `roki-pc`'s
|
|
17
|
+
-- hostname and OS with the operator's own machine because `register`
|
|
18
|
+
-- defaults those from the current host.
|
|
19
|
+
--
|
|
20
|
+
-- `last_doctor_status` is worse than useless as a gate: `worker doctor`
|
|
21
|
+
-- does not report it: an operator records it by hand. On 2026-09-10
|
|
22
|
+
-- `roki-pc` carried `pass` in the registry while its actual doctor run
|
|
23
|
+
-- reported two failures. The gate cannot see the difference.
|
|
24
|
+
--
|
|
25
|
+
-- 2. The automatic revoke-previous makes issuance destructive before the new
|
|
26
|
+
-- credential is known to work. Plaintext prints once and delivery is a
|
|
27
|
+
-- manual copy into the team canvas, so any gap between issuance and
|
|
28
|
+
-- delivery is an outage. Measured: `roki` was left with zero valid
|
|
29
|
+
-- credentials for three days after a rotation whose replacement was never
|
|
30
|
+
-- issued, and `jay` spent that period holding a credential revoked on
|
|
31
|
+
-- 2026-09-07 while the canvas still advertised it.
|
|
32
|
+
--
|
|
33
|
+
-- What changes
|
|
34
|
+
--
|
|
35
|
+
-- The device binding still has to exist, still has to be `active`, and still
|
|
36
|
+
-- has to be `worker-gateway`. Those are deliberate operator decisions. The two
|
|
37
|
+
-- liveness conditions are removed from the gate; they remain valuable as
|
|
38
|
+
-- *reporting*, which is where this change moves them.
|
|
39
|
+
--
|
|
40
|
+
-- Issuance no longer revokes anything, and no longer repoints the device at
|
|
41
|
+
-- the new credential. Both move to first use: when the new credential
|
|
42
|
+
-- authenticates for the first time, the Gateway repoints
|
|
43
|
+
-- `worker_devices.gateway_credential_id` and revokes its predecessors
|
|
44
|
+
-- (`tenant-credentials.ts`).
|
|
45
|
+
--
|
|
46
|
+
-- The repoint has to move with the revoke, not stay behind. `getActiveRelease`
|
|
47
|
+
-- requires `wd.gateway_credential_id = <the calling credential>`, so a device
|
|
48
|
+
-- repointed at an undelivered credential cannot fetch a release even though
|
|
49
|
+
-- its old credential still authenticates. Deferring only the revoke would
|
|
50
|
+
-- trade a total outage for a silent half one.
|
|
51
|
+
--
|
|
52
|
+
-- The invariant "one active credential per worker device" is preserved; what
|
|
53
|
+
-- changes is that it is reached by success rather than by hope.
|
|
54
|
+
--
|
|
55
|
+
-- What does NOT change
|
|
56
|
+
--
|
|
57
|
+
-- `revoke_gateway_credential` still exists and is still how a credential is
|
|
58
|
+
-- taken away deliberately, which is what a suspected leak calls for. The
|
|
59
|
+
-- `FOR UPDATE` row lock on the device stays: it serializes concurrent
|
|
60
|
+
-- issuance for the same device. Expiry, scopes, workspace grants, and the
|
|
61
|
+
-- worker-agent and dashboard issuers are untouched.
|
|
62
|
+
--
|
|
63
|
+
-- How
|
|
64
|
+
--
|
|
65
|
+
-- Two in-place edits of the installed definition, the 187/194/205/220 pattern.
|
|
66
|
+
-- Copying the body into this file would silently revert corrections it has
|
|
67
|
+
-- received since the copy was taken.
|
|
68
|
+
-- ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
DO $worker_liveness_and_rotation$
|
|
71
|
+
DECLARE
|
|
72
|
+
v_target constant text :=
|
|
73
|
+
'semicolony.issue_worker_gateway_credential(uuid,text,text,text,text,text[],text,integer,jsonb)';
|
|
74
|
+
|
|
75
|
+
v_gate_old constant text :=
|
|
76
|
+
E' AND wd.kb_access_mode = ''worker-gateway''\n'
|
|
77
|
+
|| E' AND wd.last_doctor_status = ''pass''\n'
|
|
78
|
+
|| E' AND wd.last_seen_at >= clock_timestamp() - interval ''24 hours''\n'
|
|
79
|
+
|| E' FOR UPDATE;';
|
|
80
|
+
v_gate_new constant text :=
|
|
81
|
+
E' AND wd.kb_access_mode = ''worker-gateway''\n'
|
|
82
|
+
|| E' FOR UPDATE;';
|
|
83
|
+
|
|
84
|
+
v_revoke_old constant text :=
|
|
85
|
+
E' IF v_previous_credential_id IS NOT NULL THEN\n'
|
|
86
|
+
|| E' UPDATE semicolony.gateway_credentials\n'
|
|
87
|
+
|| E' SET status = ''revoked'',\n'
|
|
88
|
+
|| E' revoked_at = clock_timestamp(),\n'
|
|
89
|
+
|| E' revoked_reason = ''worker credential rotated''\n'
|
|
90
|
+
|| E' WHERE id = v_previous_credential_id\n'
|
|
91
|
+
|| E' AND status = ''active'';\n'
|
|
92
|
+
|| E' END IF;\n\n';
|
|
93
|
+
v_revoke_new constant text := '';
|
|
94
|
+
|
|
95
|
+
v_pointer_old constant text :=
|
|
96
|
+
E' UPDATE semicolony.worker_devices\n'
|
|
97
|
+
|| E' SET gateway_credential_id = v_credential_id\n'
|
|
98
|
+
|| E' WHERE device_id = p_device_id\n'
|
|
99
|
+
|| E' AND worker_id = p_worker_id;\n\n';
|
|
100
|
+
v_pointer_new constant text := '';
|
|
101
|
+
|
|
102
|
+
v_definition text;
|
|
103
|
+
v_gate_hits integer;
|
|
104
|
+
v_revoke_hits integer;
|
|
105
|
+
v_pointer_hits integer;
|
|
106
|
+
BEGIN
|
|
107
|
+
SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
|
|
108
|
+
|
|
109
|
+
v_gate_hits :=
|
|
110
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_gate_old, '')))
|
|
111
|
+
/ char_length(v_gate_old);
|
|
112
|
+
v_revoke_hits :=
|
|
113
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_revoke_old, '')))
|
|
114
|
+
/ char_length(v_revoke_old);
|
|
115
|
+
v_pointer_hits :=
|
|
116
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_pointer_old, '')))
|
|
117
|
+
/ char_length(v_pointer_old);
|
|
118
|
+
|
|
119
|
+
-- Already applied: re-running must be a no-op, not an exception.
|
|
120
|
+
IF v_gate_hits = 0
|
|
121
|
+
AND v_revoke_hits = 0
|
|
122
|
+
AND v_pointer_hits = 0
|
|
123
|
+
AND position(v_gate_new IN v_definition) > 0
|
|
124
|
+
THEN
|
|
125
|
+
RETURN;
|
|
126
|
+
END IF;
|
|
127
|
+
|
|
128
|
+
IF v_gate_hits <> 1 THEN
|
|
129
|
+
RAISE EXCEPTION 'unexpected worker device liveness gate in % (% occurrences)',
|
|
130
|
+
v_target, v_gate_hits;
|
|
131
|
+
END IF;
|
|
132
|
+
IF v_revoke_hits <> 1 THEN
|
|
133
|
+
RAISE EXCEPTION 'unexpected revoke-previous block in % (% occurrences)',
|
|
134
|
+
v_target, v_revoke_hits;
|
|
135
|
+
END IF;
|
|
136
|
+
IF v_pointer_hits <> 1 THEN
|
|
137
|
+
RAISE EXCEPTION 'unexpected device repoint block in % (% occurrences)',
|
|
138
|
+
v_target, v_pointer_hits;
|
|
139
|
+
END IF;
|
|
140
|
+
|
|
141
|
+
v_definition := replace(v_definition, v_gate_old, v_gate_new);
|
|
142
|
+
v_definition := replace(v_definition, v_revoke_old, v_revoke_new);
|
|
143
|
+
v_definition := replace(v_definition, v_pointer_old, v_pointer_new);
|
|
144
|
+
EXECUTE v_definition;
|
|
145
|
+
END;
|
|
146
|
+
$worker_liveness_and_rotation$;
|
|
147
|
+
|
|
148
|
+
-- ---------------------------------------------------------------------------
|
|
149
|
+
-- `issue_worker_asset_gateway_credential` needs no edit. It forwards to the
|
|
150
|
+
-- function rewritten above and carries neither the liveness gate nor a
|
|
151
|
+
-- revoke-previous block of its own. Verified against the installed definition
|
|
152
|
+
-- on 2026-09-10: `last_seen_at`, `last_doctor_status`, and `revoked_reason`
|
|
153
|
+
-- appear there zero times.
|
|
154
|
+
-- ---------------------------------------------------------------------------
|