@cotal-ai/auth 0.57.0 → 0.58.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/dist/auth-admin.d.ts +10 -0
- package/dist/auth-admin.d.ts.map +1 -1
- package/dist/auth-admin.js +162 -213
- package/dist/auth-admin.js.map +1 -1
- package/dist/auth-service-contract.d.ts +64 -0
- package/dist/auth-service-contract.d.ts.map +1 -0
- package/dist/auth-service-contract.js +131 -0
- package/dist/auth-service-contract.js.map +1 -0
- package/dist/authority-client.d.ts +16 -0
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +61 -1
- package/dist/authority-client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/manager-authority.d.ts +102 -1
- package/dist/manager-authority.d.ts.map +1 -1
- package/dist/manager-authority.js +276 -16
- package/dist/manager-authority.js.map +1 -1
- package/dist/manager-contract.d.ts +4 -0
- package/dist/manager-contract.d.ts.map +1 -1
- package/dist/manager-contract.js +7 -1
- package/dist/manager-contract.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +8 -0
- package/dist/provider.js.map +1 -1
- package/dist/service.d.ts +68 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +572 -149
- package/dist/service.js.map +1 -1
- package/package.json +6 -6
package/dist/service.js
CHANGED
|
@@ -54,11 +54,12 @@
|
|
|
54
54
|
*/
|
|
55
55
|
import { createHash, randomBytes } from "node:crypto";
|
|
56
56
|
import { createServer } from "node:http";
|
|
57
|
+
import { resolve } from "node:path";
|
|
57
58
|
import { connect, credsAuthenticator } from "@nats-io/transport-node";
|
|
58
59
|
import { jetstreamManager } from "@nats-io/jetstream";
|
|
59
60
|
import { Kvm } from "@nats-io/kv";
|
|
60
|
-
import { admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, readSvcRecordLeader, reconcileEndpointGate, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC } from "@cotal-ai/core";
|
|
61
|
-
import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
|
|
61
|
+
import { contractRefToHex, contractStoreContext, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, authorizeServeGrant, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, SERVICE_READY, writeServiceStatus } from "@cotal-ai/core";
|
|
62
|
+
import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
|
|
62
63
|
import { decodeJwt } from "jose";
|
|
63
64
|
import { deriveOwnerForIdpSubject } from "./derive.js";
|
|
64
65
|
import { startAuthCallout } from "./callout.js";
|
|
@@ -66,15 +67,15 @@ import { createIdpBridge, verifyIdpToken } from "./idp.js";
|
|
|
66
67
|
import { PUBLIC_EXCHANGE_VIEWS } from "./token.js";
|
|
67
68
|
import { pinnedJwksResolver } from "./issuer.js";
|
|
68
69
|
import { calloutPermissions } from "./permissions.js";
|
|
69
|
-
import { issueRemoteManagerAuthority } from "./manager-authority.js";
|
|
70
|
+
import { admitRemoteRun, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, issueRemoteManagerAuthority, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest } from "./manager-authority.js";
|
|
70
71
|
import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
|
|
71
72
|
import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement } from "./managed-agent-enrollment.js";
|
|
72
73
|
import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
|
|
73
74
|
import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
|
|
74
75
|
import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
|
|
75
76
|
import { validateRetainedManagedAgent } from "./continuity.js";
|
|
76
|
-
import { reconstructRemoteManagerServeGrant } from "./manager-contract.js";
|
|
77
|
-
import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, remoteManagerIssuerGrants, remoteManagerRegistrationProof } from "./authority-client.js";
|
|
77
|
+
import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster } from "./manager-contract.js";
|
|
78
|
+
import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, remoteManagerIssuerGrants, remoteManagerRegistrationProof, authRegistrationExecutorGrants } from "./authority-client.js";
|
|
78
79
|
import { authorizeConnectCredential } from "./connect-reader.js";
|
|
79
80
|
import { ensureRootCredential } from "./root-credential.js";
|
|
80
81
|
import { observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
|
|
@@ -87,6 +88,7 @@ import { resumeAgentRetirement, runAgentRetirementBarrier } from "./retirement-b
|
|
|
87
88
|
import { makeRetirementCleaners } from "./retirement-cleaner.js";
|
|
88
89
|
import { makeDrainRepairers } from "./drain-repair.js";
|
|
89
90
|
import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
|
|
91
|
+
import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
|
|
90
92
|
import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
|
|
91
93
|
import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
|
|
92
94
|
import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
|
|
@@ -143,6 +145,10 @@ export function authorizeRemoteManagerRetirement(args) {
|
|
|
143
145
|
*/
|
|
144
146
|
export async function openAuthAuthorityPlane(opts) {
|
|
145
147
|
const { server, space, dataAccount, log } = opts;
|
|
148
|
+
const standingTtl = opts.standingRenewableTtlSeconds ?? STANDING_RENEWABLE_TTL_SEC;
|
|
149
|
+
if (!Number.isSafeInteger(standingTtl) || standingTtl < 5 || standingTtl > STANDING_RENEWABLE_TTL_SEC) {
|
|
150
|
+
throw new Error(`standingRenewableTtlSeconds must be an integer between 5 and ${STANDING_RENEWABLE_TTL_SEC} seconds (got ${standingTtl})`);
|
|
151
|
+
}
|
|
146
152
|
const writer = await openAuthorityClient({ server, space, dataAccount, label: `cotal:auth-mint:${space}`, grants: (id) => authorityWriterGrants(space, id), log });
|
|
147
153
|
let registry;
|
|
148
154
|
try {
|
|
@@ -153,6 +159,87 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
153
159
|
await writer.close();
|
|
154
160
|
throw e;
|
|
155
161
|
}
|
|
162
|
+
// #399 M2: register the auth plane itself as an ordinary `auth` service endpoint — the SAME
|
|
163
|
+
// §13.7 registration ceremony the manager runs (`manager.ts:6562-6690`), before the listener
|
|
164
|
+
// below serves a single request. The persisted instance id + serve nkey (under the plane's
|
|
165
|
+
// `dir`, hardened the way the manager's own instance identity is) so a restart re-registers the
|
|
166
|
+
// SAME instance: `registerServiceInstance` advances the process epoch on a re-registration and
|
|
167
|
+
// fences the predecessor; a first registration stays at epoch 0.
|
|
168
|
+
let authServeInstanceId;
|
|
169
|
+
let authServeGrant;
|
|
170
|
+
try {
|
|
171
|
+
const persisted = loadAuthInstanceIdentity(opts.dir, space);
|
|
172
|
+
const authIdentity = persisted ?? createAuthInstanceIdentity(opts.dir, space, {
|
|
173
|
+
instanceId: mintLifecycleUid(),
|
|
174
|
+
serveIdentity: newIdentity(),
|
|
175
|
+
});
|
|
176
|
+
const iid = authIdentity.instanceId;
|
|
177
|
+
const artifacts = authClusterArtifacts();
|
|
178
|
+
const store = new Map([
|
|
179
|
+
[artifacts.rootDigest, artifacts.document],
|
|
180
|
+
[artifacts.closureDigest, artifacts.manifest],
|
|
181
|
+
]);
|
|
182
|
+
const readClusterArtifact = (digest) => store.get(digest);
|
|
183
|
+
// §13.9 name authority, static mode: the auth plane holds the space signing seed, so it
|
|
184
|
+
// self-authorizes exactly its own name ("auth") for exactly DEV_OWNER — mirrors the manager's
|
|
185
|
+
// own self-authorization for "manager".
|
|
186
|
+
const authority = {
|
|
187
|
+
authorize: (name, owner) => ({ authorized: name === AUTH_SERVICE_ENDPOINT && owner === DEV_OWNER, revision: 0 }),
|
|
188
|
+
};
|
|
189
|
+
const servePrincipal = principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key;
|
|
190
|
+
const registrationOpId = mintLifecycleUid();
|
|
191
|
+
const regClient = await openAuthorityClient({
|
|
192
|
+
server, space, dataAccount, label: `cotal:auth-registration:${space}`,
|
|
193
|
+
grants: (id) => authRegistrationExecutorGrants(space, id, iid),
|
|
194
|
+
log,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const authKv = await new Kvm(regClient.nc).open(epAuthBucket(space));
|
|
198
|
+
const recordsKv = await new Kvm(regClient.nc).open(recordsBucket(space));
|
|
199
|
+
// §13.7 contract-artifact publication BEFORE the registration that advertises the digests.
|
|
200
|
+
const storeCtx = await contractStoreContext(regClient.nc, space);
|
|
201
|
+
for (const value of [...authContractArtifactValues(), artifacts.document, artifacts.manifest])
|
|
202
|
+
await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
|
|
203
|
+
// §13.1 pre-registration: the issuance gate, born open@gen0, provisioned ONCE on the FIRST
|
|
204
|
+
// registration. On a restart the gate already exists; `registerServiceInstance` below
|
|
205
|
+
// freezes + re-registers it (advancing the epoch).
|
|
206
|
+
if ((await serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid }).observe()) === null)
|
|
207
|
+
await provisionEndpointGateOpen(authKv, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, principal: servePrincipal });
|
|
208
|
+
const barrier = endpointRegistrationBarrier(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, opId: registrationOpId });
|
|
209
|
+
const spec = { endpoint: AUTH_SERVICE_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
|
|
210
|
+
const { registrationRevision } = await registerServiceInstance(recordsKv, {
|
|
211
|
+
space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
|
|
212
|
+
});
|
|
213
|
+
const fence = serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid });
|
|
214
|
+
const observed = await fence.observe();
|
|
215
|
+
if (observed === null)
|
|
216
|
+
throw new Error(`the issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid} vanished after registration`);
|
|
217
|
+
const readProcessEpoch = async () => {
|
|
218
|
+
const g = await fence.observe();
|
|
219
|
+
if (g === null)
|
|
220
|
+
throw new Error(`no issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid}`);
|
|
221
|
+
return g.processEpoch;
|
|
222
|
+
};
|
|
223
|
+
const grant = await authorizeServeGrant(recordsKv, {
|
|
224
|
+
space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
|
|
225
|
+
holder: { owner: DEV_OWNER }, authority, readClusterArtifact, readProcessEpoch,
|
|
226
|
+
});
|
|
227
|
+
await writeServiceStatus(recordsKv, {
|
|
228
|
+
endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
|
|
229
|
+
status: { state: SERVICE_READY, epoch: observed.processEpoch, observedSpecRevision: registrationRevision },
|
|
230
|
+
readProcessEpoch,
|
|
231
|
+
});
|
|
232
|
+
authServeInstanceId = iid;
|
|
233
|
+
authServeGrant = grant;
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await regClient.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
await writer.close();
|
|
241
|
+
throw e;
|
|
242
|
+
}
|
|
156
243
|
let reader;
|
|
157
244
|
try {
|
|
158
245
|
reader = await openSupervisedConnectReader({ server, space, dataAccount, log });
|
|
@@ -325,7 +412,7 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
325
412
|
// the FRESH space-manager-lease holder check) and dispatches.
|
|
326
413
|
let authAdmin;
|
|
327
414
|
try {
|
|
328
|
-
authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, log });
|
|
415
|
+
authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, instanceId: authServeInstanceId, epoch: authServeGrant.epoch, grant: authServeGrant, log });
|
|
329
416
|
}
|
|
330
417
|
catch (e) {
|
|
331
418
|
closing = true;
|
|
@@ -340,9 +427,34 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
340
427
|
}
|
|
341
428
|
const fileArm = ledgerAuthorizeConnect(opts.dir);
|
|
342
429
|
const recordsJsm = await jetstreamManager(remoteIssuer.nc);
|
|
343
|
-
const
|
|
430
|
+
const loadLocalManager = opts.localManager ?? (() => undefined);
|
|
431
|
+
// Standing renewal re-derives the serve surface from the REGISTERED service spec at the gate's
|
|
432
|
+
// registration revision: spec (leader read) -> closure manifest -> root document, each verified
|
|
433
|
+
// against its content digest. The request never selects the surface.
|
|
434
|
+
const registeredManagerCluster = async (owner, instanceId, observed) => {
|
|
435
|
+
const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
|
|
436
|
+
if (!rec || "deleted" in rec || rec.revision !== observed.registrationRevision)
|
|
437
|
+
throw new EpEnvelopeError("failed-precondition", "manager-service renewal found no service spec at the gate's registration revision");
|
|
438
|
+
const spec = parseServiceSpec(rec.value, { endpoint: "manager" });
|
|
439
|
+
if (spec.owner !== owner)
|
|
440
|
+
throw new EpEnvelopeError("permission-denied", "manager-service renewal spec belongs to another owner");
|
|
441
|
+
const store = await contractStoreContext(remoteIssuer.nc, space);
|
|
442
|
+
const read = async (digest) => {
|
|
443
|
+
const bytes = await fetchContractArtifact(store, contractRefToHex(digest));
|
|
444
|
+
if (!bytes)
|
|
445
|
+
throw new EpEnvelopeError("failed-precondition", `manager-service renewal cannot read registered contract artifact ${digest}`);
|
|
446
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
447
|
+
};
|
|
448
|
+
for (const closure of spec.clusterDigests) {
|
|
449
|
+
const { root } = verifyClusterManifest(closure, await read(closure));
|
|
450
|
+
const document = await read(root);
|
|
451
|
+
if (verifyClusterRoot(root, document).urn === "ai.cotal.manager")
|
|
452
|
+
return document;
|
|
453
|
+
}
|
|
454
|
+
throw new EpEnvelopeError("failed-precondition", "manager-service renewal spec registers no manager cluster");
|
|
455
|
+
};
|
|
344
456
|
const managerGate = async (owner, instanceId) => {
|
|
345
|
-
const localManager =
|
|
457
|
+
const localManager = loadLocalManager();
|
|
346
458
|
const gate = await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe();
|
|
347
459
|
if (!gate)
|
|
348
460
|
return "unknown";
|
|
@@ -379,7 +491,7 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
379
491
|
const remoteInstances = [];
|
|
380
492
|
const remoteLive = [];
|
|
381
493
|
let localCandidate;
|
|
382
|
-
const localManager =
|
|
494
|
+
const localManager = loadLocalManager();
|
|
383
495
|
for (const raw of await scanner.scanManagerGates()) {
|
|
384
496
|
if (raw.op !== undefined)
|
|
385
497
|
continue;
|
|
@@ -494,13 +606,43 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
494
606
|
},
|
|
495
607
|
issueManagerServiceAuthority: async ({ owner, scope, request }) => {
|
|
496
608
|
refuseIfFenced();
|
|
609
|
+
const observeManagerGate = async (instanceId) => (await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe()) ?? null;
|
|
497
610
|
return issueRemoteManagerAuthority({
|
|
498
611
|
owner,
|
|
499
612
|
scope,
|
|
500
613
|
request,
|
|
614
|
+
authorizeRenewal: ({ owner: o, request: r }) => authorizeRemoteManagerRenewal({
|
|
615
|
+
request: r, owner: o, space, accountPublicKey: dataAccount.pub, proofSecret: dataAccount.signingSeed,
|
|
616
|
+
observeManagerGate,
|
|
617
|
+
observeRun: async (runId) => {
|
|
618
|
+
const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
|
|
619
|
+
return await observeHostedRunAttempt(recordsKv, "manager", runId, {
|
|
620
|
+
supervisorId: r.identities.supervisor.id,
|
|
621
|
+
instanceId: r.instanceId,
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
}),
|
|
501
625
|
issue: async ({ actors, request: r }) => {
|
|
502
626
|
const credential = async (key, profile, actor, opts) => mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.identities[key].id, profile, { ...opts, principal: { owner, actor }, lifecycleUid: r.managerLifecycleUid });
|
|
503
627
|
const credentials = {};
|
|
628
|
+
const siblingOn = (gate, observed) => async (key, profile, actor) => {
|
|
629
|
+
const issued = await credential(key, profile, actor, profile === "goal-writer" ? { goalWriter: { endpoint: "manager" }, expiresInSeconds: standingTtl } : { expiresInSeconds: standingTtl });
|
|
630
|
+
await commitSiblingIssuance(gate, observed, {
|
|
631
|
+
credentialId: rawDigest(issued.jwt).replace("sha256:", "sha256-"),
|
|
632
|
+
credentialKey: r.identities[key].id,
|
|
633
|
+
holderPrincipal: `${owner}.${actor}`,
|
|
634
|
+
endpoint: "manager",
|
|
635
|
+
lifecycleUid: r.instanceId,
|
|
636
|
+
sourceChain: ["root"],
|
|
637
|
+
state: "active",
|
|
638
|
+
exp: issued.exp,
|
|
639
|
+
generation: observed.generation,
|
|
640
|
+
processEpoch: observed.processEpoch,
|
|
641
|
+
registrationRevision: observed.registrationRevision,
|
|
642
|
+
nameAuthorityRevision: observed.nameAuthorityRevision,
|
|
643
|
+
});
|
|
644
|
+
return issued;
|
|
645
|
+
};
|
|
504
646
|
if (r.operation === "renew") {
|
|
505
647
|
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId: r.instanceId });
|
|
506
648
|
const observed = await gate.observe();
|
|
@@ -516,7 +658,7 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
516
658
|
if (r.operation === "prepare" || r.operation === "renew") {
|
|
517
659
|
credentials.supervisor = await credential("supervisor", "remote-manager", actors.supervisor, {
|
|
518
660
|
remoteManager: { instanceId: r.instanceId, owner, actor: actors.supervisor },
|
|
519
|
-
expiresInSeconds:
|
|
661
|
+
expiresInSeconds: standingTtl,
|
|
520
662
|
});
|
|
521
663
|
// The executor receives the exact instance-scoped registration/maintenance surface under
|
|
522
664
|
// a separate nkey and bounded five-minute lifetime. The participant retains it for the
|
|
@@ -564,6 +706,71 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
564
706
|
});
|
|
565
707
|
return { credentials };
|
|
566
708
|
}
|
|
709
|
+
if (r.operation === "renewStandingBundle") {
|
|
710
|
+
// authorizeRenewal fresh-checked account, owner, epoch and the current-registration proof
|
|
711
|
+
// (which binds the held nkeys and lifecycle). Re-observe so every row binds one revision.
|
|
712
|
+
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId: r.instanceId });
|
|
713
|
+
const observed = await gate.observe();
|
|
714
|
+
if (!observed || observed.state !== "open" || observed.processEpoch !== r.processEpoch)
|
|
715
|
+
throw new EpEnvelopeError("conflict", "manager-service standing renewal gate moved before issuance");
|
|
716
|
+
credentials.supervisor = await credential("supervisor", "remote-manager", actors.supervisor, {
|
|
717
|
+
remoteManager: { instanceId: r.instanceId, owner, actor: actors.supervisor },
|
|
718
|
+
expiresInSeconds: standingTtl,
|
|
719
|
+
});
|
|
720
|
+
credentials.executor = await credential("executor", "remote-manager", actors.executor, {
|
|
721
|
+
remoteManager: { instanceId: r.instanceId, owner, actor: actors.executor },
|
|
722
|
+
expiresInSeconds: 5 * 60,
|
|
723
|
+
});
|
|
724
|
+
credentials.serve = await mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.identities.serve.id, "endpoint-serve", {
|
|
725
|
+
principal: { owner, actor: actors.serve },
|
|
726
|
+
lifecycleUid: r.managerLifecycleUid,
|
|
727
|
+
expiresInSeconds: standingTtl,
|
|
728
|
+
endpointServe: remoteManagerServeGrantFromCluster(r, owner, await registeredManagerCluster(owner, r.instanceId, observed), observed),
|
|
729
|
+
serveIssuance: gate,
|
|
730
|
+
});
|
|
731
|
+
const sibling = siblingOn(gate, observed);
|
|
732
|
+
credentials.goalWriter = await sibling("goalWriter", "goal-writer", actors.goalWriter);
|
|
733
|
+
credentials.sessionLedger = await sibling("sessionLedger", "session-ledger", actors.sessionLedger);
|
|
734
|
+
return { credentials };
|
|
735
|
+
}
|
|
736
|
+
if (r.operation === "renewRunDriver") {
|
|
737
|
+
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId: r.instanceId });
|
|
738
|
+
const observed = await gate.observe();
|
|
739
|
+
if (!observed || observed.state !== "open" || observed.processEpoch !== r.processEpoch)
|
|
740
|
+
throw new EpEnvelopeError("conflict", "manager-service run renewal gate moved before issuance");
|
|
741
|
+
const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
|
|
742
|
+
const run = await observeHostedRunAttempt(recordsKv, "manager", r.run.runId, {
|
|
743
|
+
supervisorId: r.identities.supervisor.id,
|
|
744
|
+
instanceId: r.instanceId,
|
|
745
|
+
});
|
|
746
|
+
if (!run || run.state !== "running" || run.instanceId !== r.instanceId ||
|
|
747
|
+
run.holder !== r.run.holder || run.takeoverId !== r.run.takeoverId ||
|
|
748
|
+
run.epoch !== r.run.epoch || run.fencingToken !== r.run.fencingToken)
|
|
749
|
+
throw new EpEnvelopeError("conflict", "manager-service run renewal authority moved before issuance");
|
|
750
|
+
const caller = runDriverCaller(r.run.runId, owner);
|
|
751
|
+
const binding = {
|
|
752
|
+
endpoint: "manager",
|
|
753
|
+
runId: r.run.runId,
|
|
754
|
+
takeoverId: r.run.takeoverId,
|
|
755
|
+
instanceId: r.instanceId,
|
|
756
|
+
epoch: r.run.epoch,
|
|
757
|
+
owner,
|
|
758
|
+
};
|
|
759
|
+
const auth = { space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } };
|
|
760
|
+
credentials.runDriver = await mintPublicUserJwt(auth, r.run.driverId, "run-driver", {
|
|
761
|
+
principal: { owner, actor: caller.actor },
|
|
762
|
+
lifecycleUid: r.managerLifecycleUid,
|
|
763
|
+
runDriver: binding,
|
|
764
|
+
expiresInSeconds: standingTtl,
|
|
765
|
+
});
|
|
766
|
+
credentials.runMediator = await mintPublicUserJwt(auth, r.run.mediatorId, "run-mediator", {
|
|
767
|
+
principal: { owner, actor: caller.actor },
|
|
768
|
+
lifecycleUid: r.managerLifecycleUid,
|
|
769
|
+
runMediator: binding,
|
|
770
|
+
expiresInSeconds: standingTtl,
|
|
771
|
+
});
|
|
772
|
+
return { credentials };
|
|
773
|
+
}
|
|
567
774
|
if (r.operation === "activate") {
|
|
568
775
|
const expectedProof = remoteManagerRegistrationProof(owner, r);
|
|
569
776
|
if (r.registrationProof !== expectedProof)
|
|
@@ -580,30 +787,13 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
580
787
|
credentials.serve = await mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.identities.serve.id, "endpoint-serve", {
|
|
581
788
|
principal: { owner, actor: actors.serve },
|
|
582
789
|
lifecycleUid: r.managerLifecycleUid,
|
|
583
|
-
expiresInSeconds:
|
|
790
|
+
expiresInSeconds: standingTtl,
|
|
584
791
|
endpointServe: reconstructRemoteManagerServeGrant(r, owner, actors.serve, observed),
|
|
585
792
|
serveIssuance: gate,
|
|
586
793
|
});
|
|
587
794
|
if (!observed)
|
|
588
795
|
throw new EpEnvelopeError("failed-precondition", "manager-service activation found no issuance gate");
|
|
589
|
-
const sibling =
|
|
590
|
-
const issued = await credential(key, profile, actor, profile === "goal-writer" ? { goalWriter: { endpoint: "manager" }, expiresInSeconds: STANDING_RENEWABLE_TTL_SEC } : { expiresInSeconds: STANDING_RENEWABLE_TTL_SEC });
|
|
591
|
-
await commitSiblingIssuance(gate, observed, {
|
|
592
|
-
credentialId: rawDigest(issued.jwt).replace("sha256:", "sha256-"),
|
|
593
|
-
credentialKey: r.identities[key].id,
|
|
594
|
-
holderPrincipal: `${owner}.${actor}`,
|
|
595
|
-
endpoint: "manager",
|
|
596
|
-
lifecycleUid: r.instanceId,
|
|
597
|
-
sourceChain: ["root"],
|
|
598
|
-
state: "active",
|
|
599
|
-
exp: issued.exp,
|
|
600
|
-
generation: observed.generation,
|
|
601
|
-
processEpoch: observed.processEpoch,
|
|
602
|
-
registrationRevision: observed.registrationRevision,
|
|
603
|
-
nameAuthorityRevision: observed.nameAuthorityRevision,
|
|
604
|
-
});
|
|
605
|
-
return issued;
|
|
606
|
-
};
|
|
796
|
+
const sibling = siblingOn(gate, observed);
|
|
607
797
|
credentials.goalWriter = await sibling("goalWriter", "goal-writer", actors.goalWriter);
|
|
608
798
|
credentials.sessionLedger = await sibling("sessionLedger", "session-ledger", actors.sessionLedger);
|
|
609
799
|
return {
|
|
@@ -741,6 +931,124 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
741
931
|
},
|
|
742
932
|
});
|
|
743
933
|
},
|
|
934
|
+
admitManagerRun: async ({ owner, scope, request }) => {
|
|
935
|
+
refuseIfFenced();
|
|
936
|
+
if (!scope.includes("supervise"))
|
|
937
|
+
throw new EpEnvelopeError("permission-denied", 'manager run admission needs scope "supervise"; spawn/admin do not imply it');
|
|
938
|
+
const runId = parseRemoteRunAdmissionRequest(request).run.runId;
|
|
939
|
+
const hostAuth = {
|
|
940
|
+
space,
|
|
941
|
+
operator: { seed: "", jwt: "" },
|
|
942
|
+
account: { pub: dataAccount.pub, seed: "", jwt: "", signingSeed: dataAccount.signingSeed, signingPub: "" },
|
|
943
|
+
sys: { pub: "", jwt: "" },
|
|
944
|
+
};
|
|
945
|
+
return withIssuerSession({ servers: server, space, auth: hostAuth, tls: false }, async (session) => {
|
|
946
|
+
const admitterNc = await connect({
|
|
947
|
+
servers: server,
|
|
948
|
+
...standaloneConnectOpts({
|
|
949
|
+
creds: await mintCreds(hostAuth, newIdentity(), "run-admitter", { runAdmitter: { endpoint: "manager", runId }, expiresInSeconds: 60 }),
|
|
950
|
+
tls: false,
|
|
951
|
+
}),
|
|
952
|
+
maxReconnectAttempts: 0,
|
|
953
|
+
});
|
|
954
|
+
try {
|
|
955
|
+
return await admitRemoteRun({
|
|
956
|
+
request, owner, space, accountPublicKey: dataAccount.pub, proofSecret: dataAccount.signingSeed, endpoint: "manager",
|
|
957
|
+
observeManagerGate: async (instanceId) => {
|
|
958
|
+
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
|
|
959
|
+
return gate.observe();
|
|
960
|
+
},
|
|
961
|
+
issued: session.store,
|
|
962
|
+
sourceIsLive: (source) => session.sourceIsLive(source),
|
|
963
|
+
admissions: await new Kvm(admitterNc).open(admissionBucket(space)),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
await admitterNc.drain().catch(() => admitterNc.close());
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
},
|
|
971
|
+
issueManagerRunAttempt: async ({ owner, scope, request }) => {
|
|
972
|
+
refuseIfFenced();
|
|
973
|
+
if (!scope.includes("supervise"))
|
|
974
|
+
throw new EpEnvelopeError("permission-denied", 'manager run attempt needs scope "supervise"; spawn/admin do not imply it');
|
|
975
|
+
const req = parseRemoteRunAttemptRequest(request);
|
|
976
|
+
const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
|
|
977
|
+
const grant = await authorizeRemoteRunAttempt({
|
|
978
|
+
request,
|
|
979
|
+
owner,
|
|
980
|
+
space,
|
|
981
|
+
accountPublicKey: dataAccount.pub,
|
|
982
|
+
proofSecret: dataAccount.signingSeed,
|
|
983
|
+
endpoint: "manager",
|
|
984
|
+
observeManagerGate: async (instanceId) => {
|
|
985
|
+
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
|
|
986
|
+
return gate.observe();
|
|
987
|
+
},
|
|
988
|
+
readAdmission: (runId) => readRunAdmission(recordsJsm, space, "manager", runId),
|
|
989
|
+
readRunStatus: async (runId) => (await readRunRecord(recordsKv, "manager", runId))?.status?.value,
|
|
990
|
+
checkpointWaiting: async (token) => (await readCheckpointStatus(recordsKv, { endpoint: "manager", token }))?.value.state === "waiting",
|
|
991
|
+
});
|
|
992
|
+
const hostAuth = {
|
|
993
|
+
space,
|
|
994
|
+
operator: { seed: "", jwt: "" },
|
|
995
|
+
account: { pub: dataAccount.pub, seed: "", jwt: "", signingSeed: dataAccount.signingSeed, signingPub: "" },
|
|
996
|
+
sys: { pub: "", jwt: "" },
|
|
997
|
+
};
|
|
998
|
+
if (grant.kind === "attempt") {
|
|
999
|
+
const caller = runDriverCaller(grant.driver.runDriver.runId, owner);
|
|
1000
|
+
const driver = await mintPublicUserJwt(hostAuth, grant.driver.id, "run-driver", {
|
|
1001
|
+
principal: { owner, actor: caller.actor },
|
|
1002
|
+
lifecycleUid: req.managerLifecycleUid,
|
|
1003
|
+
runDriver: grant.driver.runDriver,
|
|
1004
|
+
expiresInSeconds: standingTtl,
|
|
1005
|
+
});
|
|
1006
|
+
const mediator = await mintPublicUserJwt(hostAuth, grant.mediator.id, "run-mediator", {
|
|
1007
|
+
principal: { owner, actor: caller.actor },
|
|
1008
|
+
lifecycleUid: req.managerLifecycleUid,
|
|
1009
|
+
runMediator: grant.mediator.runMediator,
|
|
1010
|
+
expiresInSeconds: standingTtl,
|
|
1011
|
+
});
|
|
1012
|
+
return {
|
|
1013
|
+
v: 1,
|
|
1014
|
+
kind: "manager-run-attempt",
|
|
1015
|
+
space,
|
|
1016
|
+
owner,
|
|
1017
|
+
actor: req.actor,
|
|
1018
|
+
instanceId: req.instanceId,
|
|
1019
|
+
managerLifecycleUid: req.managerLifecycleUid,
|
|
1020
|
+
requestId: req.requestId,
|
|
1021
|
+
registrationProof: req.registrationProof,
|
|
1022
|
+
accountPublicKey: req.accountPublicKey,
|
|
1023
|
+
processEpoch: req.processEpoch,
|
|
1024
|
+
identities: req.identities,
|
|
1025
|
+
attempt: req.attempt,
|
|
1026
|
+
credentials: { driver, mediator },
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
const operator = await mintPublicUserJwt(hostAuth, grant.operator.id, "run-operator", {
|
|
1030
|
+
principal: { owner, actor: req.actor },
|
|
1031
|
+
lifecycleUid: req.managerLifecycleUid,
|
|
1032
|
+
runOperator: grant.operator.runOperator,
|
|
1033
|
+
expiresInSeconds: 60,
|
|
1034
|
+
});
|
|
1035
|
+
return {
|
|
1036
|
+
v: 1,
|
|
1037
|
+
kind: "manager-run-attempt",
|
|
1038
|
+
space,
|
|
1039
|
+
owner,
|
|
1040
|
+
actor: req.actor,
|
|
1041
|
+
instanceId: req.instanceId,
|
|
1042
|
+
managerLifecycleUid: req.managerLifecycleUid,
|
|
1043
|
+
requestId: req.requestId,
|
|
1044
|
+
registrationProof: req.registrationProof,
|
|
1045
|
+
accountPublicKey: req.accountPublicKey,
|
|
1046
|
+
processEpoch: req.processEpoch,
|
|
1047
|
+
identities: req.identities,
|
|
1048
|
+
operator: req.operator,
|
|
1049
|
+
credentials: { operator },
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
744
1052
|
fenced,
|
|
745
1053
|
close: async () => {
|
|
746
1054
|
// Clean-close order (SPEC 13.13): the rail stops answering first, then scan-capable
|
|
@@ -894,6 +1202,77 @@ export async function runAuthService(args, store) {
|
|
|
894
1202
|
await new Promise((r) => setTimeout(r, hold));
|
|
895
1203
|
}
|
|
896
1204
|
}
|
|
1205
|
+
const started = await startAuthContext({
|
|
1206
|
+
space,
|
|
1207
|
+
server,
|
|
1208
|
+
port,
|
|
1209
|
+
dir,
|
|
1210
|
+
secrets,
|
|
1211
|
+
hostedStore: store !== undefined,
|
|
1212
|
+
...(publicPort !== undefined ? { publicFace: { port: publicPort, urlFlag: publicUrlFlag, trustedProxy, advertisedServer, agentProvisioningUrl } } : {}),
|
|
1213
|
+
// The CLI composition's local manager is this workspace root's persisted instance, re-read per
|
|
1214
|
+
// selection exactly as before. A hosted context passes no local manager at all.
|
|
1215
|
+
localManager: () => loadManagerInstanceIdentity(root, space),
|
|
1216
|
+
});
|
|
1217
|
+
// All planes bound — NOW write the discovery file (its existence is the readiness signal).
|
|
1218
|
+
saveAuthServiceInfo(dir, { url: started.url, pid: process.pid, cap: started.cap, ...(started.publicUrl !== undefined ? { publicUrl: started.publicUrl } : {}) });
|
|
1219
|
+
console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${started.url}${started.publicUrl !== undefined ? `, public exchange at ${started.publicUrl}` : ""}`);
|
|
1220
|
+
// The CLI wrapper, not the context, owns process signals and exit codes.
|
|
1221
|
+
const stop = async () => {
|
|
1222
|
+
clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
|
|
1223
|
+
await started.handle.close();
|
|
1224
|
+
process.exit(0);
|
|
1225
|
+
};
|
|
1226
|
+
process.on("SIGINT", () => void stop());
|
|
1227
|
+
process.on("SIGTERM", () => void stop());
|
|
1228
|
+
// A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
|
|
1229
|
+
// owns restarts, and a callout that silently stopped answering would hang every user connect.
|
|
1230
|
+
// A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
|
|
1231
|
+
// operation already refuses, and a daemon that stayed up would look healthy while a successor
|
|
1232
|
+
// reclaims — down the whole service instead.
|
|
1233
|
+
const ended = await started.ended;
|
|
1234
|
+
clearAuthServiceInfo(dir);
|
|
1235
|
+
if (ended.kind === "fenced") {
|
|
1236
|
+
console.error(`✗ auth-service: ${ended.cause} - exiting`);
|
|
1237
|
+
process.exit(1);
|
|
1238
|
+
}
|
|
1239
|
+
if (ended.cause !== undefined) {
|
|
1240
|
+
console.error(`✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
|
|
1241
|
+
process.exit(1);
|
|
1242
|
+
}
|
|
1243
|
+
process.exit(0);
|
|
1244
|
+
}
|
|
1245
|
+
/** Start one account-scoped auth-service context. Every input is explicit: the state dir, the
|
|
1246
|
+
* injected store with its proved identity, the assigned data account and the broker. The context
|
|
1247
|
+
* installs no process signal handler, never exits the process, never selects a root from cwd,
|
|
1248
|
+
* and has no local manager. It returns only after the authority plane, the callout subscription
|
|
1249
|
+
* and the loopback listener are bound. A failed start releases what it acquired and throws. A
|
|
1250
|
+
* mid-life fence or broker loss makes only this context `unavailable` and closes its resources. */
|
|
1251
|
+
export async function startAuthService(inputs) {
|
|
1252
|
+
if (!inputs.context?.accountPublicKey || !inputs.context.lifecycleUid || !inputs.space || !inputs.servers || !inputs.stateDir)
|
|
1253
|
+
throw new Error("auth-service: hosted context needs an account, lifecycle, space, server and stateDir");
|
|
1254
|
+
if (inputs.store.identity === undefined)
|
|
1255
|
+
throw new Error("auth-service: hosted SecretStore must declare a stable identity");
|
|
1256
|
+
const actual = parseSecretStoreIdentity(inputs.store.identity);
|
|
1257
|
+
if (actual.kind !== "injected" || !sameSecretStoreIdentity(actual, inputs.storeIdentity))
|
|
1258
|
+
throw new Error("auth-service: hosted SecretStore identity does not match the assigned store identity");
|
|
1259
|
+
const port = inputs.port ?? 0;
|
|
1260
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
1261
|
+
throw new Error(`auth-service: port must be a port number, got ${port}`);
|
|
1262
|
+
const started = await startAuthContext({
|
|
1263
|
+
space: inputs.space,
|
|
1264
|
+
server: inputs.servers,
|
|
1265
|
+
port,
|
|
1266
|
+
dir: resolve(inputs.stateDir),
|
|
1267
|
+
secrets: inputs.store,
|
|
1268
|
+
hostedStore: true,
|
|
1269
|
+
localManager: () => undefined,
|
|
1270
|
+
context: inputs.context,
|
|
1271
|
+
});
|
|
1272
|
+
return started.handle;
|
|
1273
|
+
}
|
|
1274
|
+
async function startAuthContext(o) {
|
|
1275
|
+
const { space, server, dir, secrets, port } = o;
|
|
897
1276
|
const keys = await loadServiceKeys(secrets, space);
|
|
898
1277
|
const callout = await loadCalloutAuth(secrets, space);
|
|
899
1278
|
const issuer = await loadIssuer(secrets, space);
|
|
@@ -907,10 +1286,12 @@ export async function runAuthService(args, store) {
|
|
|
907
1286
|
...(ownerSecret ? [] : ["owner secret"]),
|
|
908
1287
|
...(idp ? [] : [`IdP pin under ${dir}`]),
|
|
909
1288
|
];
|
|
910
|
-
throw new Error(`auth-service: user-auth material is missing (${missing.join(", ")}) - ${
|
|
1289
|
+
throw new Error(`auth-service: user-auth material is missing (${missing.join(", ")}) - ${o.hostedStore ? "the hosted composition must provision the secret store before starting this daemon" : "enable it with `cotal up --user-auth --idp <url>`"}`);
|
|
911
1290
|
}
|
|
912
1291
|
if (issuer.issuer !== spaceIssuer(space))
|
|
913
1292
|
throw new Error(`auth-service: issuer pin ${issuer.issuer} does not match space "${space}"`);
|
|
1293
|
+
if (o.context !== undefined && keys.dataAccount.pub !== o.context.accountPublicKey)
|
|
1294
|
+
throw new Error("auth-service: the store's data account does not match the assigned hosted context");
|
|
914
1295
|
if (!(await isReachable(server, { creds: callout.calloutCreds })))
|
|
915
1296
|
throw new Error(`auth-service: can't reach the broker at ${server} with the callout creds - is the mesh up (with the callout account preloaded)?`);
|
|
916
1297
|
// ---- The authority plane (R1): stores ensured + registry + supervised reader, BEFORE the
|
|
@@ -921,132 +1302,170 @@ export async function runAuthService(args, store) {
|
|
|
921
1302
|
dir,
|
|
922
1303
|
dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
|
|
923
1304
|
log: (l) => console.error(l),
|
|
1305
|
+
localManager: o.localManager,
|
|
924
1306
|
});
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
servers: server,
|
|
928
|
-
authenticator: credsAuthenticator(new TextEncoder().encode(callout.calloutCreds)),
|
|
929
|
-
name: `cotal:auth-service:${space}`,
|
|
930
|
-
});
|
|
931
|
-
startAuthCallout(nc, {
|
|
932
|
-
xkeySeed: callout.xkey.seed,
|
|
933
|
-
authAccount: { pub: callout.account.pub, signingSeed: callout.account.signingSeed },
|
|
934
|
-
dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
|
|
935
|
-
space,
|
|
936
|
-
token: { key: issuer.localKeySet(), issuer: issuer.issuer },
|
|
937
|
-
authorizeActor: plane.authorizeConnect,
|
|
938
|
-
permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller),
|
|
939
|
-
log: (l) => console.error(l),
|
|
940
|
-
});
|
|
941
|
-
// The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
|
|
942
|
-
// usable user mesh while the SUB was still in flight would intermittently deny first connects.
|
|
943
|
-
await nc.flush();
|
|
944
|
-
// ---- Plane 1: the exchange + JWKS, loopback HTTP ----
|
|
945
|
-
const bridgeIdp = { issuer: idp.issuer, audience: idp.audience, key: pinnedJwksResolver(idp.jwksUri) };
|
|
946
|
-
const bridge = createIdpBridge({
|
|
947
|
-
idp: bridgeIdp,
|
|
948
|
-
space,
|
|
949
|
-
spaceSecret: ownerSecret,
|
|
950
|
-
issuer,
|
|
951
|
-
authorizeActor: ledgerAuthorizeGrant(dir),
|
|
952
|
-
mintConnectCredential: plane.mintConnectCredential,
|
|
953
|
-
});
|
|
954
|
-
const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
|
|
955
|
-
const failures = []; // rolling-window timestamps of REFUSED exchanges
|
|
956
|
-
const badCaps = []; // rolling-window timestamps of invalid-capability attempts
|
|
957
|
-
const ctx = {
|
|
958
|
-
issuer,
|
|
959
|
-
bridge,
|
|
960
|
-
bridgeIdp,
|
|
961
|
-
ownerSecret,
|
|
962
|
-
managerServiceAuthority: plane.issueManagerServiceAuthority,
|
|
963
|
-
maintainRemoteManager: plane.maintainRemoteManager,
|
|
964
|
-
validateRetainedAgent: plane.validateRetainedAgent,
|
|
965
|
-
verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
|
|
966
|
-
verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
|
|
967
|
-
scanManagerGoalIndex: plane.scanManagerGoalIndex,
|
|
968
|
-
authorizeManagerAdmin: plane.authorizeManagerAdmin,
|
|
969
|
-
secrets,
|
|
970
|
-
retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
|
|
971
|
-
retireManagedLifecycle: plane.retireManagedLifecycle,
|
|
972
|
-
cap,
|
|
973
|
-
failures,
|
|
974
|
-
badCaps,
|
|
975
|
-
space,
|
|
976
|
-
dir,
|
|
977
|
-
mintConnectCredential: plane.mintConnectCredential,
|
|
978
|
-
selectManagerInstance: plane.selectManagerInstance,
|
|
979
|
-
};
|
|
980
|
-
const http = createServer((req, res) => void handle(req, res, ctx));
|
|
981
|
-
await new Promise((resolvePort, reject) => {
|
|
982
|
-
http.once("error", reject);
|
|
983
|
-
http.listen(port, "127.0.0.1", () => resolvePort());
|
|
984
|
-
});
|
|
985
|
-
const addr = http.address();
|
|
986
|
-
const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
|
987
|
-
const url = `http://127.0.0.1:${boundPort}`;
|
|
988
|
-
// The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
|
|
989
|
-
// loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
|
|
1307
|
+
let nc;
|
|
1308
|
+
let http;
|
|
990
1309
|
let publicHttp;
|
|
991
|
-
let
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1310
|
+
// Stop admission, let accepted requests settle within the public request deadline, then cut
|
|
1311
|
+
// whatever is still open so a stuck client cannot hold this context's close forever.
|
|
1312
|
+
const closeServer = async (srv) => {
|
|
1313
|
+
if (srv === undefined || !srv.listening)
|
|
1314
|
+
return;
|
|
1315
|
+
const closed = new Promise((r) => srv.close(() => r()));
|
|
1316
|
+
let timer;
|
|
1317
|
+
await Promise.race([closed, new Promise((r) => { timer = setTimeout(r, PUBLIC_DEADLINE_MS); })]);
|
|
1318
|
+
clearTimeout(timer);
|
|
1319
|
+
srv.closeAllConnections();
|
|
1320
|
+
await closed;
|
|
1321
|
+
};
|
|
1322
|
+
try {
|
|
1323
|
+
// ---- Plane 2: the callout, on its own callout-account connection ----
|
|
1324
|
+
nc = await connect({
|
|
1325
|
+
servers: server,
|
|
1326
|
+
authenticator: credsAuthenticator(new TextEncoder().encode(callout.calloutCreds)),
|
|
1327
|
+
name: `cotal:auth-service:${space}`,
|
|
1328
|
+
});
|
|
1329
|
+
startAuthCallout(nc, {
|
|
1330
|
+
xkeySeed: callout.xkey.seed,
|
|
1331
|
+
authAccount: { pub: callout.account.pub, signingSeed: callout.account.signingSeed },
|
|
1332
|
+
dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
|
|
998
1333
|
space,
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
idp: { url: idp.url, issuer: idp.issuer, audience: idp.audience },
|
|
1004
|
-
sentinelCreds: callout.sentinelCreds,
|
|
1005
|
-
...(agentProvisioningUrl ? { agentProvisioningUrl } : {}),
|
|
1334
|
+
token: { key: issuer.localKeySet(), issuer: issuer.issuer },
|
|
1335
|
+
authorizeActor: plane.authorizeConnect,
|
|
1336
|
+
permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller),
|
|
1337
|
+
log: (l) => console.error(l),
|
|
1006
1338
|
});
|
|
1007
|
-
|
|
1339
|
+
// The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
|
|
1340
|
+
// usable user mesh while the SUB was still in flight would intermittently deny first connects.
|
|
1341
|
+
await nc.flush();
|
|
1342
|
+
// ---- Plane 1: the exchange + JWKS, loopback HTTP ----
|
|
1343
|
+
const bridgeIdp = { issuer: idp.issuer, audience: idp.audience, key: pinnedJwksResolver(idp.jwksUri) };
|
|
1344
|
+
const bridge = createIdpBridge({
|
|
1345
|
+
idp: bridgeIdp,
|
|
1346
|
+
space,
|
|
1347
|
+
spaceSecret: ownerSecret,
|
|
1348
|
+
issuer,
|
|
1349
|
+
authorizeActor: ledgerAuthorizeGrant(dir),
|
|
1350
|
+
mintConnectCredential: plane.mintConnectCredential,
|
|
1351
|
+
});
|
|
1352
|
+
const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
|
|
1353
|
+
const failures = []; // rolling-window timestamps of REFUSED exchanges
|
|
1354
|
+
const badCaps = []; // rolling-window timestamps of invalid-capability attempts
|
|
1355
|
+
const ctx = {
|
|
1356
|
+
issuer,
|
|
1357
|
+
bridge,
|
|
1358
|
+
bridgeIdp,
|
|
1359
|
+
ownerSecret,
|
|
1360
|
+
managerServiceAuthority: plane.issueManagerServiceAuthority,
|
|
1361
|
+
maintainRemoteManager: plane.maintainRemoteManager,
|
|
1362
|
+
validateRetainedAgent: plane.validateRetainedAgent,
|
|
1363
|
+
verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
|
|
1364
|
+
verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
|
|
1365
|
+
scanManagerGoalIndex: plane.scanManagerGoalIndex,
|
|
1366
|
+
authorizeManagerAdmin: plane.authorizeManagerAdmin,
|
|
1367
|
+
admitManagerRun: plane.admitManagerRun,
|
|
1368
|
+
issueManagerRunAttempt: plane.issueManagerRunAttempt,
|
|
1369
|
+
secrets,
|
|
1370
|
+
retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
|
|
1371
|
+
retireManagedLifecycle: plane.retireManagedLifecycle,
|
|
1372
|
+
cap,
|
|
1373
|
+
failures,
|
|
1374
|
+
badCaps,
|
|
1375
|
+
space,
|
|
1376
|
+
dir,
|
|
1377
|
+
mintConnectCredential: plane.mintConnectCredential,
|
|
1378
|
+
selectManagerInstance: plane.selectManagerInstance,
|
|
1379
|
+
};
|
|
1380
|
+
const loopback = createServer((req, res) => void handle(req, res, ctx));
|
|
1381
|
+
http = loopback;
|
|
1008
1382
|
await new Promise((resolvePort, reject) => {
|
|
1009
|
-
|
|
1010
|
-
|
|
1383
|
+
loopback.once("error", reject);
|
|
1384
|
+
loopback.listen(port, "127.0.0.1", () => resolvePort());
|
|
1385
|
+
});
|
|
1386
|
+
const addr = loopback.address();
|
|
1387
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1388
|
+
const url = `http://127.0.0.1:${boundPort}`;
|
|
1389
|
+
// The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
|
|
1390
|
+
// loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
|
|
1391
|
+
let publicUrl;
|
|
1392
|
+
if (o.publicFace !== undefined) {
|
|
1393
|
+
const face = o.publicFace;
|
|
1394
|
+
// The discovery bundle is GENERATED from the daemon's own recorded config — the pinned IdP,
|
|
1395
|
+
// the flags, the callout material — so it cannot drift from what this process enforces.
|
|
1396
|
+
// `endpoints.url` is finalized AFTER bind (the closure sees the mutation): with `--port 0`
|
|
1397
|
+
// the pre-bind port would advertise an address nothing listens on.
|
|
1398
|
+
const bundle = composeUserBundle({
|
|
1399
|
+
space,
|
|
1400
|
+
// What participants DIAL, not what the callout dials: the daemon reaches the broker on its
|
|
1401
|
+
// loopback/LAN address (--server), which is meaningless off this machine. --advertised-server
|
|
1402
|
+
// is the publicly dialable address (e.g. wss://… through the reverse proxy).
|
|
1403
|
+
server: face.advertisedServer ?? server,
|
|
1404
|
+
idp: { url: idp.url, issuer: idp.issuer, audience: idp.audience },
|
|
1405
|
+
sentinelCreds: callout.sentinelCreds,
|
|
1406
|
+
...(face.agentProvisioningUrl ? { agentProvisioningUrl: face.agentProvisioningUrl } : {}),
|
|
1407
|
+
});
|
|
1408
|
+
const pub = createServer(makePublicHandler(ctx, makePublicPolicy(face.trustedProxy), bundle));
|
|
1409
|
+
publicHttp = pub;
|
|
1410
|
+
await new Promise((resolvePort, reject) => {
|
|
1411
|
+
pub.once("error", reject);
|
|
1412
|
+
pub.listen(face.port, "127.0.0.1", () => resolvePort());
|
|
1413
|
+
});
|
|
1414
|
+
const paddr = pub.address();
|
|
1415
|
+
const boundPublic = typeof paddr === "object" && paddr ? paddr.port : face.port;
|
|
1416
|
+
publicUrl = face.urlFlag ?? `http://127.0.0.1:${boundPublic}`;
|
|
1417
|
+
finalizeUserBundleEndpoint(bundle, publicUrl);
|
|
1418
|
+
}
|
|
1419
|
+
const callNc = nc;
|
|
1420
|
+
let state = "ready";
|
|
1421
|
+
let cause;
|
|
1422
|
+
let closePromise;
|
|
1423
|
+
const close = () => {
|
|
1424
|
+
if (closePromise !== undefined)
|
|
1425
|
+
return closePromise;
|
|
1426
|
+
if (state === "ready")
|
|
1427
|
+
state = "draining";
|
|
1428
|
+
closePromise = (async () => {
|
|
1429
|
+
await Promise.all([closeServer(http), closeServer(publicHttp)]);
|
|
1430
|
+
await plane.close().catch(() => { });
|
|
1431
|
+
await callNc.close().catch(() => { });
|
|
1432
|
+
})();
|
|
1433
|
+
return closePromise;
|
|
1434
|
+
};
|
|
1435
|
+
const ended = Promise.race([
|
|
1436
|
+
callNc.closed().then((err) => ({ kind: "broker", ...(err ? { cause: err.message } : {}) })),
|
|
1437
|
+
plane.fenced.then((reason) => ({ kind: "fenced", cause: reason })),
|
|
1438
|
+
]).then((end) => {
|
|
1439
|
+
// A clean broker close after this context's own close() is the orderly end, not a failure.
|
|
1440
|
+
if (end.kind === "fenced" || end.cause !== undefined || closePromise === undefined) {
|
|
1441
|
+
state = "unavailable";
|
|
1442
|
+
cause = end.kind === "fenced" ? end.cause : `broker connection closed${end.cause !== undefined ? ` (${end.cause})` : ""}`;
|
|
1443
|
+
void close();
|
|
1444
|
+
}
|
|
1445
|
+
return end;
|
|
1011
1446
|
});
|
|
1012
|
-
const
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1447
|
+
const context = o.context ?? { accountPublicKey: keys.dataAccount.pub, lifecycleUid: "" };
|
|
1448
|
+
const service = {
|
|
1449
|
+
url,
|
|
1450
|
+
...(publicUrl !== undefined ? { publicUrl } : {}),
|
|
1451
|
+
readiness() {
|
|
1452
|
+
if (state === "unavailable")
|
|
1453
|
+
return { state, context, cause: cause ?? "auth-service context is unavailable" };
|
|
1454
|
+
return { state: closePromise === undefined ? "ready" : "draining", context };
|
|
1455
|
+
},
|
|
1456
|
+
drain: close,
|
|
1457
|
+
close,
|
|
1458
|
+
};
|
|
1459
|
+
return { url, ...(publicUrl !== undefined ? { publicUrl } : {}), cap, handle: service, ended };
|
|
1016
1460
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
http.close();
|
|
1023
|
-
publicHttp?.close();
|
|
1461
|
+
catch (e) {
|
|
1462
|
+
// A failed start releases only what THIS context acquired, in reverse order.
|
|
1463
|
+
await closeServer(publicHttp).catch(() => { });
|
|
1464
|
+
await closeServer(http).catch(() => { });
|
|
1465
|
+
await nc?.close().catch(() => { });
|
|
1024
1466
|
await plane.close().catch(() => { });
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
};
|
|
1028
|
-
process.on("SIGINT", () => void stop());
|
|
1029
|
-
process.on("SIGTERM", () => void stop());
|
|
1030
|
-
// A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
|
|
1031
|
-
// owns restarts, and a callout that silently stopped answering would hang every user connect.
|
|
1032
|
-
// A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
|
|
1033
|
-
// operation already refuses, and a daemon that stayed up would look healthy while a successor
|
|
1034
|
-
// reclaims — down the whole service instead.
|
|
1035
|
-
await Promise.race([
|
|
1036
|
-
nc.closed().then((err) => {
|
|
1037
|
-
clearAuthServiceInfo(dir);
|
|
1038
|
-
if (err) {
|
|
1039
|
-
console.error(`✗ auth-service: broker connection closed (${err.message}) - exiting`);
|
|
1040
|
-
process.exit(1);
|
|
1041
|
-
}
|
|
1042
|
-
process.exit(0);
|
|
1043
|
-
}),
|
|
1044
|
-
plane.fenced.then((reason) => {
|
|
1045
|
-
clearAuthServiceInfo(dir);
|
|
1046
|
-
console.error(`✗ auth-service: ${reason} - exiting`);
|
|
1047
|
-
process.exit(1);
|
|
1048
|
-
}),
|
|
1049
|
-
]);
|
|
1467
|
+
throw e;
|
|
1468
|
+
}
|
|
1050
1469
|
}
|
|
1051
1470
|
/** Dispatch one already-authenticated manager-authority body through the fixed host validator. */
|
|
1052
1471
|
export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
|
|
@@ -1084,6 +1503,10 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
|
|
|
1084
1503
|
return ctx.scanManagerGoalIndex({ owner, scope: row.scope ?? [], request: body.request });
|
|
1085
1504
|
if (request.kind === "manager-admin-authorization")
|
|
1086
1505
|
return ctx.authorizeManagerAdmin({ owner, scope: row.scope ?? [], request: body.request });
|
|
1506
|
+
if (request.kind === "manager-run-admission")
|
|
1507
|
+
return ctx.admitManagerRun({ owner, scope: row.scope ?? [], request: body.request });
|
|
1508
|
+
if (request.kind === "manager-run-attempt")
|
|
1509
|
+
return ctx.issueManagerRunAttempt({ owner, scope: row.scope ?? [], request: body.request });
|
|
1087
1510
|
if (request.kind === "manager-service-maintenance")
|
|
1088
1511
|
return ctx.maintainRemoteManager({ owner, scope: row.scope ?? [], request: body.request });
|
|
1089
1512
|
return ctx.managerServiceAuthority({ owner, scope: row.scope ?? [], request: body.request });
|
|
@@ -1496,7 +1919,7 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
1496
1919
|
}
|
|
1497
1920
|
/** Loopback/operator-only typed manager authority exchange. The public route table never includes
|
|
1498
1921
|
* this path, and the loopback capability is checked here in addition to the route separation. */
|
|
1499
|
-
async function handleManagerServiceAuthority(req, res, ctx, policy) {
|
|
1922
|
+
export async function handleManagerServiceAuthority(req, res, ctx, policy) {
|
|
1500
1923
|
if (req.method !== "POST")
|
|
1501
1924
|
return send(res, 405, { error: "POST only" });
|
|
1502
1925
|
if (req.headers.origin !== undefined)
|