@cotal-ai/auth 0.64.0 → 0.66.1
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/authority-client.d.ts +7 -6
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +14 -13
- package/dist/authority-client.js.map +1 -1
- package/dist/ledger.d.ts +3 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +7 -0
- package/dist/ledger.js.map +1 -1
- package/dist/service.d.ts +64 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +259 -60
- package/dist/service.js.map +1 -1
- package/package.json +3 -3
package/dist/service.js
CHANGED
|
@@ -59,7 +59,7 @@ import { resolve } from "node:path";
|
|
|
59
59
|
import { connect, credsAuthenticator } from "@nats-io/transport-node";
|
|
60
60
|
import { jetstreamManager } from "@nats-io/jetstream";
|
|
61
61
|
import { Kvm } from "@nats-io/kv";
|
|
62
|
-
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, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow,
|
|
62
|
+
import { CotalEndpoint, deprovisionAgent, isConcreteChannel, provisionAgentDurables, contractDigest, contractRefToHex, contractStoreContext, endpointToken, 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, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, registerServingInstance } from "@cotal-ai/core";
|
|
63
63
|
import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
|
|
64
64
|
import { decodeJwt } from "jose";
|
|
65
65
|
import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
|
|
@@ -78,7 +78,7 @@ import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
|
|
|
78
78
|
import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
|
|
79
79
|
import { validateRetainedManagedAgent } from "./continuity.js";
|
|
80
80
|
import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
|
|
81
|
-
import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof,
|
|
81
|
+
import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
|
|
82
82
|
import { authorizeConnectCredential } from "./connect-reader.js";
|
|
83
83
|
import { ensureRootCredential } from "./root-credential.js";
|
|
84
84
|
import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
|
|
@@ -93,7 +93,7 @@ import { makeDrainRepairers } from "./drain-repair.js";
|
|
|
93
93
|
import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
|
|
94
94
|
import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
|
|
95
95
|
import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
|
|
96
|
-
import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
|
|
96
|
+
import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, grantManagedActor, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorAt, } from "./ledger.js";
|
|
97
97
|
import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
|
|
98
98
|
/** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
|
|
99
99
|
* retire floor (max-age + max bearer TTL) from it. */
|
|
@@ -121,7 +121,21 @@ const PUBLIC_FAILED_PER_MIN = 30; // per-PEER refused-exchange window (rolling m
|
|
|
121
121
|
const PUBLIC_PEER_BUCKETS_MAX = 1024; // bounded LRU of per-peer failure buckets
|
|
122
122
|
const PUBLIC_MAX_IN_FLIGHT = 64; // global concurrent-admission cap on the public listener
|
|
123
123
|
const PUBLIC_DEADLINE_MS = 10_000; // hard wall-clock deadline per public request
|
|
124
|
+
const HOST_FENCE_POLL_MS = 1_000; // how often awaitHostFence re-reads the host gate
|
|
124
125
|
const OBSERVED_RUN_REQUEST_WINDOW_MS = 5 * 60_000; // how long an observed served resume or answer can still be forwarded
|
|
126
|
+
/** Enrollment and retirement preparation of one managed agent run one at a time, so neither acts on
|
|
127
|
+
* a grant the other is still writing or releasing. The chain lives at module level, keyed by the
|
|
128
|
+
* ledger directory, so two plane instances over one ledger share it. */
|
|
129
|
+
const MANAGED_ALIAS_CHAINS = new Map();
|
|
130
|
+
const serializedForAlias = (dir, owner, actor, fn) => {
|
|
131
|
+
const key = `${resolve(dir)}:${principalKey(owner, actor).key}`;
|
|
132
|
+
const run = (MANAGED_ALIAS_CHAINS.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
133
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
134
|
+
MANAGED_ALIAS_CHAINS.set(key, tail);
|
|
135
|
+
void tail.then(() => { if (MANAGED_ALIAS_CHAINS.get(key) === tail)
|
|
136
|
+
MANAGED_ALIAS_CHAINS.delete(key); });
|
|
137
|
+
return run;
|
|
138
|
+
};
|
|
125
139
|
/** Package-internal policy step for the hosted retirement requester. The HTTP production door calls
|
|
126
140
|
* this after deriving the owner and manager actors; broker-free smokes exercise the same decision. */
|
|
127
141
|
export function authorizeRemoteManagerRetirement(args) {
|
|
@@ -135,6 +149,50 @@ export function authorizeRemoteManagerRetirement(args) {
|
|
|
135
149
|
if (args.gate.processEpoch !== args.serveEpoch)
|
|
136
150
|
throw new EpEnvelopeError("conflict", `manager-service retirement serve epoch ${args.serveEpoch} is stale; current is ${args.gate.processEpoch}`);
|
|
137
151
|
}
|
|
152
|
+
/** The §13.9 name authority in static mode: the plane holds the space signing seed, so it
|
|
153
|
+
* self-authorizes exactly one endpoint name for exactly DEV_OWNER, as the manager does for
|
|
154
|
+
* "manager". */
|
|
155
|
+
function selfNameAuthority(endpoint) {
|
|
156
|
+
return { authorize: (name, owner) => ({ authorized: name === endpoint && owner === DEV_OWNER, revision: 0 }) };
|
|
157
|
+
}
|
|
158
|
+
/** Each contract artifact by its §13.7 content digest. */
|
|
159
|
+
function contractArtifactReader(values) {
|
|
160
|
+
const store = new Map(values.map((v) => [contractDigest(v), v]));
|
|
161
|
+
return (digest) => store.get(digest);
|
|
162
|
+
}
|
|
163
|
+
/** The §13.7 registration ceremony for one instance of an endpoint this plane self-authorizes, on
|
|
164
|
+
* a connection scoped to that instance. The contract artifacts are published before the
|
|
165
|
+
* registration that advertises them. The issuance gate is provisioned open once, on first sight
|
|
166
|
+
* (§13.1); on a restart it already exists and `registerServiceInstance` freezes and re-registers
|
|
167
|
+
* it, which advances the process epoch and fences the predecessor. A first registration stays at
|
|
168
|
+
* epoch 0. `register` is {@link registerServiceInstance}, or {@link registerServingInstance} for an
|
|
169
|
+
* instance that serves. It returns what `register` committed, and refuses when its confirming read
|
|
170
|
+
* finds that a later registration of the same instance superseded it. */
|
|
171
|
+
async function registerSelfAuthorizedInstance(nc, args, register) {
|
|
172
|
+
const { space, endpoint, instanceId } = args;
|
|
173
|
+
const authKv = await new Kvm(nc).open(epAuthBucket(space));
|
|
174
|
+
const recordsKv = await new Kvm(nc).open(recordsBucket(space));
|
|
175
|
+
const storeCtx = await contractStoreContext(nc, space);
|
|
176
|
+
for (const value of args.artifacts)
|
|
177
|
+
await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
|
|
178
|
+
if ((await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe()) === null)
|
|
179
|
+
await provisionEndpointGateOpen(authKv, { endpoint, instanceId, principal: args.principal });
|
|
180
|
+
const barrier = endpointRegistrationBarrier(authKv, space, { endpoint, instanceId, opId: mintLifecycleUid() });
|
|
181
|
+
const spec = { endpoint, owner: DEV_OWNER, clusterDigests: [args.clusterDigest], protocol: { v: 1 } };
|
|
182
|
+
const registered = await register(recordsKv, {
|
|
183
|
+
space, spec, instanceId, registrant: { owner: DEV_OWNER }, authority: selfNameAuthority(endpoint), barrier,
|
|
184
|
+
readClusterArtifact: contractArtifactReader(args.artifacts),
|
|
185
|
+
});
|
|
186
|
+
const { registrationRevision, processEpoch } = registered;
|
|
187
|
+
// A second start of the same instance can complete between this reopen and this read. Its epoch
|
|
188
|
+
// is not this start's to claim, and this start's epoch is already fenced. One that completes after
|
|
189
|
+
// the read leaves this start returning its own epoch, which the gate fences as it fences any
|
|
190
|
+
// restart's predecessor; no read can close that window.
|
|
191
|
+
const observed = await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe();
|
|
192
|
+
if (observed?.state !== "open" || observed.processEpoch !== processEpoch || observed.registrationRevision !== registrationRevision)
|
|
193
|
+
throw new EpEnvelopeError("conflict", `the issuance gate of ${endpoint}/${instanceId} is no longer open at this registration (process epoch ${processEpoch}, revision ${registrationRevision}); a later barrier fenced this start (SPEC 13.1)`);
|
|
194
|
+
return registered;
|
|
195
|
+
}
|
|
138
196
|
/**
|
|
139
197
|
* Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
|
|
140
198
|
* deny-new smoke exercises exactly what the daemon runs). Boot order is the readiness contract:
|
|
@@ -179,60 +237,18 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
179
237
|
});
|
|
180
238
|
const iid = authIdentity.instanceId;
|
|
181
239
|
const artifacts = authClusterArtifacts();
|
|
182
|
-
const
|
|
183
|
-
[artifacts.rootDigest, artifacts.document],
|
|
184
|
-
[artifacts.closureDigest, artifacts.manifest],
|
|
185
|
-
]);
|
|
186
|
-
const readClusterArtifact = (digest) => store.get(digest);
|
|
187
|
-
// §13.9 name authority, static mode: the auth plane holds the space signing seed, so it
|
|
188
|
-
// self-authorizes exactly its own name ("auth") for exactly DEV_OWNER — mirrors the manager's
|
|
189
|
-
// own self-authorization for "manager".
|
|
190
|
-
const authority = {
|
|
191
|
-
authorize: (name, owner) => ({ authorized: name === AUTH_SERVICE_ENDPOINT && owner === DEV_OWNER, revision: 0 }),
|
|
192
|
-
};
|
|
193
|
-
const servePrincipal = principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key;
|
|
194
|
-
const registrationOpId = mintLifecycleUid();
|
|
240
|
+
const values = [...authContractArtifactValues(), artifacts.document, artifacts.manifest];
|
|
195
241
|
const regClient = await openAuthorityClient({
|
|
196
242
|
server, space, dataAccount, label: `cotal:auth-registration:${space}`,
|
|
197
|
-
grants: (id) =>
|
|
243
|
+
grants: (id) => registrationExecutorGrants(space, id, AUTH_SERVICE_ENDPOINT, iid),
|
|
198
244
|
log,
|
|
199
245
|
});
|
|
200
246
|
try {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
|
|
207
|
-
// §13.1 pre-registration: the issuance gate, born open@gen0, provisioned ONCE on the FIRST
|
|
208
|
-
// registration. On a restart the gate already exists; `registerServiceInstance` below
|
|
209
|
-
// freezes + re-registers it (advancing the epoch).
|
|
210
|
-
if ((await serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid }).observe()) === null)
|
|
211
|
-
await provisionEndpointGateOpen(authKv, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, principal: servePrincipal });
|
|
212
|
-
const barrier = endpointRegistrationBarrier(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, opId: registrationOpId });
|
|
213
|
-
const spec = { endpoint: AUTH_SERVICE_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
|
|
214
|
-
const { registrationRevision } = await registerServiceInstance(recordsKv, {
|
|
215
|
-
space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
|
|
216
|
-
});
|
|
217
|
-
const fence = serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid });
|
|
218
|
-
const observed = await fence.observe();
|
|
219
|
-
if (observed === null)
|
|
220
|
-
throw new Error(`the issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid} vanished after registration`);
|
|
221
|
-
const readProcessEpoch = async () => {
|
|
222
|
-
const g = await fence.observe();
|
|
223
|
-
if (g === null)
|
|
224
|
-
throw new Error(`no issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid}`);
|
|
225
|
-
return g.processEpoch;
|
|
226
|
-
};
|
|
227
|
-
const grant = await authorizeServeGrant(recordsKv, {
|
|
228
|
-
space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
|
|
229
|
-
holder: { owner: DEV_OWNER }, authority, readClusterArtifact, readProcessEpoch,
|
|
230
|
-
});
|
|
231
|
-
await writeServiceStatus(recordsKv, {
|
|
232
|
-
endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
|
|
233
|
-
status: { state: SERVICE_READY, epoch: observed.processEpoch, observedSpecRevision: registrationRevision },
|
|
234
|
-
readProcessEpoch,
|
|
235
|
-
});
|
|
247
|
+
const { grant } = await registerSelfAuthorizedInstance(regClient.nc, {
|
|
248
|
+
space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid,
|
|
249
|
+
principal: principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key,
|
|
250
|
+
clusterDigest: artifacts.closureDigest, artifacts: values,
|
|
251
|
+
}, registerServingInstance);
|
|
236
252
|
authServeInstanceId = iid;
|
|
237
253
|
authServeGrant = grant;
|
|
238
254
|
}
|
|
@@ -574,6 +590,14 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
574
590
|
observedRunRequests.delete(subject);
|
|
575
591
|
return observed !== undefined && observed.expires > Date.now() ? observed.request : undefined;
|
|
576
592
|
};
|
|
593
|
+
const observeManagerGate = async (instanceId) => serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe();
|
|
594
|
+
// A managed lifecycle's broker footprint, released under a deprovisioner pinned to that uid, so
|
|
595
|
+
// no call here can reach a same-name successor's durables.
|
|
596
|
+
const deprovisionManagedFootprint = async (owner, actor, lifecycleUid, memberChannels) => {
|
|
597
|
+
const targetId = principalKey(owner, actor).key;
|
|
598
|
+
const creds = await mintCreds(issuerAuth(), newIdentity(), "deprovisioner", { deprovisionTarget: { principal: targetId, lifecycleUid, memberChannels } });
|
|
599
|
+
await deprovisionAgent({ servers: server, space, targetId, lifecycleUid, memberChannels, creds });
|
|
600
|
+
};
|
|
577
601
|
return {
|
|
578
602
|
authorizeConnect: async (t) => {
|
|
579
603
|
refuseIfFenced();
|
|
@@ -1001,6 +1025,121 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
1001
1025
|
},
|
|
1002
1026
|
});
|
|
1003
1027
|
},
|
|
1028
|
+
enrollManagedAgent: async ({ owner, scope, request, sentinelCreds, agentBearerExchangeUrl }) => {
|
|
1029
|
+
refuseIfFenced();
|
|
1030
|
+
const r = await authorizeRemoteManagedAgentEnrollment({ owner, scope, proofSecret: dataAccount.signingSeed, space, request, observeManagerGate });
|
|
1031
|
+
const t = r.target;
|
|
1032
|
+
const subscribe = t.subscribe ?? [];
|
|
1033
|
+
const { lifecycleUid, row } = await serializedForAlias(opts.dir, owner, t.actor, async () => {
|
|
1034
|
+
// The token digest names one enrollment. A held row with this request's digest is that
|
|
1035
|
+
// enrollment retried, so it answers the same uid; any other held row is a lifecycle that
|
|
1036
|
+
// still runs, and taking its grant would strand it.
|
|
1037
|
+
const held = findManagedActor(opts.dir, owner, t.actor);
|
|
1038
|
+
if (held !== undefined && held.tokenHash !== t.tokenHash)
|
|
1039
|
+
throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
|
|
1040
|
+
// A fresh uid is never one a retirement tombstoned, and only the host can make that promise.
|
|
1041
|
+
const lifecycleUid = held?.lifecycleUid ?? mintLifecycleUid();
|
|
1042
|
+
// The grant first: its envelope walk against the supervising actor refuses an over-wide
|
|
1043
|
+
// request before any durable exists. A retry walks the held grant again: its spawner chain
|
|
1044
|
+
// may have narrowed since the first answer, and provisioning would restore what it took away.
|
|
1045
|
+
if (held !== undefined)
|
|
1046
|
+
assertWithinSpawnerGrant(opts.dir, held, "spawn");
|
|
1047
|
+
const row = held ?? grantManagedActor(opts.dir, {
|
|
1048
|
+
owner,
|
|
1049
|
+
actor: t.actor,
|
|
1050
|
+
scope: t.capabilities ?? [],
|
|
1051
|
+
allowSubscribe: t.allowSubscribe ?? [],
|
|
1052
|
+
allowPublish: t.allowPublish ?? [],
|
|
1053
|
+
...(t.role !== undefined ? { role: t.role } : {}),
|
|
1054
|
+
...(t.label !== undefined ? { label: t.label } : {}),
|
|
1055
|
+
parent: principalKey(owner, r.actor).key,
|
|
1056
|
+
lifecycleUid,
|
|
1057
|
+
tokenHash: t.tokenHash,
|
|
1058
|
+
});
|
|
1059
|
+
try {
|
|
1060
|
+
const identity = newIdentity();
|
|
1061
|
+
const provisioner = new CotalEndpoint({
|
|
1062
|
+
space,
|
|
1063
|
+
servers: server,
|
|
1064
|
+
channels: [],
|
|
1065
|
+
creds: await mintCreds(issuerAuth(), identity, "provisioner"),
|
|
1066
|
+
card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
|
|
1067
|
+
registerPresence: false,
|
|
1068
|
+
watchPresence: false,
|
|
1069
|
+
watchChannels: false,
|
|
1070
|
+
consume: false,
|
|
1071
|
+
});
|
|
1072
|
+
await provisioner.start();
|
|
1073
|
+
try {
|
|
1074
|
+
await provisionAgentDurables(provisioner, { owner, actor: t.actor, lifecycleUid }, { subscribe, allowSubscribe: row.allowSubscribe, role: row.role });
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
await provisioner.stop();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
// A retried enrollment's first answer may already have reached the participant, so its
|
|
1082
|
+
// grant stays. A grant this call wrote gave the participant no uid, so it can never be
|
|
1083
|
+
// retired: it and any durable it got must go now.
|
|
1084
|
+
if (held === undefined) {
|
|
1085
|
+
revokeManagedActorAt(opts.dir, owner, t.actor, lifecycleUid);
|
|
1086
|
+
await deprovisionManagedFootprint(owner, t.actor, lifecycleUid, []).catch((err) => opts.log(`auth-service: refused enrollment of ${owner}/${t.actor} left durables at ${lifecycleUid}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1087
|
+
}
|
|
1088
|
+
throw e;
|
|
1089
|
+
}
|
|
1090
|
+
return { lifecycleUid, row };
|
|
1091
|
+
});
|
|
1092
|
+
return {
|
|
1093
|
+
v: 1,
|
|
1094
|
+
kind: "manager-managed-agent-enrollment",
|
|
1095
|
+
space,
|
|
1096
|
+
owner,
|
|
1097
|
+
actor: r.actor,
|
|
1098
|
+
instanceId: r.instanceId,
|
|
1099
|
+
managerLifecycleUid: r.managerLifecycleUid,
|
|
1100
|
+
requestId: r.requestId,
|
|
1101
|
+
registrationProof: r.registrationProof,
|
|
1102
|
+
serveEpoch: r.serveEpoch,
|
|
1103
|
+
material: {
|
|
1104
|
+
owner,
|
|
1105
|
+
actor: t.actor,
|
|
1106
|
+
lifecycleUid,
|
|
1107
|
+
sentinelCreds,
|
|
1108
|
+
subscribe,
|
|
1109
|
+
allowSubscribe: row.allowSubscribe,
|
|
1110
|
+
allowPublish: row.allowPublish,
|
|
1111
|
+
agentBearerExchangeUrl,
|
|
1112
|
+
},
|
|
1113
|
+
};
|
|
1114
|
+
},
|
|
1115
|
+
prepareManagedAgentRetirement: async ({ owner, scope, request }) => {
|
|
1116
|
+
refuseIfFenced();
|
|
1117
|
+
const r = await authorizeRemoteManagedAgentPrepareRetirement({ owner, scope, proofSecret: dataAccount.signingSeed, space, request, observeManagerGate });
|
|
1118
|
+
const { actor, lifecycleUid } = r.target;
|
|
1119
|
+
await serializedForAlias(opts.dir, owner, actor, async () => {
|
|
1120
|
+
// Only a grant still at the target uid is this lifecycle's. A row at another uid belongs to a
|
|
1121
|
+
// successor, and an absent row means an earlier attempt already released both halves.
|
|
1122
|
+
const row = findManagedActor(opts.dir, owner, actor);
|
|
1123
|
+
if (row?.lifecycleUid === lifecycleUid) {
|
|
1124
|
+
// Footprint before grant: while the row stands, a retry still knows which channels to purge.
|
|
1125
|
+
await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
|
|
1126
|
+
revokeManagedActorAt(opts.dir, owner, actor, lifecycleUid);
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
return {
|
|
1130
|
+
v: 1,
|
|
1131
|
+
kind: "manager-managed-agent-prepare-retirement",
|
|
1132
|
+
space,
|
|
1133
|
+
owner,
|
|
1134
|
+
actor: r.actor,
|
|
1135
|
+
instanceId: r.instanceId,
|
|
1136
|
+
managerLifecycleUid: r.managerLifecycleUid,
|
|
1137
|
+
requestId: r.requestId,
|
|
1138
|
+
target: r.target,
|
|
1139
|
+
opId: r.opId,
|
|
1140
|
+
prepared: true,
|
|
1141
|
+
};
|
|
1142
|
+
},
|
|
1004
1143
|
verifyManagedAgentRuntime: async ({ owner, request }) => {
|
|
1005
1144
|
refuseIfFenced();
|
|
1006
1145
|
const args = {
|
|
@@ -1188,6 +1327,29 @@ export async function openAuthAuthorityPlane(opts) {
|
|
|
1188
1327
|
const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
|
|
1189
1328
|
return { registered: rec !== undefined && !("deleted" in rec), gate: gate ?? null };
|
|
1190
1329
|
},
|
|
1330
|
+
registerHostInstance: async (host, instanceId) => {
|
|
1331
|
+
refuseIfFenced();
|
|
1332
|
+
const regClient = await openAuthorityClient({
|
|
1333
|
+
server, space, dataAccount, label: `cotal:host-registration:${space}`,
|
|
1334
|
+
grants: (id) => registrationExecutorGrants(space, id, host.endpoint, instanceId),
|
|
1335
|
+
log,
|
|
1336
|
+
});
|
|
1337
|
+
try {
|
|
1338
|
+
return (await registerSelfAuthorizedInstance(regClient.nc, {
|
|
1339
|
+
space, endpoint: host.endpoint, instanceId,
|
|
1340
|
+
// The host is minted no serve credential, so the gate binds a principal nothing holds.
|
|
1341
|
+
principal: principalKey(DEV_OWNER, `host_serve_${instanceId}`).key,
|
|
1342
|
+
clusterDigest: host.clusterDigest, artifacts: host.artifacts,
|
|
1343
|
+
}, registerServiceInstance)).processEpoch;
|
|
1344
|
+
}
|
|
1345
|
+
finally {
|
|
1346
|
+
await regClient.close();
|
|
1347
|
+
}
|
|
1348
|
+
},
|
|
1349
|
+
observeEndpointGate: async (endpoint, instanceId) => {
|
|
1350
|
+
refuseIfFenced();
|
|
1351
|
+
return await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint, instanceId }).observe();
|
|
1352
|
+
},
|
|
1191
1353
|
fenced,
|
|
1192
1354
|
close: async () => {
|
|
1193
1355
|
// Clean-close order (SPEC 13.13): the rail stops answering first, then scan-capable
|
|
@@ -1393,6 +1555,12 @@ export async function startAuthService(inputs) {
|
|
|
1393
1555
|
throw new Error(`auth-service: port must be a port number, got ${port}`);
|
|
1394
1556
|
if (inputs.platformControl !== undefined && typeof inputs.platformControl?.observeAssignment !== "function")
|
|
1395
1557
|
throw new Error("auth-service: platformControl requires an observeAssignment function");
|
|
1558
|
+
const host = inputs.platformControl?.host;
|
|
1559
|
+
if (host !== undefined) {
|
|
1560
|
+
endpointToken(host.endpoint);
|
|
1561
|
+
if (!host.endpoint.includes("."))
|
|
1562
|
+
throw new Error(`auth-service: platformControl.host.endpoint "${host.endpoint}" must be a reverse-DNS name; single-label names are reserved (SPEC 13.2)`);
|
|
1563
|
+
}
|
|
1396
1564
|
const badFace = inputs.publicFace && checkPublicFace(inputs.publicFace);
|
|
1397
1565
|
if (badFace)
|
|
1398
1566
|
throw new Error(badFace);
|
|
@@ -1491,6 +1659,9 @@ async function startAuthContext(o) {
|
|
|
1491
1659
|
mintConnectCredential: plane.mintConnectCredential,
|
|
1492
1660
|
});
|
|
1493
1661
|
const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
|
|
1662
|
+
// Set once the public face binds. An enrolled child's bearer exchanges there without the
|
|
1663
|
+
// loopback capability, so enrollment needs that face.
|
|
1664
|
+
let publicUrl;
|
|
1494
1665
|
const failures = []; // rolling-window timestamps of REFUSED exchanges
|
|
1495
1666
|
const badCaps = []; // rolling-window timestamps of invalid-capability attempts
|
|
1496
1667
|
const ctx = {
|
|
@@ -1504,6 +1675,12 @@ async function startAuthContext(o) {
|
|
|
1504
1675
|
verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
|
|
1505
1676
|
verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
|
|
1506
1677
|
verifyManagedAgentRuntime: plane.verifyManagedAgentRuntime,
|
|
1678
|
+
enrollManagedAgent: (args) => {
|
|
1679
|
+
if (publicUrl === undefined)
|
|
1680
|
+
throw new EpEnvelopeError("failed-precondition", "managed agent enrollment needs the public exchange face, where the enrolled agent's bearer exchanges - start the auth service with --exchange-public-port");
|
|
1681
|
+
return plane.enrollManagedAgent({ ...args, sentinelCreds: callout.sentinelCreds, agentBearerExchangeUrl: publicUrl });
|
|
1682
|
+
},
|
|
1683
|
+
prepareManagedAgentRetirement: plane.prepareManagedAgentRetirement,
|
|
1507
1684
|
scanManagerGoalIndex: plane.scanManagerGoalIndex,
|
|
1508
1685
|
authorizeManagerAdmin: plane.authorizeManagerAdmin,
|
|
1509
1686
|
admitManagerRun: plane.admitManagerRun,
|
|
@@ -1531,7 +1708,6 @@ async function startAuthContext(o) {
|
|
|
1531
1708
|
const url = `http://127.0.0.1:${boundPort}`;
|
|
1532
1709
|
// The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
|
|
1533
1710
|
// loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
|
|
1534
|
-
let publicUrl;
|
|
1535
1711
|
if (o.publicFace !== undefined) {
|
|
1536
1712
|
const face = o.publicFace;
|
|
1537
1713
|
// The discovery bundle is GENERATED from the daemon's own recorded config — the pinned IdP,
|
|
@@ -1593,6 +1769,7 @@ async function startAuthContext(o) {
|
|
|
1593
1769
|
});
|
|
1594
1770
|
const context = o.context ?? { accountPublicKey: keys.dataAccount.pub, lifecycleUid: "" };
|
|
1595
1771
|
const platformControl = o.platformControl;
|
|
1772
|
+
const host = platformControl?.host;
|
|
1596
1773
|
// The human route's retained-validation composition, for the platform holder.
|
|
1597
1774
|
const validateRetainedForHolder = async (holder, request) => {
|
|
1598
1775
|
const retained = await plane.validateRetainedAgent({ ...holder, request });
|
|
@@ -1670,6 +1847,29 @@ async function startAuthContext(o) {
|
|
|
1670
1847
|
await plane.activateManagedLifecycle(target);
|
|
1671
1848
|
},
|
|
1672
1849
|
} : {}),
|
|
1850
|
+
...(host !== undefined ? {
|
|
1851
|
+
registerHostIncarnation: async (instanceId) => {
|
|
1852
|
+
refuseUnlessReady();
|
|
1853
|
+
return { instanceId, processEpoch: await plane.registerHostInstance(host, instanceId) };
|
|
1854
|
+
},
|
|
1855
|
+
observeHostGate: async (instanceId) => {
|
|
1856
|
+
refuseUnlessReady();
|
|
1857
|
+
return await plane.observeEndpointGate(host.endpoint, instanceId);
|
|
1858
|
+
},
|
|
1859
|
+
awaitHostFence: async (instanceId, processEpoch) => {
|
|
1860
|
+
if (!Number.isSafeInteger(processEpoch) || processEpoch < 0)
|
|
1861
|
+
throw new EpEnvelopeError("bad-request", `processEpoch must be a non-negative integer, got ${String(processEpoch)}`);
|
|
1862
|
+
// No runtime credential may create a consumer on the auth bucket (SPEC 13.9), so the
|
|
1863
|
+
// gate cannot be watched; the hook polls its leader-served read.
|
|
1864
|
+
for (;;) {
|
|
1865
|
+
refuseUnlessReady();
|
|
1866
|
+
const gate = await plane.observeEndpointGate(host.endpoint, instanceId);
|
|
1867
|
+
if (gate?.state !== "open" || gate.processEpoch !== processEpoch)
|
|
1868
|
+
return gate;
|
|
1869
|
+
await new Promise((r) => setTimeout(r, HOST_FENCE_POLL_MS));
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
} : {}),
|
|
1673
1873
|
readiness() {
|
|
1674
1874
|
if (state === "unavailable")
|
|
1675
1875
|
return { state, context, cause: cause ?? "auth-service context is unavailable" };
|
|
@@ -1697,13 +1897,12 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
|
|
|
1697
1897
|
if (typeof request.actor !== "string")
|
|
1698
1898
|
throw new Error("manager-service authority request requires an actor");
|
|
1699
1899
|
const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
|
|
1700
|
-
// #1972:
|
|
1701
|
-
//
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
throw new EpEnvelopeError("unimplemented", "managed agent enrollment and retirement preparation must be handled by host platform interception");
|
|
1900
|
+
// #1972: this service owns the ledger and the space's provisioning authority, so it is the host
|
|
1901
|
+
// that enrolls a remote manager's managed agent and prepares its terminal release.
|
|
1902
|
+
if (request.kind === "manager-managed-agent-enrollment")
|
|
1903
|
+
return ctx.enrollManagedAgent({ owner, scope: row.scope ?? [], request: body.request });
|
|
1904
|
+
if (request.kind === "manager-managed-agent-prepare-retirement")
|
|
1905
|
+
return ctx.prepareManagedAgentRetirement({ owner, scope: row.scope ?? [], request: body.request });
|
|
1707
1906
|
// The hosted runtime kinds read and drive host-owned intent state that stock does not hold.
|
|
1708
1907
|
if (request.kind === "manager-managed-agent-runtime-create" || request.kind === "manager-managed-agent-runtime-status")
|
|
1709
1908
|
throw new EpEnvelopeError("unimplemented", "managed agent runtime create and status must be handled by host platform interception");
|