@zgeoff/atc 2.24.0 → 2.25.0
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/package.json +1 -1
- package/src/agents/agent-adapter.ts +27 -2
- package/src/agents/gateway-adapter.ts +21 -3
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +52 -2
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +58 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/restore-fleet.ts +15 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +16 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { SessionID } from '../shared/session-id';
|
|
4
|
+
import type { RuntimeAuthBinding, RuntimeAuthGrant } from '../store/runtime-auth-binding';
|
|
5
|
+
import type { StateStore } from '../store/state-store';
|
|
6
|
+
import type { BrokerAuthHost } from './broker-auth-host';
|
|
7
|
+
import { BrokerAuthorityError } from './broker-authority-error';
|
|
8
|
+
import type { AuthBinding } from './build-auth-binding';
|
|
9
|
+
import { collectSecretRuleMismatches } from './collect-secret-rule-mismatches';
|
|
10
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
11
|
+
import type { LaunchTicket } from './execution-provider';
|
|
12
|
+
import type { ImpView } from './imp-port';
|
|
13
|
+
import { ImpPortError } from './imp-port-error';
|
|
14
|
+
import { verifyBrokerAuthority } from './verify-broker-authority';
|
|
15
|
+
import { verifyCleanupAuthority } from './verify-cleanup-authority';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The store calls the binder makes: a host's binding and its grant rows.
|
|
19
|
+
*/
|
|
20
|
+
export type RuntimeAuthStore = Pick<
|
|
21
|
+
StateStore,
|
|
22
|
+
| 'createAuthBinding'
|
|
23
|
+
| 'updateAuthBinding'
|
|
24
|
+
| 'findAuthBinding'
|
|
25
|
+
| 'collectAuthBindings'
|
|
26
|
+
| 'upsertAuthGrant'
|
|
27
|
+
| 'collectAuthGrants'
|
|
28
|
+
| 'removeAuthBinding'
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
// The host a fresh spawn provisions: its key, the target and identity it
|
|
32
|
+
// runs on, and the binding its agent asks for.
|
|
33
|
+
interface BindingRequest {
|
|
34
|
+
readonly hostKey: SessionID;
|
|
35
|
+
readonly target: string;
|
|
36
|
+
readonly targetIdentity: string;
|
|
37
|
+
readonly binding: AuthBinding;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What a launch was planned under: the binding's revision and hash, and
|
|
41
|
+
// the spawn attempt still provisioning the host, or null for a launch on a
|
|
42
|
+
// host already ready.
|
|
43
|
+
interface LaunchAdmission {
|
|
44
|
+
readonly revision: number;
|
|
45
|
+
readonly hash: string;
|
|
46
|
+
readonly attemptID: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Binds a host's runtime auth through impd's credential broker and keeps
|
|
51
|
+
* the binding's record in the store, one host at a time: every call on a
|
|
52
|
+
* host waits for the one before it on that host.
|
|
53
|
+
*
|
|
54
|
+
* A spawn creates the binding: the full gate, then the record, then a new
|
|
55
|
+
* imp, then each grant, each step recorded before the next. A resume, a
|
|
56
|
+
* restore, and a sub-session joining the host verify it: the full gate,
|
|
57
|
+
* the imp's identity, and the exact grant set, and nothing is granted
|
|
58
|
+
* again. A rebind moves the binding to a new revision. A revoke and a
|
|
59
|
+
* forget first record the block that stops every launch, then check only
|
|
60
|
+
* that the token may manage the recorded imp and that the imp is still
|
|
61
|
+
* the one recorded, never a secret's rules or a complete grant set.
|
|
62
|
+
* Every harness start and attach is admitted under the same lock, so a
|
|
63
|
+
* block recorded before the admission refuses it. Nothing here falls back
|
|
64
|
+
* to a broader token or to a local launch.
|
|
65
|
+
*/
|
|
66
|
+
export class RuntimeAuthBinder {
|
|
67
|
+
private readonly store: RuntimeAuthStore;
|
|
68
|
+
|
|
69
|
+
private readonly now: () => number;
|
|
70
|
+
|
|
71
|
+
// The tail of each host's chain of calls, which never rejects.
|
|
72
|
+
private readonly locks = new Map<SessionID, Promise<void>>();
|
|
73
|
+
|
|
74
|
+
// The admissions on each host whose request has not gone out yet. A
|
|
75
|
+
// revoke, a rebind or a forget cancels them before it writes anything,
|
|
76
|
+
// so a request still opening its connection closes it unsent.
|
|
77
|
+
private readonly pending = new Map<SessionID, Set<{ refusal: DaemonError | null }>>();
|
|
78
|
+
|
|
79
|
+
constructor(store: RuntimeAuthStore, now: () => number = Date.now) {
|
|
80
|
+
this.store = store;
|
|
81
|
+
this.now = now;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
findBinding(hostKey: SessionID): Promise<RuntimeAuthBinding | null> {
|
|
85
|
+
return this.store.findAuthBinding(hostKey);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Provisions a fresh host for a spawn and returns the attempt's id. It
|
|
90
|
+
* writes nothing until the gate passes, refuses an imp that already
|
|
91
|
+
* holds the name or any grant, and records the imp's id as soon as impd
|
|
92
|
+
* creates it. A refusal after the record exists takes back what this
|
|
93
|
+
* attempt made before it rejects, and a take-back that cannot be
|
|
94
|
+
* confirmed rejects with an effect that remains.
|
|
95
|
+
*/
|
|
96
|
+
createBinding(host: BrokerAuthHost, request: BindingRequest): Promise<string> {
|
|
97
|
+
return this.withHostLock(request.hostKey, async () => {
|
|
98
|
+
const impName = host.getImpName(request.hostKey);
|
|
99
|
+
const binding = request.binding;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await verifyGate(host, impName, binding);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw toAuthRefusal(error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const attemptID = randomUUID();
|
|
108
|
+
|
|
109
|
+
await this.store.createAuthBinding(
|
|
110
|
+
{
|
|
111
|
+
hostKey: request.hostKey,
|
|
112
|
+
target: request.target,
|
|
113
|
+
targetIdentity: request.targetIdentity,
|
|
114
|
+
impName,
|
|
115
|
+
bindingHash: binding.hash,
|
|
116
|
+
bindingJSON: JSON.stringify(binding),
|
|
117
|
+
attemptID,
|
|
118
|
+
},
|
|
119
|
+
this.now(),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await this.createImpAndGrants(host, request.hostKey, impName, binding, attemptID);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
await this.removeAttemptLocked(host, request.hostKey, attemptID);
|
|
126
|
+
|
|
127
|
+
throw toAuthRefusal(error);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return attemptID;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Records a provisioned host ready once its harness started, unless a
|
|
136
|
+
* revoke or another attempt has moved the binding on since.
|
|
137
|
+
*/
|
|
138
|
+
async updateReady(hostKey: SessionID, attemptID: string): Promise<void> {
|
|
139
|
+
await this.withHostLock(hostKey, async () => {
|
|
140
|
+
const row = await this.store.findAuthBinding(hostKey);
|
|
141
|
+
|
|
142
|
+
if (row?.state === 'provisioning' && row.attemptID === attemptID && row.rebind === null) {
|
|
143
|
+
await this.store.updateAuthBinding(hostKey, { state: 'ready' }, this.now());
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Checks a host's binding before a harness launches on it again. Every
|
|
150
|
+
* check reads, so a refusal changes nothing: the binding must be ready,
|
|
151
|
+
* the current binding must hash as the one recorded, the gate must pass,
|
|
152
|
+
* each secret must hold exactly the bound rules, the imp under the name
|
|
153
|
+
* must be the one recorded, and impd must hold exactly the bound grants.
|
|
154
|
+
* Resolves to the binding.
|
|
155
|
+
*/
|
|
156
|
+
verifyBinding(
|
|
157
|
+
host: BrokerAuthHost,
|
|
158
|
+
hostKey: SessionID,
|
|
159
|
+
binding: AuthBinding,
|
|
160
|
+
): Promise<RuntimeAuthBinding> {
|
|
161
|
+
return this.withHostLock(hostKey, async () => {
|
|
162
|
+
const row = await this.store.findAuthBinding(hostKey);
|
|
163
|
+
|
|
164
|
+
if (row === null) {
|
|
165
|
+
throw new DaemonError(
|
|
166
|
+
'auth_blocked',
|
|
167
|
+
`host ${hostKey} has no runtime auth binding, so nothing may launch on it with auth`,
|
|
168
|
+
{ host: hostKey, state: null },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (row.state !== 'ready') {
|
|
173
|
+
throw new DaemonError(
|
|
174
|
+
'auth_blocked',
|
|
175
|
+
`the runtime auth of host ${hostKey} is ${row.state}; rebind it to launch again`,
|
|
176
|
+
{ host: hostKey, state: row.state },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (row.bindingHash !== binding.hash) {
|
|
181
|
+
throw new DaemonError(
|
|
182
|
+
'auth_rebind_required',
|
|
183
|
+
`the auth profiles host ${hostKey} uses changed since it was bound; rebind it to adopt them`,
|
|
184
|
+
{ host: hostKey, revision: row.revision },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await verifyGate(host, row.impName, binding);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw toAuthRefusal(error);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const imp = await tryReadImp(host, row.impName);
|
|
195
|
+
|
|
196
|
+
if (imp === null || imp.id !== row.impID || row.impName !== host.getImpName(hostKey)) {
|
|
197
|
+
throw new DaemonError(
|
|
198
|
+
'auth_runtime_mismatch',
|
|
199
|
+
`imp ${row.impName} is no longer the imp host ${hostKey} was bound to`,
|
|
200
|
+
{ host: hostKey, imp: row.impName, recordedID: row.impID, actualID: imp?.id ?? null },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const held = await tryReadGrants(host, row.impName);
|
|
205
|
+
|
|
206
|
+
const bound = binding.secrets.map((secret) => secret.secret);
|
|
207
|
+
const missing = bound.filter((secret) => !held.includes(secret));
|
|
208
|
+
const extra = held.filter((secret) => !bound.includes(secret));
|
|
209
|
+
|
|
210
|
+
if (missing.length > 0) {
|
|
211
|
+
throw new DaemonError(
|
|
212
|
+
'auth_grant_missing',
|
|
213
|
+
`imp ${row.impName} no longer holds ${missing.join(', ')}; atc never grants it again on its own, so rebind the host`,
|
|
214
|
+
{ host: hostKey, missing },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (extra.length > 0) {
|
|
219
|
+
throw new DaemonError(
|
|
220
|
+
'auth_grants_mismatch',
|
|
221
|
+
`imp ${row.impName} holds ${extra.join(', ')}, which its binding does not grant`,
|
|
222
|
+
{ host: hostKey, extra },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return row;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Admits one start or attach of a harness on a host, by calling send,
|
|
232
|
+
* which hands the request to impd's client, under the host's lock and
|
|
233
|
+
* only while the binding is still launchable: ready, or still being
|
|
234
|
+
* provisioned by the attempt that launches. A start also needs the
|
|
235
|
+
* revision and hash its launch was planned under. send gets a ticket
|
|
236
|
+
* whose check the client runs once its connection to impd opens, just
|
|
237
|
+
* before the request goes out: null lets it go, and the refusal of a
|
|
238
|
+
* revoke, a rebind or a forget that came first stops it unsent. A
|
|
239
|
+
* connection that ends before it opens releases the ticket. A revoke, a rebind
|
|
240
|
+
* or a forget therefore either refuses the admission or cancels it, or
|
|
241
|
+
* comes after the request went out. The lock is held only for one read
|
|
242
|
+
* and the handoff, never across the network.
|
|
243
|
+
*/
|
|
244
|
+
withLaunchAdmission(
|
|
245
|
+
hostKey: SessionID,
|
|
246
|
+
admission: LaunchAdmission,
|
|
247
|
+
kind: 'start' | 'attach',
|
|
248
|
+
send: (ticket: LaunchTicket) => void,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
return this.withHostLock(hostKey, async () => {
|
|
251
|
+
const row = await this.store.findAuthBinding(hostKey);
|
|
252
|
+
|
|
253
|
+
const isProvisioning =
|
|
254
|
+
admission.attemptID !== null &&
|
|
255
|
+
row?.state === 'provisioning' &&
|
|
256
|
+
row.attemptID === admission.attemptID &&
|
|
257
|
+
row.rebind === null;
|
|
258
|
+
|
|
259
|
+
if (row === null || (row.state !== 'ready' && !isProvisioning)) {
|
|
260
|
+
throw new DaemonError(
|
|
261
|
+
'auth_blocked',
|
|
262
|
+
`the runtime auth of host ${hostKey} is ${row?.state ?? 'gone'}; nothing launches on it until a rebind`,
|
|
263
|
+
{ host: hostKey, state: row?.state ?? null },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (
|
|
268
|
+
kind === 'start' &&
|
|
269
|
+
(row.revision !== admission.revision || row.bindingHash !== admission.hash)
|
|
270
|
+
) {
|
|
271
|
+
throw new DaemonError(
|
|
272
|
+
'auth_rebind_required',
|
|
273
|
+
`the runtime auth of host ${hostKey} moved to revision ${String(row.revision)} after this launch was planned`,
|
|
274
|
+
{ host: hostKey, revision: row.revision },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const ticket: { refusal: DaemonError | null } = { refusal: null };
|
|
279
|
+
const tickets = this.pending.get(hostKey) ?? new Set();
|
|
280
|
+
|
|
281
|
+
tickets.add(ticket);
|
|
282
|
+
this.pending.set(hostKey, tickets);
|
|
283
|
+
|
|
284
|
+
const release = () => {
|
|
285
|
+
tickets.delete(ticket);
|
|
286
|
+
|
|
287
|
+
if (tickets.size === 0 && this.pending.get(hostKey) === tickets) {
|
|
288
|
+
this.pending.delete(hostKey);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
send({
|
|
293
|
+
check: () => {
|
|
294
|
+
release();
|
|
295
|
+
|
|
296
|
+
return ticket.refusal;
|
|
297
|
+
},
|
|
298
|
+
release,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// How many admitted requests on a host have not gone out or ended yet.
|
|
304
|
+
countPendingAdmissions(hostKey: SessionID): number {
|
|
305
|
+
return this.pending.get(hostKey)?.size ?? 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Stops every admitted request on a host that has not gone out yet,
|
|
309
|
+
// with the block the caller is about to record.
|
|
310
|
+
private revokeAdmissions(hostKey: SessionID, state: string): void {
|
|
311
|
+
const tickets = this.pending.get(hostKey);
|
|
312
|
+
|
|
313
|
+
this.pending.delete(hostKey);
|
|
314
|
+
|
|
315
|
+
for (const ticket of tickets ?? []) {
|
|
316
|
+
ticket.refusal = new DaemonError(
|
|
317
|
+
'auth_blocked',
|
|
318
|
+
`the runtime auth of host ${hostKey} is ${state}; nothing launches on it until a rebind`,
|
|
319
|
+
{ host: hostKey, state },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Takes back what one spawn attempt made: the imp, with every grant on
|
|
326
|
+
* it, but only an imp this attempt created whose id impd still shows
|
|
327
|
+
* under the name, and then the record. An imp atc cannot tell apart from
|
|
328
|
+
* one it did not make is left alone, and so is any attempt but the one
|
|
329
|
+
* given. Rejects with an effect that remains when it cannot confirm the
|
|
330
|
+
* imp gone, leaving the binding pending its take-back.
|
|
331
|
+
*/
|
|
332
|
+
async removeAttempt(host: BrokerAuthHost, hostKey: SessionID, attemptID: string): Promise<void> {
|
|
333
|
+
await this.withHostLock(hostKey, () => this.removeAttemptLocked(host, hostKey, attemptID));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Withdraws every grant of a host's binding. The block that stops every
|
|
338
|
+
* launch is written first, even when impd cannot be reached; a grant impd
|
|
339
|
+
* no longer holds counts as revoked; a running harness keeps running and
|
|
340
|
+
* impd fails its later requests. Rejects with `auth_revocation_pending`
|
|
341
|
+
* when any grant cannot be confirmed gone.
|
|
342
|
+
*/
|
|
343
|
+
async revokeBinding(host: BrokerAuthHost | null, hostKey: SessionID): Promise<void> {
|
|
344
|
+
await this.withHostLock(hostKey, async () => {
|
|
345
|
+
this.revokeAdmissions(hostKey, 'revocation_pending');
|
|
346
|
+
|
|
347
|
+
const row = await this.requireBinding(hostKey);
|
|
348
|
+
|
|
349
|
+
await this.store.updateAuthBinding(hostKey, { state: 'revocation_pending' }, this.now());
|
|
350
|
+
|
|
351
|
+
const pending = await this.revokeGrants(host, row);
|
|
352
|
+
|
|
353
|
+
if (pending.length > 0) {
|
|
354
|
+
throw buildRevocationPending(hostKey, pending);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const at = this.now();
|
|
358
|
+
|
|
359
|
+
await this.store.updateAuthBinding(hostKey, { state: 'revoked', revokedAt: at }, at);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Moves a host's binding to the current binding at the next revision:
|
|
365
|
+
* the full gate on the new binding and the imp's identity first, then
|
|
366
|
+
* each newly bound grant, then the removal of each grant the new binding
|
|
367
|
+
* drops. A failure after the attempt starts removes only the grants this
|
|
368
|
+
* attempt added, restores nothing, never destroys the imp, and leaves the
|
|
369
|
+
* binding at its old revision, blocked as a failed rebind until a retry.
|
|
370
|
+
* Resolves to the new revision.
|
|
371
|
+
*/
|
|
372
|
+
updateBinding(host: BrokerAuthHost, hostKey: SessionID, binding: AuthBinding): Promise<number> {
|
|
373
|
+
return this.withHostLock(hostKey, async () => {
|
|
374
|
+
this.revokeAdmissions(hostKey, 'provisioning');
|
|
375
|
+
|
|
376
|
+
const row = await this.requireBinding(hostKey);
|
|
377
|
+
|
|
378
|
+
// A spawn whose session listed but never recorded its start leaves a
|
|
379
|
+
// provisioned imp a rebind may take on.
|
|
380
|
+
const isProvisioned =
|
|
381
|
+
row.state === 'provisioning' && row.rebind === null && row.impID !== null;
|
|
382
|
+
|
|
383
|
+
if (
|
|
384
|
+
row.state !== 'ready' &&
|
|
385
|
+
row.state !== 'revoked' &&
|
|
386
|
+
row.state !== 'rebind_failed' &&
|
|
387
|
+
!isProvisioned
|
|
388
|
+
) {
|
|
389
|
+
throw new DaemonError(
|
|
390
|
+
'auth_blocked',
|
|
391
|
+
`the runtime auth of host ${hostKey} is ${row.state}, which a rebind cannot move on from`,
|
|
392
|
+
{ host: hostKey, state: row.state },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (row.impID === null) {
|
|
397
|
+
throw new DaemonError(
|
|
398
|
+
'auth_runtime_mismatch',
|
|
399
|
+
`host ${hostKey} never recorded its imp, so atc cannot rebind it`,
|
|
400
|
+
{ host: hostKey, imp: row.impName, recordedID: null, actualID: null },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
await verifyGate(host, row.impName, binding);
|
|
406
|
+
|
|
407
|
+
const imp = await verifyCleanupAuthority(
|
|
408
|
+
host.port,
|
|
409
|
+
{ name: row.impName, id: row.impID },
|
|
410
|
+
host.impPrefix,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
if (imp === null) {
|
|
414
|
+
throw new BrokerAuthorityError(
|
|
415
|
+
'auth_runtime_mismatch',
|
|
416
|
+
`imp ${row.impName} is gone, so atc cannot rebind it`,
|
|
417
|
+
{ imp: row.impName, recordedID: row.impID, actualID: null },
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw toAuthRefusal(error);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const revision = row.revision + 1;
|
|
425
|
+
const attemptID = randomUUID();
|
|
426
|
+
const bindingJSON = JSON.stringify(binding);
|
|
427
|
+
|
|
428
|
+
await this.store.updateAuthBinding(
|
|
429
|
+
hostKey,
|
|
430
|
+
{
|
|
431
|
+
state: 'provisioning',
|
|
432
|
+
rebind: { revision, bindingHash: binding.hash, bindingJSON, attemptID },
|
|
433
|
+
},
|
|
434
|
+
this.now(),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
await this.applyRebindGrants(host, row, binding, revision, attemptID);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
await this.removeRebindGrants(host, hostKey, row.impName, attemptID);
|
|
441
|
+
await this.store.updateAuthBinding(hostKey, { state: 'rebind_failed' }, this.now());
|
|
442
|
+
|
|
443
|
+
throw toAuthRefusal(error);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
await this.store.updateAuthBinding(
|
|
447
|
+
hostKey,
|
|
448
|
+
{
|
|
449
|
+
state: 'ready',
|
|
450
|
+
revision,
|
|
451
|
+
bindingHash: binding.hash,
|
|
452
|
+
bindingJSON,
|
|
453
|
+
attemptID,
|
|
454
|
+
rebind: null,
|
|
455
|
+
},
|
|
456
|
+
this.now(),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
return revision;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Destroys a bound host's imp for a forget and drops its record, and
|
|
465
|
+
* resolves false when the host has no binding, which leaves the destroy
|
|
466
|
+
* to the caller. The block is written first; only an imp impd still
|
|
467
|
+
* shows under the recorded id is destroyed, and the record goes once
|
|
468
|
+
* impd shows no imp under the name. Never deletes a secret.
|
|
469
|
+
*/
|
|
470
|
+
forgetBinding(host: BrokerAuthHost | null, hostKey: SessionID): Promise<boolean> {
|
|
471
|
+
return this.withHostLock(hostKey, async () => {
|
|
472
|
+
this.revokeAdmissions(hostKey, 'revocation_pending');
|
|
473
|
+
|
|
474
|
+
const row = await this.store.findAuthBinding(hostKey);
|
|
475
|
+
|
|
476
|
+
if (row === null) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
await this.store.updateAuthBinding(hostKey, { state: 'revocation_pending' }, this.now());
|
|
481
|
+
|
|
482
|
+
if (host === null) {
|
|
483
|
+
throw buildRevocationPending(hostKey, ['(imp unreachable)']);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
assertRecordedImpName(host, row);
|
|
488
|
+
|
|
489
|
+
const imp =
|
|
490
|
+
row.impID === null
|
|
491
|
+
? await tryReadImp(host, row.impName)
|
|
492
|
+
: await verifyCleanupAuthority(
|
|
493
|
+
host.port,
|
|
494
|
+
{ name: row.impName, id: row.impID },
|
|
495
|
+
host.impPrefix,
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
if (imp !== null && row.impID === null) {
|
|
499
|
+
throw new BrokerAuthorityError(
|
|
500
|
+
'auth_runtime_mismatch',
|
|
501
|
+
`imp ${row.impName} exists, but host ${hostKey} never recorded its id`,
|
|
502
|
+
{ imp: row.impName, recordedID: null, actualID: imp.id },
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (imp !== null) {
|
|
507
|
+
await host.destroyImp(hostKey);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if ((await tryReadImp(host, row.impName)) !== null) {
|
|
511
|
+
throw new Error(`imp ${row.impName} is still listed after its destroy`);
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
throw buildRevocationPending(hostKey, [formatError(error)]);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await this.store.removeAuthBinding(hostKey);
|
|
518
|
+
|
|
519
|
+
return true;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Settles, as a daemon starts, the bindings a stopped daemon left
|
|
525
|
+
* mid-change. A provisioning binding with no fleet entry on its host
|
|
526
|
+
* was a spawn that never listed, and its attempt is taken back; a
|
|
527
|
+
* rebind in flight becomes a failed rebind, whose added grants are
|
|
528
|
+
* removed. Each host is reached through its target's broker, or left as
|
|
529
|
+
* it is, still blocked, when its target has none here. Failures are
|
|
530
|
+
* reported and leave the binding blocked for the next start.
|
|
531
|
+
*/
|
|
532
|
+
async reconcileBindings(
|
|
533
|
+
findHost: (target: string) => BrokerAuthHost | null,
|
|
534
|
+
listedHostKeys: ReadonlySet<SessionID>,
|
|
535
|
+
log: (line: string) => void,
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
const rows = await this.store.collectAuthBindings();
|
|
538
|
+
|
|
539
|
+
for (const row of rows) {
|
|
540
|
+
const host = findHost(row.target);
|
|
541
|
+
const isOrphan = !listedHostKeys.has(row.hostKey);
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
if (
|
|
545
|
+
row.rebind !== null &&
|
|
546
|
+
(row.state === 'provisioning' || row.state === 'rebind_failed')
|
|
547
|
+
) {
|
|
548
|
+
await this.reconcileRebind(host, row);
|
|
549
|
+
} else if (
|
|
550
|
+
isOrphan &&
|
|
551
|
+
(row.state === 'provisioning' || row.state === 'rollback_pending') &&
|
|
552
|
+
host !== null
|
|
553
|
+
) {
|
|
554
|
+
await this.removeAttempt(host, row.hostKey, row.attemptID);
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
log(
|
|
558
|
+
`atc could not settle the runtime auth of host ${row.hostKey} (${formatError(error)}); it stays blocked`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Runs each call on a host after the one before it on that host.
|
|
565
|
+
private async withHostLock<T>(hostKey: SessionID, run: () => Promise<T>): Promise<T> {
|
|
566
|
+
const before = this.locks.get(hostKey);
|
|
567
|
+
const turn = Promise.withResolvers<void>();
|
|
568
|
+
|
|
569
|
+
this.locks.set(hostKey, turn.promise);
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
if (before !== undefined) {
|
|
573
|
+
await before;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return await run();
|
|
577
|
+
} finally {
|
|
578
|
+
turn.resolve();
|
|
579
|
+
|
|
580
|
+
if (this.locks.get(hostKey) === turn.promise) {
|
|
581
|
+
this.locks.delete(hostKey);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// The imp, then its id on the record, then the check that impd holds no
|
|
587
|
+
// grant on it, then each grant, each one recorded as granting before impd
|
|
588
|
+
// is asked and as granted after.
|
|
589
|
+
private async createImpAndGrants(
|
|
590
|
+
host: BrokerAuthHost,
|
|
591
|
+
hostKey: SessionID,
|
|
592
|
+
impName: string,
|
|
593
|
+
binding: AuthBinding,
|
|
594
|
+
attemptID: string,
|
|
595
|
+
): Promise<void> {
|
|
596
|
+
const existing = await host.port.readImp(impName);
|
|
597
|
+
|
|
598
|
+
if (existing !== null) {
|
|
599
|
+
await this.store.removeAuthBinding(hostKey);
|
|
600
|
+
|
|
601
|
+
throw new DaemonError(
|
|
602
|
+
'auth_runtime_exists',
|
|
603
|
+
`imp ${impName} already exists, and atc never grants a credential to an imp it did not make`,
|
|
604
|
+
{ imp: impName },
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
let imp: ImpView;
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
imp = await host.createImp(hostKey);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
// A create impd refused made nothing, so the record goes with it. One
|
|
614
|
+
// that never answered may have made an imp, which the take-back
|
|
615
|
+
// settles by what impd shows under the name.
|
|
616
|
+
if (error instanceof ImpPortError && error.code !== 'UNREACHABLE') {
|
|
617
|
+
await this.store.removeAuthBinding(hostKey);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
await this.store.updateAuthBinding(
|
|
624
|
+
hostKey,
|
|
625
|
+
{ impID: imp.id, impCreatedByAttempt: true },
|
|
626
|
+
this.now(),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const held = await host.port.readGrants(impName);
|
|
630
|
+
|
|
631
|
+
if (held.length > 0) {
|
|
632
|
+
throw new DaemonError(
|
|
633
|
+
'auth_grants_mismatch',
|
|
634
|
+
`the new imp ${impName} already holds ${held.join(', ')}`,
|
|
635
|
+
{ host: hostKey, extra: held },
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
for (const secret of binding.secrets) {
|
|
640
|
+
const grant = {
|
|
641
|
+
hostKey,
|
|
642
|
+
secret: secret.secret,
|
|
643
|
+
revision: 1,
|
|
644
|
+
attemptID,
|
|
645
|
+
preexisting: false,
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
await this.store.upsertAuthGrant({ ...grant, phase: 'granting' }, this.now());
|
|
649
|
+
await host.port.createGrant(impName, secret.secret);
|
|
650
|
+
await this.store.upsertAuthGrant({ ...grant, phase: 'granted' }, this.now());
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private async removeAttemptLocked(
|
|
655
|
+
host: BrokerAuthHost,
|
|
656
|
+
hostKey: SessionID,
|
|
657
|
+
attemptID: string,
|
|
658
|
+
): Promise<void> {
|
|
659
|
+
const row = await this.store.findAuthBinding(hostKey);
|
|
660
|
+
|
|
661
|
+
if (row === null || row.attemptID !== attemptID || row.rebind !== null) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
if (row.impID === null || !row.impCreatedByAttempt) {
|
|
667
|
+
// Nothing this attempt made carries an id, so only an empty name
|
|
668
|
+
// lets the record go.
|
|
669
|
+
if ((await tryReadImp(host, row.impName)) !== null) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`imp ${row.impName} exists, and nothing shows this attempt created it; reconcile it by hand`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
assertRecordedImpName(host, row);
|
|
676
|
+
|
|
677
|
+
const imp = await verifyCleanupAuthority(
|
|
678
|
+
host.port,
|
|
679
|
+
{ name: row.impName, id: row.impID },
|
|
680
|
+
host.impPrefix,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
if (imp !== null) {
|
|
684
|
+
await host.destroyImp(hostKey);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if ((await tryReadImp(host, row.impName)) !== null) {
|
|
688
|
+
throw new Error(`imp ${row.impName} is still listed after its destroy`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch (error) {
|
|
692
|
+
await this.store.updateAuthBinding(hostKey, { state: 'rollback_pending' }, this.now());
|
|
693
|
+
|
|
694
|
+
throw new EffectRemainsError(
|
|
695
|
+
`atc could not take back the runtime auth of host ${hostKey} (${formatError(error)})`,
|
|
696
|
+
{ cause: error },
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await this.store.removeAuthBinding(hostKey);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async requireBinding(hostKey: SessionID): Promise<RuntimeAuthBinding> {
|
|
704
|
+
const row = await this.store.findAuthBinding(hostKey);
|
|
705
|
+
|
|
706
|
+
if (row === null) {
|
|
707
|
+
throw new DaemonError(
|
|
708
|
+
'unsupported_operation',
|
|
709
|
+
`host ${hostKey} has no runtime auth binding`,
|
|
710
|
+
{ host: hostKey, problem: 'no_auth_binding' },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return row;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Revokes each bound or recorded grant and returns the secrets it could
|
|
718
|
+
// not confirm gone. An imp impd shows gone took every grant with it.
|
|
719
|
+
private async revokeGrants(
|
|
720
|
+
host: BrokerAuthHost | null,
|
|
721
|
+
row: RuntimeAuthBinding,
|
|
722
|
+
): Promise<string[]> {
|
|
723
|
+
const grants = await this.store.collectAuthGrants(row.hostKey);
|
|
724
|
+
|
|
725
|
+
const secrets = collectBindingSecrets(row, grants);
|
|
726
|
+
|
|
727
|
+
if (host === null || row.impID === null) {
|
|
728
|
+
await this.updateGrantPhases(row, grants, secrets, 'revocation_pending');
|
|
729
|
+
|
|
730
|
+
return secrets;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const recorded = { name: row.impName, id: row.impID };
|
|
734
|
+
let imp: ImpView | null;
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
imp = await verifyCleanupAuthority(host.port, recorded, host.impPrefix);
|
|
738
|
+
} catch {
|
|
739
|
+
await this.updateGrantPhases(row, grants, secrets, 'revocation_pending');
|
|
740
|
+
|
|
741
|
+
return secrets;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (imp === null) {
|
|
745
|
+
await this.updateGrantPhases(row, grants, secrets, 'revoked');
|
|
746
|
+
|
|
747
|
+
return [];
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const pending: string[] = [];
|
|
751
|
+
|
|
752
|
+
for (const secret of secrets) {
|
|
753
|
+
await this.updateGrantPhases(row, grants, [secret], 'revoking');
|
|
754
|
+
|
|
755
|
+
const revoked = await tryRevokeGrant(host, row.impName, secret);
|
|
756
|
+
|
|
757
|
+
const phase = revoked ? 'revoked' : 'revocation_pending';
|
|
758
|
+
|
|
759
|
+
await this.updateGrantPhases(row, grants, [secret], phase);
|
|
760
|
+
|
|
761
|
+
if (!revoked) {
|
|
762
|
+
pending.push(secret);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return pending;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private async updateGrantPhases(
|
|
770
|
+
row: RuntimeAuthBinding,
|
|
771
|
+
grants: readonly RuntimeAuthGrant[],
|
|
772
|
+
secrets: readonly string[],
|
|
773
|
+
phase: RuntimeAuthGrant['phase'],
|
|
774
|
+
): Promise<void> {
|
|
775
|
+
for (const secret of secrets) {
|
|
776
|
+
const grant = grants.find((g) => g.secret === secret);
|
|
777
|
+
|
|
778
|
+
await this.store.upsertAuthGrant(
|
|
779
|
+
{
|
|
780
|
+
hostKey: row.hostKey,
|
|
781
|
+
secret,
|
|
782
|
+
revision: grant?.revision ?? row.revision,
|
|
783
|
+
attemptID: grant?.attemptID ?? row.attemptID,
|
|
784
|
+
preexisting: grant?.preexisting ?? false,
|
|
785
|
+
phase,
|
|
786
|
+
},
|
|
787
|
+
this.now(),
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Grants each secret of the new binding the imp lacks, recording one it
|
|
793
|
+
// already holds as preexisting, then revokes each held grant the new
|
|
794
|
+
// binding drops.
|
|
795
|
+
private async applyRebindGrants(
|
|
796
|
+
host: BrokerAuthHost,
|
|
797
|
+
row: RuntimeAuthBinding,
|
|
798
|
+
binding: AuthBinding,
|
|
799
|
+
revision: number,
|
|
800
|
+
attemptID: string,
|
|
801
|
+
): Promise<void> {
|
|
802
|
+
const held = await host.port.readGrants(row.impName);
|
|
803
|
+
|
|
804
|
+
const bound = binding.secrets.map((secret) => secret.secret);
|
|
805
|
+
|
|
806
|
+
for (const secret of bound) {
|
|
807
|
+
const grant = { hostKey: row.hostKey, secret, revision, attemptID };
|
|
808
|
+
|
|
809
|
+
if (held.includes(secret)) {
|
|
810
|
+
await this.store.upsertAuthGrant(
|
|
811
|
+
{ ...grant, preexisting: true, phase: 'granted' },
|
|
812
|
+
this.now(),
|
|
813
|
+
);
|
|
814
|
+
} else {
|
|
815
|
+
await this.store.upsertAuthGrant(
|
|
816
|
+
{ ...grant, preexisting: false, phase: 'granting' },
|
|
817
|
+
this.now(),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
await host.port.createGrant(row.impName, secret);
|
|
821
|
+
|
|
822
|
+
await this.store.upsertAuthGrant(
|
|
823
|
+
{ ...grant, preexisting: false, phase: 'granted' },
|
|
824
|
+
this.now(),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
for (const secret of held.filter((name) => !bound.includes(name))) {
|
|
830
|
+
const grant = {
|
|
831
|
+
hostKey: row.hostKey,
|
|
832
|
+
secret,
|
|
833
|
+
revision,
|
|
834
|
+
attemptID,
|
|
835
|
+
preexisting: true,
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
await this.store.upsertAuthGrant({ ...grant, phase: 'revoking' }, this.now());
|
|
839
|
+
|
|
840
|
+
if (!(await tryRevokeGrant(host, row.impName, secret))) {
|
|
841
|
+
await this.store.upsertAuthGrant({ ...grant, phase: 'revocation_pending' }, this.now());
|
|
842
|
+
|
|
843
|
+
throw new DaemonError(
|
|
844
|
+
'auth_revocation_pending',
|
|
845
|
+
`imp ${row.impName} may still hold ${secret}, which the new binding drops`,
|
|
846
|
+
{ host: row.hostKey, pending: [secret] },
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
await this.store.upsertAuthGrant({ ...grant, phase: 'revoked' }, this.now());
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Revokes each grant a rebind attempt added, which only it can have
|
|
855
|
+
// made, and records each it could not confirm gone as pending.
|
|
856
|
+
private async removeRebindGrants(
|
|
857
|
+
host: BrokerAuthHost | null,
|
|
858
|
+
hostKey: SessionID,
|
|
859
|
+
impName: string,
|
|
860
|
+
attemptID: string,
|
|
861
|
+
): Promise<void> {
|
|
862
|
+
const grants = await this.store.collectAuthGrants(hostKey);
|
|
863
|
+
|
|
864
|
+
const added = grants.filter(
|
|
865
|
+
(grant) =>
|
|
866
|
+
grant.attemptID === attemptID &&
|
|
867
|
+
!grant.preexisting &&
|
|
868
|
+
(grant.phase === 'granting' ||
|
|
869
|
+
grant.phase === 'granted' ||
|
|
870
|
+
grant.phase === 'uncertain' ||
|
|
871
|
+
grant.phase === 'revocation_pending'),
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
for (const grant of added) {
|
|
875
|
+
const revoked = host === null ? false : await tryRevokeGrant(host, impName, grant.secret);
|
|
876
|
+
|
|
877
|
+
await this.store.upsertAuthGrant(
|
|
878
|
+
{ ...grant, phase: revoked ? 'revoked' : 'revocation_pending' },
|
|
879
|
+
this.now(),
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// A rebind a stopped daemon left in flight failed: it is blocked first,
|
|
885
|
+
// and the grants its attempt added are removed only from the imp it was
|
|
886
|
+
// recorded against. A failed rebind whose removal never finished is
|
|
887
|
+
// tried again on each start. Nothing changes unless the binding still
|
|
888
|
+
// holds the same imp, revision, and rebind attempt under the host's lock.
|
|
889
|
+
private async reconcileRebind(
|
|
890
|
+
host: BrokerAuthHost | null,
|
|
891
|
+
row: RuntimeAuthBinding,
|
|
892
|
+
): Promise<void> {
|
|
893
|
+
const rebind = row.rebind;
|
|
894
|
+
const impID = row.impID;
|
|
895
|
+
|
|
896
|
+
if (rebind === null) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
await this.withHostLock(row.hostKey, async () => {
|
|
901
|
+
const current = await this.store.findAuthBinding(row.hostKey);
|
|
902
|
+
|
|
903
|
+
if (
|
|
904
|
+
current === null ||
|
|
905
|
+
current.impID !== impID ||
|
|
906
|
+
current.revision !== row.revision ||
|
|
907
|
+
current.state !== row.state ||
|
|
908
|
+
current.rebind?.attemptID !== rebind.attemptID
|
|
909
|
+
) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
await this.store.updateAuthBinding(row.hostKey, { state: 'rebind_failed' }, this.now());
|
|
914
|
+
|
|
915
|
+
if (host === null || impID === null) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
assertRecordedImpName(host, row);
|
|
920
|
+
|
|
921
|
+
const imp = await verifyCleanupAuthority(
|
|
922
|
+
host.port,
|
|
923
|
+
{ name: row.impName, id: impID },
|
|
924
|
+
host.impPrefix,
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
if (imp !== null) {
|
|
928
|
+
await this.removeRebindGrants(host, row.hostKey, row.impName, rebind.attemptID);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Refuses a provider whose name for the host is not the imp the binding
|
|
935
|
+
// recorded, such as after the target's imp name prefix changed, so a
|
|
936
|
+
// destroy by host key never reaches another imp.
|
|
937
|
+
function assertRecordedImpName(host: BrokerAuthHost, row: RuntimeAuthBinding): void {
|
|
938
|
+
const name = host.getImpName(row.hostKey);
|
|
939
|
+
|
|
940
|
+
if (name !== row.impName) {
|
|
941
|
+
throw new BrokerAuthorityError(
|
|
942
|
+
'auth_runtime_mismatch',
|
|
943
|
+
`host ${row.hostKey} was bound to imp ${row.impName}, but its target now names it ${name}`,
|
|
944
|
+
{ imp: row.impName, recordedID: row.impID, actualID: null },
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// The full gate on a binding: impd's features and the token's authority
|
|
950
|
+
// over the imp and every bound secret, then each secret's kind and rules.
|
|
951
|
+
async function verifyGate(
|
|
952
|
+
host: BrokerAuthHost,
|
|
953
|
+
impName: string,
|
|
954
|
+
binding: AuthBinding,
|
|
955
|
+
): Promise<void> {
|
|
956
|
+
const secrets = binding.secrets.map((secret) => secret.secret);
|
|
957
|
+
|
|
958
|
+
await verifyBrokerAuthority(host.port, { impNames: [impName], secrets }, host.impPrefix);
|
|
959
|
+
|
|
960
|
+
const held = await host.port.readSecrets();
|
|
961
|
+
|
|
962
|
+
const mismatches = collectSecretRuleMismatches(binding.secrets, held);
|
|
963
|
+
|
|
964
|
+
if (mismatches.length > 0) {
|
|
965
|
+
throw new DaemonError(
|
|
966
|
+
'auth_secret_mismatch',
|
|
967
|
+
`impd holds ${mismatches.map((mismatch) => `${mismatch.secret} (${mismatch.reason})`).join(', ')} unlike the binding`,
|
|
968
|
+
{ mismatches },
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function tryReadImp(host: BrokerAuthHost, name: string) {
|
|
974
|
+
try {
|
|
975
|
+
return await host.port.readImp(name);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
throw toAuthRefusal(error);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function tryReadGrants(host: BrokerAuthHost, name: string): Promise<readonly string[]> {
|
|
982
|
+
try {
|
|
983
|
+
return await host.port.readGrants(name);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
throw toAuthRefusal(error);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Whether a grant is gone: revoked now, already gone, its imp or secret
|
|
990
|
+
// gone, or, when impd forbids the revoke, absent from the imp's grants.
|
|
991
|
+
async function tryRevokeGrant(
|
|
992
|
+
host: BrokerAuthHost,
|
|
993
|
+
impName: string,
|
|
994
|
+
secret: string,
|
|
995
|
+
): Promise<boolean> {
|
|
996
|
+
try {
|
|
997
|
+
await host.port.removeGrant(impName, secret);
|
|
998
|
+
|
|
999
|
+
return true;
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error instanceof ImpPortError && error.code === 'NOT_FOUND') {
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (!(error instanceof ImpPortError && error.code === 'FORBIDDEN')) {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
try {
|
|
1011
|
+
const held = await host.port.readGrants(impName);
|
|
1012
|
+
|
|
1013
|
+
return !held.includes(secret);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
return error instanceof ImpPortError && error.code === 'NOT_FOUND';
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// The secrets a binding grants and any its grant rows still record.
|
|
1020
|
+
function collectBindingSecrets(
|
|
1021
|
+
row: RuntimeAuthBinding,
|
|
1022
|
+
grants: readonly RuntimeAuthGrant[],
|
|
1023
|
+
): string[] {
|
|
1024
|
+
const bound = parseBoundSecrets(row.bindingJSON);
|
|
1025
|
+
|
|
1026
|
+
return [...new Set([...bound, ...grants.map((grant) => grant.secret)])].toSorted();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function parseBoundSecrets(json: string): string[] {
|
|
1030
|
+
const parsed: unknown = JSON.parse(json);
|
|
1031
|
+
|
|
1032
|
+
if (typeof parsed !== 'object' || parsed === null || !('secrets' in parsed)) {
|
|
1033
|
+
return [];
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const secrets = parsed.secrets;
|
|
1037
|
+
|
|
1038
|
+
if (!Array.isArray(secrets)) {
|
|
1039
|
+
return [];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return secrets.flatMap((secret: unknown) =>
|
|
1043
|
+
typeof secret === 'object' &&
|
|
1044
|
+
secret !== null &&
|
|
1045
|
+
'secret' in secret &&
|
|
1046
|
+
typeof secret.secret === 'string'
|
|
1047
|
+
? [secret.secret]
|
|
1048
|
+
: [],
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function buildRevocationPending(hostKey: SessionID, pending: readonly string[]): DaemonError {
|
|
1053
|
+
return new DaemonError(
|
|
1054
|
+
'auth_revocation_pending',
|
|
1055
|
+
`revocation pending for host ${hostKey}, owner intervention needed: atc could not confirm ${pending.join(', ')} gone, and launches stay blocked`,
|
|
1056
|
+
{ host: hostKey, pending },
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// An impd refusal or an authority refusal as the daemon error it answers.
|
|
1061
|
+
function toAuthRefusal(error: unknown): unknown {
|
|
1062
|
+
if (error instanceof DaemonError || error instanceof EffectRemainsError) {
|
|
1063
|
+
return error;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (error instanceof BrokerAuthorityError) {
|
|
1067
|
+
return new DaemonError(error.code, error.message, error.data);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (error instanceof ImpPortError) {
|
|
1071
|
+
return new DaemonError(
|
|
1072
|
+
'host_unavailable',
|
|
1073
|
+
`impd refused a runtime auth call: ${error.message}`,
|
|
1074
|
+
{
|
|
1075
|
+
provider: 'imp',
|
|
1076
|
+
problem: error.code.toLowerCase(),
|
|
1077
|
+
},
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return error;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function formatError(error: unknown): string {
|
|
1085
|
+
return error instanceof Error ? error.message : String(error);
|
|
1086
|
+
}
|