@cotal-ai/auth 0.53.0 → 0.55.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 +12 -1
- package/dist/auth-admin.d.ts.map +1 -1
- package/dist/auth-admin.js +25 -19
- package/dist/auth-admin.js.map +1 -1
- package/dist/callout.d.ts +1 -1
- package/dist/callout.d.ts.map +1 -1
- package/dist/callout.js +1 -1
- package/dist/callout.js.map +1 -1
- package/dist/commands.js +19 -4
- package/dist/commands.js.map +1 -1
- package/dist/idp.d.ts +5 -4
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +3 -2
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +2 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +8 -2
- package/dist/issuer.js.map +1 -1
- package/dist/ledger-scanner.d.ts +2 -0
- package/dist/ledger-scanner.d.ts.map +1 -1
- package/dist/ledger-scanner.js +1 -0
- package/dist/ledger-scanner.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +6 -3
- package/dist/ledger.js.map +1 -1
- package/dist/managed-agent-enrollment.d.ts +43 -0
- package/dist/managed-agent-enrollment.d.ts.map +1 -0
- package/dist/managed-agent-enrollment.js +67 -0
- package/dist/managed-agent-enrollment.js.map +1 -0
- package/dist/permissions.d.ts +1 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +8 -14
- package/dist/permissions.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +61 -7
- package/dist/provider.js.map +1 -1
- package/dist/service.d.ts +28 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +314 -17
- package/dist/service.js.map +1 -1
- package/dist/token.d.ts +11 -8
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +19 -8
- package/dist/token.js.map +1 -1
- package/package.json +3 -3
package/dist/service.js
CHANGED
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
* the public face is the credential itself: the human arm presents an EdDSA IdP JWT verified
|
|
32
32
|
* against the pinned JWKS/issuer/audience; the agent arm presents an actorToken whose sha256 must
|
|
33
33
|
* match a FRESH ledger row. Origin rejection, JSON-only bodies, the 64 KB bound, and no-CORS-ever
|
|
34
|
-
* hold verbatim
|
|
35
|
-
* (
|
|
36
|
-
*
|
|
34
|
+
* hold verbatim. Public views are channel-writer/channel-purger (admin-gated) and manager-caller
|
|
35
|
+
* (instance-bound, with no added command scope). Other views stay loopback-only. The agent-secret
|
|
36
|
+
* arm may request only manager-caller on either face;
|
|
37
37
|
* failures are bucketed per peer (`--exchange-trusted-proxy` opts into the last X-Forwarded-For
|
|
38
38
|
* hop as the peer key; otherwise the socket remote address) in a bounded LRU, under a global
|
|
39
39
|
* concurrent-admission cap and a hard request deadline — all of it pools SEPARATE from the
|
|
@@ -57,8 +57,8 @@ import { createServer } from "node:http";
|
|
|
57
57
|
import { connect, credsAuthenticator } from "@nats-io/transport-node";
|
|
58
58
|
import { jetstreamManager } from "@nats-io/jetstream";
|
|
59
59
|
import { Kvm } from "@nats-io/kv";
|
|
60
|
-
import { admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, mintCreds, mintPublicUserJwt, newIdentity, rawDigest, reconcileEndpointGate, recordsBucket, remoteManagerActors, retirementFrontierStreams, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC } from "@cotal-ai/core";
|
|
61
|
-
import { findCotalRoot, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
|
|
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";
|
|
62
62
|
import { decodeJwt } from "jose";
|
|
63
63
|
import { deriveOwnerForIdpSubject } from "./derive.js";
|
|
64
64
|
import { startAuthCallout } from "./callout.js";
|
|
@@ -68,6 +68,7 @@ import { pinnedJwksResolver } from "./issuer.js";
|
|
|
68
68
|
import { calloutPermissions } from "./permissions.js";
|
|
69
69
|
import { issueRemoteManagerAuthority } from "./manager-authority.js";
|
|
70
70
|
import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
|
|
71
|
+
import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement } from "./managed-agent-enrollment.js";
|
|
71
72
|
import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
|
|
72
73
|
import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
|
|
73
74
|
import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
|
|
@@ -85,15 +86,22 @@ import { makeDeliveryAdminEvictor } from "./barrier-evict.js";
|
|
|
85
86
|
import { resumeAgentRetirement, runAgentRetirementBarrier } from "./retirement-barrier.js";
|
|
86
87
|
import { makeRetirementCleaners } from "./retirement-cleaner.js";
|
|
87
88
|
import { makeDrainRepairers } from "./drain-repair.js";
|
|
88
|
-
import { openAuthAdminListener } from "./auth-admin.js";
|
|
89
|
+
import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
|
|
89
90
|
import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
|
|
90
|
-
import { AGENT_BEARER_TTL_SEC, findInteractiveActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
|
|
91
|
+
import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
|
|
91
92
|
import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
|
|
92
93
|
/** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
|
|
93
94
|
* retire floor (max-age + max bearer TTL) from it. */
|
|
94
95
|
export const JWKS_MAX_AGE_SEC = 300;
|
|
95
96
|
/** Loopback-only operator route used by `cotal actor grant/revoke` before rotating or deleting an interactive lifecycle. */
|
|
96
97
|
export const INTERACTIVE_RETIRE_PATH = "/interactive-lifecycle/retire";
|
|
98
|
+
/** Loopback-only host route that finishes a managed lifecycle's terminal retirement after the host
|
|
99
|
+
* revoked its grant, when the remote manager that should have requested it is gone (#2070). */
|
|
100
|
+
export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
|
|
101
|
+
/** Loopback-only host route a platform calls BEFORE it enrolls or releases a managed agent for a
|
|
102
|
+
* remote manager (#1972 §2.2). The platform owns the writers; this door owns the decision. It
|
|
103
|
+
* derives the caller's scope from the local ledger and never accepts one from the caller. */
|
|
104
|
+
export const VERIFY_ENROLLMENT_PATH = "/manager-service-authority/verify-enrollment";
|
|
97
105
|
/** Failed-exchange rate limit: at most this many REFUSED exchanges per rolling minute; further
|
|
98
106
|
* attempts get 429 until the window drains. Successes are unthrottled (the CLI's normal path). */
|
|
99
107
|
const FAILED_EXCHANGE_PER_MIN = 30;
|
|
@@ -307,6 +315,9 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
307
315
|
await writer.close();
|
|
308
316
|
throw e;
|
|
309
317
|
}
|
|
318
|
+
// ONE in-flight map for the rail and the loopback managed-retire door: both run the same
|
|
319
|
+
// `managedRetirementOpId(uid)` operation, and a process must never execute it twice at once.
|
|
320
|
+
const retirementFlights = new Map();
|
|
310
321
|
// The AUTH CONTROL RAIL (#29 piece 3, SPEC 13.2 CONTROL_AUTH_ADMIN): serve the generic
|
|
311
322
|
// "retire a lifecycle" op over a dedicated minimal listener credential. Every executing right
|
|
312
323
|
// stays with the plane's own registry + retirement deps (the drain rides the ONE sealed records
|
|
@@ -314,7 +325,7 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
314
325
|
// the FRESH space-manager-lease holder check) and dispatches.
|
|
315
326
|
let authAdmin;
|
|
316
327
|
try {
|
|
317
|
-
authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, log });
|
|
328
|
+
authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, log });
|
|
318
329
|
}
|
|
319
330
|
catch (e) {
|
|
320
331
|
closing = true;
|
|
@@ -328,6 +339,76 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
328
339
|
throw e;
|
|
329
340
|
}
|
|
330
341
|
const fileArm = ledgerAuthorizeConnect(opts.dir);
|
|
342
|
+
const recordsJsm = await jetstreamManager(remoteIssuer.nc);
|
|
343
|
+
const root = findCotalRoot();
|
|
344
|
+
const managerGate = async (owner, instanceId) => {
|
|
345
|
+
const localManager = loadManagerInstanceIdentity(root, space);
|
|
346
|
+
const gate = await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe();
|
|
347
|
+
if (!gate)
|
|
348
|
+
return "unknown";
|
|
349
|
+
if (gate.state !== "open")
|
|
350
|
+
return "not open";
|
|
351
|
+
const remotePrincipal = `${owner}.${remoteManagerActors(instanceId).serve}`;
|
|
352
|
+
const local = localManager?.instanceId === instanceId && gate.principal === `local.${localManager.serveIdentity.id}`;
|
|
353
|
+
const remote = gate.principal === remotePrincipal;
|
|
354
|
+
if (!local && !remote)
|
|
355
|
+
return "not this owner's";
|
|
356
|
+
const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
|
|
357
|
+
if (!rec || "deleted" in rec)
|
|
358
|
+
return "not registered";
|
|
359
|
+
const spec = parseServiceSpec(rec.value, { endpoint: "manager" });
|
|
360
|
+
if (spec.owner !== (local ? "local" : owner) || rec.revision !== gate.registrationRevision)
|
|
361
|
+
return "not registered";
|
|
362
|
+
const statusRec = await readSvcRecordLeader(recordsJsm, space, recordStatusKey(RECORD_KINDS.svc, ["manager", instanceId]));
|
|
363
|
+
if (!statusRec || "deleted" in statusRec)
|
|
364
|
+
return "not registered";
|
|
365
|
+
const status = parseServiceStatus(statusRec.value);
|
|
366
|
+
if (status.observedSpecRevision < rec.revision || status.epoch !== gate.processEpoch)
|
|
367
|
+
return "not registered";
|
|
368
|
+
return "candidate";
|
|
369
|
+
};
|
|
370
|
+
const selectManagerInstance = async (owner, requested) => {
|
|
371
|
+
refuseIfFenced();
|
|
372
|
+
if (requested !== undefined) {
|
|
373
|
+
const instanceId = assertLifecycleToken(requested, "managerInstanceId");
|
|
374
|
+
const verdict = await managerGate(owner, instanceId);
|
|
375
|
+
if (verdict !== "candidate")
|
|
376
|
+
throw new Error(`manager instance ${instanceId} is ${verdict}`);
|
|
377
|
+
return instanceId;
|
|
378
|
+
}
|
|
379
|
+
const remoteInstances = [];
|
|
380
|
+
const remoteLive = [];
|
|
381
|
+
let localCandidate;
|
|
382
|
+
const localManager = loadManagerInstanceIdentity(root, space);
|
|
383
|
+
for (const raw of await scanner.scanManagerGates()) {
|
|
384
|
+
if (raw.op !== undefined)
|
|
385
|
+
continue;
|
|
386
|
+
const parts = raw.key.split(".");
|
|
387
|
+
if (parts.length !== 3 || parts[0] !== "epgate" || parts[1] !== "manager")
|
|
388
|
+
throw new EpEnvelopeError("internal", `the manager gate scan returned the malformed key ${raw.key}`);
|
|
389
|
+
const instanceId = assertLifecycleToken(parts[2], "managerInstanceId");
|
|
390
|
+
const row = parseEndpointGate(raw.data, raw.key);
|
|
391
|
+
if (row.principal === `${owner}.${remoteManagerActors(instanceId).serve}`) {
|
|
392
|
+
remoteInstances.push(instanceId);
|
|
393
|
+
if (await managerGate(owner, instanceId) === "candidate")
|
|
394
|
+
remoteLive.push(instanceId);
|
|
395
|
+
}
|
|
396
|
+
else if (localManager?.instanceId === instanceId && row.principal === `local.${localManager.serveIdentity.id}`) {
|
|
397
|
+
if (await managerGate(owner, instanceId) === "candidate")
|
|
398
|
+
localCandidate = instanceId;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
remoteLive.sort();
|
|
402
|
+
if (remoteLive.length === 1)
|
|
403
|
+
return remoteLive[0];
|
|
404
|
+
if (remoteLive.length > 1)
|
|
405
|
+
throw new Error(`manager-caller selection found ${remoteLive.length} live remote manager candidates for owner ${owner}; exactly one is required or name --manager-instance`);
|
|
406
|
+
if (remoteInstances.length > 0)
|
|
407
|
+
throw new Error(`the remote manager for owner ${owner} is unavailable (${remoteInstances.length} gate${remoteInstances.length === 1 ? "" : "s"}, none live); pass managerInstanceId to select an instance explicitly rather than falling through to the local manager`);
|
|
408
|
+
if (localCandidate !== undefined)
|
|
409
|
+
return localCandidate;
|
|
410
|
+
throw new Error(`manager-caller selection found no manager candidate for owner ${owner}`);
|
|
411
|
+
};
|
|
331
412
|
// Every authority operation refuses once the plane is fenced: a half-dead plane keeps NO face up.
|
|
332
413
|
// AUDIENCE SPLIT (ux): these refusals reach a CONNECTING AGENT during the brief fence→exit
|
|
333
414
|
// window, not the operator — the agent cannot "restart the auth service", so it gets a retryable
|
|
@@ -346,6 +427,13 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
346
427
|
refuseIfFenced();
|
|
347
428
|
return ensureRootCredential(registry, { ...args, managerInstance: `auth-service:${space}` });
|
|
348
429
|
},
|
|
430
|
+
selectManagerInstance,
|
|
431
|
+
authorizeManagerCaller: async (owner, instanceId) => {
|
|
432
|
+
refuseIfFenced();
|
|
433
|
+
const verdict = await managerGate(owner, assertLifecycleToken(instanceId, "managerInstanceId"));
|
|
434
|
+
if (verdict !== "candidate")
|
|
435
|
+
throw new Error(`manager instance ${instanceId} is ${verdict} at the permission mint`);
|
|
436
|
+
},
|
|
349
437
|
retireInteractiveLifecycle: async (args) => {
|
|
350
438
|
refuseIfFenced();
|
|
351
439
|
const owner = assertDerivedOwnerToken(args.owner);
|
|
@@ -374,6 +462,36 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
374
462
|
}, retirement);
|
|
375
463
|
return { retired: true, lifecycleUid };
|
|
376
464
|
},
|
|
465
|
+
retireManagedLifecycle: async (args) => {
|
|
466
|
+
refuseIfFenced();
|
|
467
|
+
const owner = assertDerivedOwnerToken(args.owner);
|
|
468
|
+
const actor = assertValidOwnerToken(args.actor);
|
|
469
|
+
const lifecycleUid = assertLifecycleToken(args.lifecycleUid);
|
|
470
|
+
// The `prepareAgentRetirement` contract: the host revokes the managed grant FIRST. Only a
|
|
471
|
+
// grant still live AT THIS uid blocks; a row at another uid is a successor the head decides.
|
|
472
|
+
if (findManagedActor(opts.dir, owner, actor)?.lifecycleUid === lifecycleUid)
|
|
473
|
+
throw new EpEnvelopeError("conflict", `managed actor "${owner}/${actor}" is still granted at lifecycle ${lifecycleUid}; revoke the grant before retiring it`);
|
|
474
|
+
const head = await readLifecycleHeadForOperation(barrierReg, owner, actor);
|
|
475
|
+
if (head === undefined)
|
|
476
|
+
return { retired: false, lifecycleUid, notStarted: true };
|
|
477
|
+
if (head.mapping.lifecycleUid !== lifecycleUid) {
|
|
478
|
+
if (head.mapping.state === "retired")
|
|
479
|
+
return { retired: false, lifecycleUid, notStarted: true };
|
|
480
|
+
throw new EpEnvelopeError("conflict", `managed lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`);
|
|
481
|
+
}
|
|
482
|
+
if (head.mapping.state === "retired")
|
|
483
|
+
return { retired: true, lifecycleUid, alreadyRetired: true };
|
|
484
|
+
// The rail's opId, not the interactive digest: a host door call and a late participant rail call
|
|
485
|
+
// create or resume ONE barrier operation and join ONE in-process flight.
|
|
486
|
+
const opId = managedRetirementOpId(lifecycleUid);
|
|
487
|
+
const flight = joinOrStartRetirement(retirementFlights, barrierReg, {
|
|
488
|
+
owner, actor, lifecycleUid, opId, frontierStreams: retirementFrontierStreams(space),
|
|
489
|
+
}, retirement);
|
|
490
|
+
if (flight === undefined)
|
|
491
|
+
throw new EpEnvelopeError("conflict", `managed retirement operation ${opId} is already in flight for different coordinates`);
|
|
492
|
+
await flight;
|
|
493
|
+
return { retired: true, lifecycleUid };
|
|
494
|
+
},
|
|
377
495
|
issueManagerServiceAuthority: async ({ owner, scope, request }) => {
|
|
378
496
|
refuseIfFenced();
|
|
379
497
|
return issueRemoteManagerAuthority({
|
|
@@ -569,6 +687,34 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
569
687
|
},
|
|
570
688
|
});
|
|
571
689
|
},
|
|
690
|
+
verifyManagedAgentEnrollment: async ({ owner, scope, request }) => {
|
|
691
|
+
refuseIfFenced();
|
|
692
|
+
return authorizeRemoteManagedAgentEnrollment({
|
|
693
|
+
owner,
|
|
694
|
+
scope,
|
|
695
|
+
proofSecret: dataAccount.signingSeed,
|
|
696
|
+
space,
|
|
697
|
+
request,
|
|
698
|
+
observeManagerGate: async (instanceId) => {
|
|
699
|
+
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
|
|
700
|
+
return gate.observe();
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
},
|
|
704
|
+
verifyManagedAgentPrepareRetirement: async ({ owner, scope, request }) => {
|
|
705
|
+
refuseIfFenced();
|
|
706
|
+
return authorizeRemoteManagedAgentPrepareRetirement({
|
|
707
|
+
owner,
|
|
708
|
+
scope,
|
|
709
|
+
proofSecret: dataAccount.signingSeed,
|
|
710
|
+
space,
|
|
711
|
+
request,
|
|
712
|
+
observeManagerGate: async (instanceId) => {
|
|
713
|
+
const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
|
|
714
|
+
return gate.observe();
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
},
|
|
572
718
|
scanManagerGoalIndex: async ({ owner, scope, request }) => {
|
|
573
719
|
refuseIfFenced();
|
|
574
720
|
const authorized = await authorizeRemoteManagerGoalIndexScan({
|
|
@@ -789,7 +935,7 @@ export async function runAuthService(args, store) {
|
|
|
789
935
|
space,
|
|
790
936
|
token: { key: issuer.localKeySet(), issuer: issuer.issuer },
|
|
791
937
|
authorizeActor: plane.authorizeConnect,
|
|
792
|
-
permissionsFor: calloutPermissions(ledgerAclResolver(dir)),
|
|
938
|
+
permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller),
|
|
793
939
|
log: (l) => console.error(l),
|
|
794
940
|
});
|
|
795
941
|
// The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
|
|
@@ -816,16 +962,20 @@ export async function runAuthService(args, store) {
|
|
|
816
962
|
managerServiceAuthority: plane.issueManagerServiceAuthority,
|
|
817
963
|
maintainRemoteManager: plane.maintainRemoteManager,
|
|
818
964
|
validateRetainedAgent: plane.validateRetainedAgent,
|
|
965
|
+
verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
|
|
966
|
+
verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
|
|
819
967
|
scanManagerGoalIndex: plane.scanManagerGoalIndex,
|
|
820
968
|
authorizeManagerAdmin: plane.authorizeManagerAdmin,
|
|
821
969
|
secrets,
|
|
822
970
|
retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
|
|
971
|
+
retireManagedLifecycle: plane.retireManagedLifecycle,
|
|
823
972
|
cap,
|
|
824
973
|
failures,
|
|
825
974
|
badCaps,
|
|
826
975
|
space,
|
|
827
976
|
dir,
|
|
828
977
|
mintConnectCredential: plane.mintConnectCredential,
|
|
978
|
+
selectManagerInstance: plane.selectManagerInstance,
|
|
829
979
|
};
|
|
830
980
|
const http = createServer((req, res) => void handle(req, res, ctx));
|
|
831
981
|
await new Promise((resolvePort, reject) => {
|
|
@@ -906,6 +1056,13 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
|
|
|
906
1056
|
if (typeof request.actor !== "string")
|
|
907
1057
|
throw new Error("manager-service authority request requires an actor");
|
|
908
1058
|
const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
|
|
1059
|
+
// #1972: the managed-agent lifecycle operations mutate host storage (a database row, JetStream
|
|
1060
|
+
// durables, a ledger grant), and stock holds none of that composition. A host platform intercepts
|
|
1061
|
+
// these kinds on its own public route and calls the loopback verify-enrollment door for the
|
|
1062
|
+
// decision. Refusing here keeps them from falling through to `managerServiceAuthority`, which
|
|
1063
|
+
// would answer a manager-lifecycle phase for an agent-lifecycle request.
|
|
1064
|
+
if (request.kind === "manager-managed-agent-enrollment" || request.kind === "manager-managed-agent-prepare-retirement")
|
|
1065
|
+
throw new EpEnvelopeError("unimplemented", "managed agent enrollment and retirement preparation must be handled by host platform interception");
|
|
909
1066
|
if (request.kind === "manager-retained-agent-validation") {
|
|
910
1067
|
const retained = await ctx.validateRetainedAgent({
|
|
911
1068
|
owner,
|
|
@@ -1029,13 +1186,15 @@ const ROUTES = new Map([
|
|
|
1029
1186
|
["/exchange", (req, res, ctx) => handleExchange(req, res, ctx, LOOPBACK_POLICY)],
|
|
1030
1187
|
["/manager-service-authority", (req, res, ctx) => handleManagerServiceAuthority(req, res, ctx, LOOPBACK_POLICY)],
|
|
1031
1188
|
[INTERACTIVE_RETIRE_PATH, handleInteractiveLifecycleRetirement],
|
|
1189
|
+
[MANAGED_RETIRE_PATH, handleManagedLifecycleRetirement],
|
|
1190
|
+
[VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
|
|
1032
1191
|
]);
|
|
1033
1192
|
/** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
|
|
1034
1193
|
async function handle(req, res, ctx) {
|
|
1035
1194
|
try {
|
|
1036
1195
|
const route = ROUTES.get(req.url ?? "");
|
|
1037
1196
|
if (!route)
|
|
1038
|
-
return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /interactive-lifecycle/retire" });
|
|
1197
|
+
return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire" });
|
|
1039
1198
|
await route(req, res, ctx);
|
|
1040
1199
|
}
|
|
1041
1200
|
catch (e) {
|
|
@@ -1072,6 +1231,128 @@ async function handleInteractiveLifecycleRetirement(req, res, ctx) {
|
|
|
1072
1231
|
const result = await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid });
|
|
1073
1232
|
return send(res, 200, result);
|
|
1074
1233
|
}
|
|
1234
|
+
/** The host's terminal step for a managed lifecycle whose remote manager is gone: the same request
|
|
1235
|
+
* guards as the interactive door, but it requires the managed grant to be REVOKED first (the
|
|
1236
|
+
* `prepareAgentRetirement` contract) and runs the rail's `managedRetirementOpId(uid)` operation. */
|
|
1237
|
+
async function handleManagedLifecycleRetirement(req, res, ctx) {
|
|
1238
|
+
if (req.method !== "POST")
|
|
1239
|
+
return send(res, 405, { error: "POST only" });
|
|
1240
|
+
if (req.headers.origin !== undefined)
|
|
1241
|
+
return send(res, 403, { error: "browser-origin requests are not served here" });
|
|
1242
|
+
if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
|
|
1243
|
+
return send(res, 415, { error: "content-type must be application/json" });
|
|
1244
|
+
if (req.headers.authorization !== `Bearer ${ctx.cap}`)
|
|
1245
|
+
return send(res, 401, { error: "missing/invalid exchange capability - managed lifecycle retirement is a loopback host action" });
|
|
1246
|
+
const body = await readJsonBody(req);
|
|
1247
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
1248
|
+
return send(res, 400, { error: "managed lifecycle retirement needs { owner, actor, lifecycleUid }" });
|
|
1249
|
+
for (const key of Object.keys(body))
|
|
1250
|
+
if (!INTERACTIVE_RETIRE_KEYS.has(key))
|
|
1251
|
+
return send(res, 400, { error: `managed lifecycle retirement carries the unknown field "${key}"` });
|
|
1252
|
+
const raw = body;
|
|
1253
|
+
if (typeof raw.owner !== "string" || typeof raw.actor !== "string" || typeof raw.lifecycleUid !== "string")
|
|
1254
|
+
return send(res, 400, { error: "managed lifecycle retirement needs string { owner, actor, lifecycleUid }" });
|
|
1255
|
+
const owner = assertDerivedOwnerToken(raw.owner);
|
|
1256
|
+
const actor = assertValidOwnerToken(raw.actor);
|
|
1257
|
+
const lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
|
|
1258
|
+
try {
|
|
1259
|
+
return send(res, 200, await ctx.retireManagedLifecycle({ owner, actor, lifecycleUid }));
|
|
1260
|
+
}
|
|
1261
|
+
catch (e) {
|
|
1262
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
1263
|
+
return send(res, 409, { error: e.message });
|
|
1264
|
+
throw e;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
/** The envelope-error to HTTP mapping this door answers with. A code the catalog grows but this
|
|
1268
|
+
* table does not name becomes 403 rather than a 500: a refusal is a refusal, and a door that
|
|
1269
|
+
* answered 500 to a new permission code would read as a host fault to the platform calling it. */
|
|
1270
|
+
const VERIFY_ENROLLMENT_STATUS = {
|
|
1271
|
+
"unauthenticated": 401,
|
|
1272
|
+
"permission-denied": 403,
|
|
1273
|
+
"conflict": 409,
|
|
1274
|
+
"failed-precondition": 412,
|
|
1275
|
+
"bad-request": 400,
|
|
1276
|
+
};
|
|
1277
|
+
const VERIFY_ENROLLMENT_KEYS = new Set(["owner", "request"]);
|
|
1278
|
+
/**
|
|
1279
|
+
* #1972 §2.2 item 3: the host platform's decision door for a remote manager's managed-agent
|
|
1280
|
+
* enrollment or terminal-release preparation.
|
|
1281
|
+
*
|
|
1282
|
+
* The platform that owns the database, the durables, and the ledger terminates its own public
|
|
1283
|
+
* route, authenticates the human there, and POSTs the resulting owner token plus the participant's
|
|
1284
|
+
* verbatim request here. This process answers because it is the only one holding the manager gate
|
|
1285
|
+
* connection and the data-account signing seed: the proof check and the gate read happen inside it,
|
|
1286
|
+
* and no secret crosses back.
|
|
1287
|
+
*
|
|
1288
|
+
* The caller supplies an OWNER and a REQUEST, and nothing else. The caller's scope is derived from
|
|
1289
|
+
* this machine's ledger (`ledgerAuthorizeGrant`), never accepted from the body — a platform bug that
|
|
1290
|
+
* forwarded a participant-supplied scope array could otherwise hand `supervise` to any caller.
|
|
1291
|
+
*/
|
|
1292
|
+
async function handleVerifyManagedAgentEnrollment(req, res, ctx) {
|
|
1293
|
+
if (req.method !== "POST")
|
|
1294
|
+
return send(res, 405, { error: "POST only" });
|
|
1295
|
+
if (req.headers.origin !== undefined)
|
|
1296
|
+
return send(res, 403, { error: "browser-origin requests are not served here" });
|
|
1297
|
+
if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
|
|
1298
|
+
return send(res, 415, { error: "content-type must be application/json" });
|
|
1299
|
+
if (req.headers.authorization !== `Bearer ${ctx.cap}`)
|
|
1300
|
+
return send(res, 401, { error: "missing/invalid exchange capability - managed agent enrollment verification is a loopback host action" });
|
|
1301
|
+
const body = await readJsonBody(req);
|
|
1302
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
1303
|
+
return send(res, 400, { error: "managed agent enrollment verification needs { owner, request }" });
|
|
1304
|
+
for (const key of Object.keys(body))
|
|
1305
|
+
if (!VERIFY_ENROLLMENT_KEYS.has(key))
|
|
1306
|
+
return send(res, 400, { error: `managed agent enrollment verification carries the unknown field "${key}"` });
|
|
1307
|
+
const raw = body;
|
|
1308
|
+
if (typeof raw.owner !== "string" || raw.request === null || typeof raw.request !== "object" || Array.isArray(raw.request))
|
|
1309
|
+
return send(res, 400, { error: "managed agent enrollment verification needs a string owner and an object request" });
|
|
1310
|
+
const request = raw.request;
|
|
1311
|
+
if (request.kind !== "manager-managed-agent-enrollment" && request.kind !== "manager-managed-agent-prepare-retirement")
|
|
1312
|
+
return send(res, 400, { error: 'managed agent enrollment verification serves only kind "manager-managed-agent-enrollment" or "manager-managed-agent-prepare-retirement"' });
|
|
1313
|
+
if (typeof request.actor !== "string")
|
|
1314
|
+
return send(res, 400, { error: "managed agent enrollment verification request requires an actor" });
|
|
1315
|
+
try {
|
|
1316
|
+
const owner = assertDerivedOwnerToken(raw.owner);
|
|
1317
|
+
// The SCOPE DERIVATION, internal by construction: the interactive row for this exact
|
|
1318
|
+
// (owner, actor) on this machine's ledger decides, so `supervise` cannot be asserted by the
|
|
1319
|
+
// caller. An ungranted actor throws the ledger's own operator-exact sentence.
|
|
1320
|
+
const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
|
|
1321
|
+
const scope = row.scope ?? [];
|
|
1322
|
+
if (request.kind === "manager-managed-agent-enrollment") {
|
|
1323
|
+
const verified = await ctx.verifyManagedAgentEnrollment({
|
|
1324
|
+
owner,
|
|
1325
|
+
scope,
|
|
1326
|
+
request: raw.request,
|
|
1327
|
+
});
|
|
1328
|
+
return send(res, 200, {
|
|
1329
|
+
authorized: true,
|
|
1330
|
+
owner,
|
|
1331
|
+
actor: verified.target.actor,
|
|
1332
|
+
instanceId: verified.instanceId,
|
|
1333
|
+
serveEpoch: verified.serveEpoch,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
const verified = await ctx.verifyManagedAgentPrepareRetirement({
|
|
1337
|
+
owner,
|
|
1338
|
+
scope,
|
|
1339
|
+
request: raw.request,
|
|
1340
|
+
});
|
|
1341
|
+
return send(res, 200, {
|
|
1342
|
+
authorized: true,
|
|
1343
|
+
owner,
|
|
1344
|
+
actor: verified.target.actor,
|
|
1345
|
+
instanceId: verified.instanceId,
|
|
1346
|
+
serveEpoch: verified.serveEpoch,
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
catch (e) {
|
|
1350
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
1351
|
+
console.error(`auth-service: refused managed agent enrollment verification: ${reason}`);
|
|
1352
|
+
const status = e instanceof EpEnvelopeError ? (VERIFY_ENROLLMENT_STATUS[e.code] ?? 403) : 403;
|
|
1353
|
+
return send(res, status, { error: reason });
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1075
1356
|
/** The exchange body, shared by every face; `policy` says how this face proves and attributes the
|
|
1076
1357
|
* caller. Behavior on the loopback face is unchanged from the pre-route-table handler. */
|
|
1077
1358
|
async function handleExchange(req, res, ctx, policy) {
|
|
@@ -1115,12 +1396,16 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
1115
1396
|
const peer = policy.peerKey(req);
|
|
1116
1397
|
const peerThrottled = policy.throttled(ctx, peer);
|
|
1117
1398
|
const body = await readJsonBody(req);
|
|
1118
|
-
const { idpToken, actor, actorToken, owner, ttlSec, view } = body;
|
|
1399
|
+
const { idpToken, actor, actorToken, owner, ttlSec, view, managerInstanceId } = body;
|
|
1119
1400
|
if (ttlSec !== undefined && typeof ttlSec !== "number")
|
|
1120
1401
|
return send(res, 400, { error: "ttlSec must be a number" });
|
|
1121
1402
|
if (view !== undefined && typeof view !== "string")
|
|
1122
1403
|
return send(res, 400, { error: "view must be a string when present" });
|
|
1123
|
-
|
|
1404
|
+
if (managerInstanceId !== undefined && typeof managerInstanceId !== "string")
|
|
1405
|
+
return send(res, 400, { error: "managerInstanceId must be a string when present" });
|
|
1406
|
+
if (managerInstanceId !== undefined && view !== "manager-caller")
|
|
1407
|
+
return send(res, 400, { error: 'managerInstanceId is valid only with view "manager-caller"' });
|
|
1408
|
+
// Public face: channel-writer / channel-purger and the narrowing manager-caller view.
|
|
1124
1409
|
// admin, purger, deployer, and manager-service stay loopback-only, whatever the credential.
|
|
1125
1410
|
// A refused view is a refused exchange: record, audit, then 429 when the peer is already
|
|
1126
1411
|
// throttled, same order as the neighbouring denial sites. A served view stays unthrottled.
|
|
@@ -1138,9 +1423,9 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
1138
1423
|
if (idpToken !== undefined && actorToken !== undefined)
|
|
1139
1424
|
return send(res, 400, { error: "exchange takes idpToken (human) OR owner+actorToken (agent), never both" });
|
|
1140
1425
|
if (actorToken !== undefined) {
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
if (view !== undefined)
|
|
1426
|
+
// The actor-secret exception is manager-caller only: it adds no command capability.
|
|
1427
|
+
// Elevated views still require a signed-in human, whatever this agent's row carries.
|
|
1428
|
+
if (view !== undefined && view !== "manager-caller")
|
|
1144
1429
|
return send(res, 400, { error: "the managed (agent-secret) exchange never mints elevated views - views ride a signed-in human exchange" });
|
|
1145
1430
|
if (typeof owner !== "string" || !owner || typeof actor !== "string" || !actor || typeof actorToken !== "string" || !actorToken)
|
|
1146
1431
|
return send(res, 400, { error: "agent exchange needs { owner: string, actor: string, actorToken: string, ttlSec?: number }" });
|
|
@@ -1152,6 +1437,9 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
1152
1437
|
// ensured (minted release-last on first exchange) BEFORE the bearer bytes are signed,
|
|
1153
1438
|
// and rides act.credentialId — the connect arm requires it against the LIVE cred row.
|
|
1154
1439
|
const credentialId = await ctx.mintConnectCredential({ owner, actor, lifecycleUid: grant.lifecycleUid });
|
|
1440
|
+
const selectedManagerInstanceId = view === "manager-caller"
|
|
1441
|
+
? await ctx.selectManagerInstance(owner, managerInstanceId)
|
|
1442
|
+
: undefined;
|
|
1155
1443
|
const token = await ctx.issuer.issue({
|
|
1156
1444
|
owner,
|
|
1157
1445
|
space: ctx.space,
|
|
@@ -1164,10 +1452,11 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
1164
1452
|
// successor's broker authority.
|
|
1165
1453
|
lifecycleUid: grant.lifecycleUid,
|
|
1166
1454
|
credentialId,
|
|
1455
|
+
...(view === "manager-caller" ? { view, managerInstanceId: selectedManagerInstanceId } : {}),
|
|
1167
1456
|
ttlSec: Math.min(ttlSec ?? AGENT_BEARER_TTL_SEC, AGENT_BEARER_TTL_SEC),
|
|
1168
1457
|
});
|
|
1169
1458
|
const { exp } = decodeJwt(token);
|
|
1170
|
-
return send(res, 200, { token, owner, exp });
|
|
1459
|
+
return send(res, 200, { token, owner, exp, ...(selectedManagerInstanceId ? { managerInstanceId: selectedManagerInstanceId } : {}) });
|
|
1171
1460
|
}
|
|
1172
1461
|
catch (e) {
|
|
1173
1462
|
policy.recordFailure(ctx, peer);
|
|
@@ -1183,7 +1472,15 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
1183
1472
|
try {
|
|
1184
1473
|
// The bridge validates `view` against the closed enum and the fresh ledger grant — an
|
|
1185
1474
|
// unknown or under-scoped view is a refused exchange (audited + throttled like any other).
|
|
1186
|
-
const
|
|
1475
|
+
const selectedManagerInstanceId = view === "manager-caller"
|
|
1476
|
+
? await ctx.selectManagerInstance(deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(idpToken, ctx.bridgeIdp)).sub), managerInstanceId)
|
|
1477
|
+
: undefined;
|
|
1478
|
+
const r = await ctx.bridge.exchange(idpToken, {
|
|
1479
|
+
actor,
|
|
1480
|
+
ttlSec,
|
|
1481
|
+
view: view,
|
|
1482
|
+
managerInstanceId: selectedManagerInstanceId,
|
|
1483
|
+
});
|
|
1187
1484
|
return send(res, 200, r);
|
|
1188
1485
|
}
|
|
1189
1486
|
catch (e) {
|