@cotal-ai/auth 0.93.0 → 0.94.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/callout.d.ts.map +1 -1
- package/dist/callout.js +12 -3
- package/dist/callout.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +5 -0
- package/dist/lifecycle-registry.d.ts.map +1 -1
- package/dist/lifecycle-registry.js +8 -1
- package/dist/lifecycle-registry.js.map +1 -1
- package/dist/permissions.d.ts +9 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +11 -2
- package/dist/permissions.js.map +1 -1
- package/dist/service-caller-ancestry.d.ts +19 -0
- package/dist/service-caller-ancestry.d.ts.map +1 -0
- package/dist/service-caller-ancestry.js +56 -0
- package/dist/service-caller-ancestry.js.map +1 -0
- package/dist/service-caller-effective.d.ts +12 -0
- package/dist/service-caller-effective.d.ts.map +1 -0
- package/dist/service-caller-effective.js +94 -0
- package/dist/service-caller-effective.js.map +1 -0
- package/dist/service-caller-payload-bootstrap.d.ts +30 -0
- package/dist/service-caller-payload-bootstrap.d.ts.map +1 -0
- package/dist/service-caller-payload-bootstrap.js +154 -0
- package/dist/service-caller-payload-bootstrap.js.map +1 -0
- package/dist/service-caller-payload.d.ts +50 -0
- package/dist/service-caller-payload.d.ts.map +1 -0
- package/dist/service-caller-payload.js +77 -0
- package/dist/service-caller-payload.js.map +1 -0
- package/dist/service-caller-policy.d.ts +71 -0
- package/dist/service-caller-policy.d.ts.map +1 -1
- package/dist/service-caller-policy.js +83 -20
- package/dist/service-caller-policy.js.map +1 -1
- package/dist/service-host-contract.d.ts +8 -2
- package/dist/service-host-contract.d.ts.map +1 -1
- package/dist/service-host-contract.js +67 -4
- package/dist/service-host-contract.js.map +1 -1
- package/dist/service-host-ops.d.ts +85 -7
- package/dist/service-host-ops.d.ts.map +1 -1
- package/dist/service-host-ops.js +804 -18
- package/dist/service-host-ops.js.map +1 -1
- package/dist/service-write.d.ts +3 -0
- package/dist/service-write.d.ts.map +1 -1
- package/dist/service-write.js +1 -1
- package/dist/service-write.js.map +1 -1
- package/dist/service.d.ts +8 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +44 -2
- package/dist/service.js.map +1 -1
- package/package.json +7 -4
package/dist/service-host-ops.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** Package-internal service host operations: register admission, decide (approve or refuse),
|
|
2
|
-
* revoke, create-only name binding,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* revoke, create-only name binding, caller grants and caller revocation up to deny-new and its
|
|
3
|
+
* cutoff marker, the operation, proposal and caller-policy reads and one owner's assignment state,
|
|
4
|
+
* over closed per-key records in the auth bucket. The index does not export it; only the
|
|
5
|
+
* owner-proved public authority door calls register. It grants no serving, registration, issuance
|
|
6
|
+
* from caller policy, terminal caller revocation, restore, terminal retirement or KICK.
|
|
6
7
|
*
|
|
7
8
|
* Every write is one zero-history create or one exact-revision CAS on one key, issued through the
|
|
8
9
|
* shared custody writer (service-write.ts) under the same plane-claim hold check and fatal latch as
|
|
@@ -13,17 +14,21 @@
|
|
|
13
14
|
* what a later invocation reads. The invocation limit and the original deadline are limits, never
|
|
14
15
|
* waits, fences or proof that an earlier write cannot still land. */
|
|
15
16
|
import { createHash } from "node:crypto";
|
|
16
|
-
import { EpEnvelopeError, GOVERN_HEAD, RECORD_KINDS, assertDerivedOwnerToken, assertLifecycleToken, canonicalJson, contractArtifactCanonicalBytes, contractDigest, contractStoreContext, endpointRegistrationBarrier, endpointToken, epgateKey, isContractDigest, mintLifecycleUid, parseEndpointCapabilities, parseNativeUidCustody, parseServiceSpec, parseServiceStatus, publishContractArtifact, recordAtomicKey, recordSpecKey, recordStatusKey, registerServiceInstance, serviceEndpointActor, serviceGateApprovalLive, serviceWitnessDigest, verifyClusterManifest, verifyClusterRoot, writeServiceStatus, } from "@cotal-ai/core";
|
|
17
|
+
import { EpEnvelopeError, GOVERN_HEAD, RECORD_KINDS, assertBoundedOwner, assertDerivedOwnerToken, assertLifecycleToken, canonicalJson, contractArtifactCanonicalBytes, contractDigest, contractStoreContext, endpointCapabilitiesWithin, endpointRegistrationBarrier, endpointToken, epgateKey, isContractDigest, mintLifecycleUid, parseEndpointCapabilities, parseNativeUidCustody, parseServiceSpec, parseServiceStatus, publishContractArtifact, recordAtomicKey, recordSpecKey, recordStatusKey, registerServiceInstance, serviceEndpointActor, serviceGateApprovalLive, serviceWitnessDigest, verifyClusterManifest, verifyClusterRoot, writeServiceStatus, } from "@cotal-ai/core";
|
|
17
18
|
import { SERVICE_HOST_INVOCATION_MS, openAuthorityClient, serviceHostEffectGrants, serviceHostEffectLabel } from "./authority-client.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
19
|
+
import { callerParentRefs } from "./service-caller-ancestry.js";
|
|
20
|
+
import { findActorUnified, findInteractiveActor } from "./ledger.js";
|
|
21
|
+
import { openLifecycleRegistry, registryMaxPayload, registryServerId, registryStores } from "./lifecycle-registry.js";
|
|
20
22
|
import { activatedServiceGateRow, approvedServiceGateRow, assertApprovableServiceGate } from "./native-service-gate.js";
|
|
21
23
|
import { nativeServiceNameAuthority, readNativeServiceName, serviceNameKey, serviceNameRowBytes } from "./native-service-name.js";
|
|
24
|
+
import { genuinePlaneHold, readPlaneClaim } from "./plane-claim.js";
|
|
25
|
+
import { SERVICE_CALLER_PAYLOAD_KEY, parseServiceCallerPayloadRecord, serviceCallerPayloadCheck, serviceCallerWriteFits, } from "./service-caller-payload.js";
|
|
26
|
+
import { callerAppendStep, callerBeginStep, callerCutoffStep, immutableGrantView, parseNativeServiceCallerPolicy, parseServiceCallerGrant, servicePolicyKey, } from "./service-caller-policy.js";
|
|
22
27
|
import { encodeRoundTrip } from "./service-custody-shapes.js";
|
|
23
28
|
import { cancelServiceCustody, runServiceBootstrap, sealServiceBootstrapIntent, sealServiceCancelIntent, serviceAssignmentGate, serviceCustodyKeys, serviceReservationKeys, } from "./service-custody.js";
|
|
24
29
|
import { parseServiceHostRequest, parseServiceProposalClusters, parseServiceProposalCommands, } from "./service-host-contract.js";
|
|
25
30
|
import { readRegistryServiceRoot } from "./service-root-reader.js";
|
|
26
|
-
import { HOSTS, Held, bounded, context, endpoint, held, leader, message, openServiceCustodyHost, readyAfter, readyBefore, scopeCovers, write, } from "./service-write.js";
|
|
31
|
+
import { HOSTS, Held, S1_REQUEST_TIMEOUT_MS, bounded, context, endpoint, held, leader, message, openServiceCustodyHost, readyAfter, readyBefore, scopeCovers, write, } from "./service-write.js";
|
|
27
32
|
/** Original authority limit of one approve, refuse or register, from its admission. */
|
|
28
33
|
export const S2A_OPERATION_TTL_MS = 600_000;
|
|
29
34
|
/** Per-invocation limit for reads, bookkeeping, settlement and native cancellation, and the
|
|
@@ -38,6 +43,9 @@ export const S2A_MAX_PROPOSAL_BYTES = 256 * 1024;
|
|
|
38
43
|
export const S2A_MAX_REDECIDE = 4;
|
|
39
44
|
/** Serving request intents one instance keeps (SPEC 13.1): never pruned or reused in this cut. */
|
|
40
45
|
export const S3_MAX_INTENTS_PER_INSTANCE = 64;
|
|
46
|
+
/** Index entries one service instance may ever hold: every caller grant written for it, active,
|
|
47
|
+
* revoking or orphaned by a grant attempt refused after its index write. Nothing is pruned. */
|
|
48
|
+
export const S3_MAX_CALLER_GRANTS_PER_INSTANCE = 256;
|
|
41
49
|
const ADMISSION_KEY = "svchost.admission";
|
|
42
50
|
const CAPACITY_KEY = "svccap.space";
|
|
43
51
|
const opKey = (operationId) => `svcop.${operationId}`;
|
|
@@ -45,6 +53,8 @@ const proposalKey = (proposalId) => `svcprop.${proposalId}`;
|
|
|
45
53
|
const assignmentKey = (endpoint, instanceId) => `svcasg.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}`;
|
|
46
54
|
const intentKey = (operationId) => `svcint.${hexN(operationId, 32)}`;
|
|
47
55
|
const intentIndexKey = (endpoint, instanceId) => `svcintx.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}`;
|
|
56
|
+
const callerIndexKey = (endpoint, instanceId) => `svcpolx.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}`;
|
|
57
|
+
const cutoffKey = (operationId) => `svccut.${hexN(operationId, 32)}`;
|
|
48
58
|
const enc = new TextEncoder();
|
|
49
59
|
const seconds = (ms) => Math.floor(ms / 1000);
|
|
50
60
|
// ---- identities ---------------------------------------------------------------------------------
|
|
@@ -53,6 +63,8 @@ const hex32 = (tag, space, value) => createHash("sha256").update(`${tag}\0${spac
|
|
|
53
63
|
export const serviceHostOperationId = (space, requestId) => hex32("cotal-service-host-op", space, requestId);
|
|
54
64
|
/** The native cancel identity an expiry settlement derives from recorded facts only. */
|
|
55
65
|
const expiryOperationId = (space, approveOperationId) => hex32("cotal-service-host-expiry", space, approveOperationId);
|
|
66
|
+
/** A caller grant's id, from its operation: one grant per bound grant request, never reused. */
|
|
67
|
+
const callerGrantId = (space, operationId) => hex32("cotal-service-caller-grant", space, operationId);
|
|
56
68
|
const expiryRequestDigest = (operationId, assignmentDigest) => contractDigest({ v: 1, kind: "service-approval-expiry", operationId, assignmentDigest });
|
|
57
69
|
const requestDigestOf = (r) => contractDigest({ v: r.v, kind: r.kind, requestId: r.requestId, operation: r.operation, input: r.input, granter: r.granter });
|
|
58
70
|
const proposalIdOf = (proposalDigest, registerRequestId) => contractDigest({ v: 1, kind: "service-proposal-id", proposalDigest, registerRequestId }).slice("sha256:".length);
|
|
@@ -92,6 +104,20 @@ const endpointName = (raw) => { if (typeof raw !== "string")
|
|
|
92
104
|
bad("expected an endpoint"); grammar(() => endpointToken(raw)); return raw; };
|
|
93
105
|
const token = (raw, what) => { if (typeof raw !== "string")
|
|
94
106
|
bad(`expected ${what}`); return grammar(() => assertLifecycleToken(raw, what)); };
|
|
107
|
+
/** A caller-ceiling entry an approval may carry: pinned to the proposal's own instance with
|
|
108
|
+
* `instanceOnly`, on a declared command, with exactly the target presence and mode that command
|
|
109
|
+
* admits. `any` and `ledger` need an operator target policy, which stock does not wire. Containment
|
|
110
|
+
* then compares instance rails directly; no class entry is re-read as an instance one. */
|
|
111
|
+
const ceilingEntryAdmitted = (c, p) => {
|
|
112
|
+
if (c.endpoint !== p.endpoint || c.instanceId !== p.instanceId || c.instanceOnly !== true)
|
|
113
|
+
return false;
|
|
114
|
+
const k = p.commands.find((x) => x.name === c.command);
|
|
115
|
+
if (k === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
if (!k.targeted)
|
|
118
|
+
return c.target === undefined;
|
|
119
|
+
return c.target !== undefined && c.target.mode !== "any" && c.target.mode !== "ledger" && k.modes.includes(c.target.mode);
|
|
120
|
+
};
|
|
95
121
|
const CAUSES = ["owner-request", "membership-removal", "operator-request", "restore-quarantine", "approval-expired"];
|
|
96
122
|
const REFUSALS = ["conflict", "expired", "revoked", "superseded", "custody-refused", "admission-closed",
|
|
97
123
|
"resource-exhausted", "selector-pending", "selector-bound", "name-owned", "restore", "owner", "operator"];
|
|
@@ -132,6 +158,20 @@ function cancelOf(raw) {
|
|
|
132
158
|
const o = closed(raw, ["operationId", "requestDigest", "cause"]);
|
|
133
159
|
return { operationId: token(o.operationId, "operationId"), requestDigest: digest(o.requestDigest), cause: pick(o.cause, CAUSES) };
|
|
134
160
|
}
|
|
161
|
+
function principalOf(raw) {
|
|
162
|
+
const o = closed(raw, ["owner", "actor", "lifecycleUid"]);
|
|
163
|
+
if (typeof o.actor !== "string")
|
|
164
|
+
bad("expected an actor");
|
|
165
|
+
return { owner: owner(o.owner), actor: grammar(() => assertBoundedOwner(o.actor, "actor")), lifecycleUid: token(o.lifecycleUid, "lifecycleUid") };
|
|
166
|
+
}
|
|
167
|
+
/** A grant body: the stored grant syntax with the status left out. */
|
|
168
|
+
function grantBody(raw) {
|
|
169
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.hasOwn(raw, "status"))
|
|
170
|
+
bad("expected a grant body");
|
|
171
|
+
const { status: _status, ...body } = grammar(() => parseServiceCallerGrant({ ...raw, status: { state: "active" } }));
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
const withStatus = (body, status) => ({ ...body, status });
|
|
135
175
|
function plan(raw) {
|
|
136
176
|
const o = raw;
|
|
137
177
|
switch (o?.kind) {
|
|
@@ -149,6 +189,27 @@ function plan(raw) {
|
|
|
149
189
|
case "revoke":
|
|
150
190
|
closed(raw, ["kind", "endpoint", "instanceId", "expectedAssignmentDigest", "cause"]);
|
|
151
191
|
return { kind: "revoke", endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"), expectedAssignmentDigest: digest(o.expectedAssignmentDigest), cause: pick(o.cause, CAUSES) };
|
|
192
|
+
case "caller-grant": {
|
|
193
|
+
closed(raw, ["kind", "grantee", "assignmentDigest", "approvalDigest", "nameAuthorityRevision", "expectedPolicyRevision", "request", "grant"]);
|
|
194
|
+
const p = { kind: "caller-grant", grantee: principalOf(o.grantee), assignmentDigest: digest(o.assignmentDigest), approvalDigest: digest(o.approvalDigest),
|
|
195
|
+
nameAuthorityRevision: ms(o.nameAuthorityRevision), expectedPolicyRevision: ms(o.expectedPolicyRevision),
|
|
196
|
+
request: structuredClone(o.request), grant: grantBody(o.grant) };
|
|
197
|
+
const src = p.grant.source;
|
|
198
|
+
if (src.kind !== "service-owner" || src.approvalDigest !== p.approvalDigest || src.nameAuthorityRevision !== p.nameAuthorityRevision
|
|
199
|
+
|| p.grant.endpoint !== p.request?.service?.endpoint || p.grant.instanceId !== p.request?.service?.instanceId || p.approvalDigest !== p.request?.service?.approvalDigest
|
|
200
|
+
|| canonicalJson(p.grantee) !== canonicalJson(p.request?.grantee) || p.expectedPolicyRevision !== p.request?.expectedPolicyRevision)
|
|
201
|
+
bad("the caller grant plan does not match its request");
|
|
202
|
+
return p;
|
|
203
|
+
}
|
|
204
|
+
case "caller-revoke": {
|
|
205
|
+
closed(raw, ["kind", "grantee", "grantId", "expectedPolicyRevision", "request", "grant"]);
|
|
206
|
+
const p = { kind: "caller-revoke", grantee: principalOf(o.grantee), grantId: hexN(o.grantId, 32), expectedPolicyRevision: ms(o.expectedPolicyRevision),
|
|
207
|
+
request: structuredClone(o.request), grant: grantBody(o.grant) };
|
|
208
|
+
if (p.grant.grantId !== p.grantId || p.grantId !== p.request?.grantId || canonicalJson(p.grantee) !== canonicalJson(p.request?.grantee)
|
|
209
|
+
|| p.expectedPolicyRevision !== p.request?.expectedPolicyRevision)
|
|
210
|
+
bad("the caller revoke plan does not match its request");
|
|
211
|
+
return p;
|
|
212
|
+
}
|
|
152
213
|
default: return bad("unknown plan");
|
|
153
214
|
}
|
|
154
215
|
}
|
|
@@ -172,6 +233,10 @@ function approvalResult(raw) {
|
|
|
172
233
|
approvedGateRevision: o.approvedGateRevision === null ? null : ms(o.approvedGateRevision) };
|
|
173
234
|
}
|
|
174
235
|
function result(raw, operation, decision) {
|
|
236
|
+
if (operation === "grant") {
|
|
237
|
+
const o = closed(raw, ["grant", "revision"]);
|
|
238
|
+
return { grant: grammar(() => parseServiceCallerGrant(o.grant)), revision: ms(o.revision) };
|
|
239
|
+
}
|
|
175
240
|
if (operation === "register") {
|
|
176
241
|
const o = closed(raw, ["proposalId", "revision", "expiresAtMs"]);
|
|
177
242
|
return { proposalId: hexN(o.proposalId, 64), revision: ms(o.revision), expiresAtMs: ms(o.expiresAtMs) };
|
|
@@ -203,20 +268,32 @@ const terminalDecision = (operation, outcome) => operation !== "decide" ? null :
|
|
|
203
268
|
export function parseServiceHostOperationRecord(raw) {
|
|
204
269
|
const o = raw;
|
|
205
270
|
const common = (x) => ({ operationId: token(x.operationId, "operationId"), requestId: hexN(x.requestId, 32), requestDigest: digest(x.requestDigest),
|
|
206
|
-
operation: pick(x.operation, ["register", "decide", "revoke"]), account: account(x.account), granter: granter(x.granter), admittedAtMs: ms(x.admittedAtMs), projection: projection(x.projection) });
|
|
271
|
+
operation: pick(x.operation, ["register", "decide", "revoke", "grant"]), account: account(x.account), granter: granter(x.granter), admittedAtMs: ms(x.admittedAtMs), projection: projection(x.projection) });
|
|
207
272
|
if (o?.kind === "service-host-operation") {
|
|
208
273
|
closed(raw, ["v", "kind", "operationId", "requestId", "requestDigest", "operation", "account", "granter", "admittedAtMs", "notAfterMs", "admissionEpoch", "projection", "plan", "progress", "debt"]);
|
|
209
274
|
if (o.v !== 1)
|
|
210
275
|
bad("unknown version");
|
|
211
276
|
const c = common(o), p = plan(o.plan);
|
|
212
|
-
if ((c.operation === "register") !== (p.kind === "register") || (c.operation === "revoke") !== (p.kind === "revoke")
|
|
277
|
+
if ((c.operation === "register") !== (p.kind === "register") || (c.operation === "revoke") !== (p.kind === "revoke" || p.kind === "caller-revoke")
|
|
278
|
+
|| (c.operation === "grant") !== (p.kind === "caller-grant"))
|
|
213
279
|
bad("plan does not match its operation");
|
|
280
|
+
// A caller plan carries its request's exact input; the record binds only if that input reproduces the digest.
|
|
281
|
+
if (p.kind === "caller-grant" || p.kind === "caller-revoke") {
|
|
282
|
+
const request = { v: 1, kind: "service-endpoint-host", requestId: c.requestId, operation: p.kind, input: p.request, granter: c.granter };
|
|
283
|
+
grammar(() => parseServiceHostRequest(request));
|
|
284
|
+
if (requestDigestOf(request) !== c.requestDigest)
|
|
285
|
+
bad("the stored request does not reproduce the request digest");
|
|
286
|
+
}
|
|
214
287
|
const g = o.progress;
|
|
215
288
|
let progress;
|
|
216
289
|
if (g?.state === "admitted") {
|
|
217
290
|
closed(g, ["state"]);
|
|
218
291
|
progress = { state: "admitted" };
|
|
219
292
|
}
|
|
293
|
+
else if (g?.state === "issued") {
|
|
294
|
+
closed(g, ["state", "step", "key", "atRevision"]);
|
|
295
|
+
progress = { state: "issued", step: word(g.step), key: word(g.key, 512), atRevision: ms(g.atRevision) };
|
|
296
|
+
}
|
|
220
297
|
else if (g?.state === "custody") {
|
|
221
298
|
closed(g, ["state", "step"]);
|
|
222
299
|
progress = { state: "custody", step: word(g.step) };
|
|
@@ -330,6 +407,46 @@ export function parseServiceAdmissionRecord(raw) {
|
|
|
330
407
|
bad("admission epochs start at 1");
|
|
331
408
|
return { v: 1, kind: "service-host-admission", state: pick(o.state, ["open", "quarantined"]), epoch, restoreId: o.restoreId === null ? null : hexN(o.restoreId, 32) };
|
|
332
409
|
}
|
|
410
|
+
export function parseServiceCallerIndexRecord(raw) {
|
|
411
|
+
const o = closed(raw, ["v", "kind", "entries"]);
|
|
412
|
+
if (o.v !== 1 || o.kind !== "service-caller-grant-index")
|
|
413
|
+
bad("unknown version or kind");
|
|
414
|
+
if (!Array.isArray(o.entries) || o.entries.length > S3_MAX_CALLER_GRANTS_PER_INSTANCE)
|
|
415
|
+
bad("invalid caller grant index");
|
|
416
|
+
const entries = o.entries.map((x) => { const e = closed(x, ["granteeUid", "grantId"]); return { granteeUid: token(e.granteeUid, "granteeUid"), grantId: hexN(e.grantId, 32) }; });
|
|
417
|
+
if (entries.some((e, i) => i > 0 && !(byEntry(entries[i - 1], e) < 0)))
|
|
418
|
+
bad("caller grant index entries must be unique and sorted");
|
|
419
|
+
return { v: 1, kind: "service-caller-grant-index", entries };
|
|
420
|
+
}
|
|
421
|
+
const byEntry = (a, b) => a.granteeUid < b.granteeUid ? -1 : a.granteeUid > b.granteeUid ? 1 : a.grantId < b.grantId ? -1 : a.grantId > b.grantId ? 1 : 0;
|
|
422
|
+
export function parseServiceCutoffMarkerRecord(raw) {
|
|
423
|
+
const o = closed(raw, ["v", "kind", "accountPublicKey", "space", "operationId", "target", "authorityDigest"]);
|
|
424
|
+
if (o.v !== 1 || o.kind !== "service-cutoff-marker")
|
|
425
|
+
bad("unknown version or kind");
|
|
426
|
+
const t = closed(o.target, ["kind", "grantee", "grantId", "endpoint", "instanceId"]);
|
|
427
|
+
pick(t.kind, ["caller-grant"]);
|
|
428
|
+
const a = account({ accountPublicKey: o.accountPublicKey, space: o.space });
|
|
429
|
+
return { v: 1, kind: "service-cutoff-marker", ...a, operationId: hexN(o.operationId, 32),
|
|
430
|
+
target: { kind: "caller-grant", grantee: principalOf(t.grantee), grantId: hexN(t.grantId, 32), endpoint: endpointName(t.endpoint), instanceId: token(t.instanceId, "instanceId") },
|
|
431
|
+
authorityDigest: digest(o.authorityDigest) };
|
|
432
|
+
}
|
|
433
|
+
/** ceil(T) in epoch seconds for the server's raw stored-message time, or undefined unless it is
|
|
434
|
+
* exactly `YYYY-MM-DDTHH:MM:SS[.d{1,9}]Z` naming a real calendar second. The whole second is
|
|
435
|
+
* formatted back and compared, so a normalized date or a leap second never passes, and any
|
|
436
|
+
* non-zero fractional digit rounds up. No double or Date carries the fraction. */
|
|
437
|
+
export function serviceMarkerCeilSeconds(raw) {
|
|
438
|
+
if (typeof raw !== "string")
|
|
439
|
+
return undefined;
|
|
440
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$/.exec(raw);
|
|
441
|
+
if (!m)
|
|
442
|
+
return undefined;
|
|
443
|
+
const at = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
|
|
444
|
+
if (!Number.isSafeInteger(at) || at < 0 || new Date(at).toISOString().slice(0, 19) !== raw.slice(0, 19))
|
|
445
|
+
return undefined;
|
|
446
|
+
return at / 1000 + (m[7] !== undefined && /[1-9]/.test(m[7]) ? 1 : 0);
|
|
447
|
+
}
|
|
448
|
+
/** The canonical cutoff deadline after the marker's time: ceil(T) + 60 + 2*5 + 1 seconds. */
|
|
449
|
+
export const SERVICE_CUTOFF_MARGIN_S = 71;
|
|
333
450
|
/** Leader-read one S2-A key. A deletion marker or spent tombstone holds under a key-specific
|
|
334
451
|
* reason and is never recreated; anything its closed parser refuses holds as malformed. */
|
|
335
452
|
async function read(ctx, key, what, parse) {
|
|
@@ -487,6 +604,18 @@ export function serviceHostScopeKeys(account, t) {
|
|
|
487
604
|
bad("a name binding scope names its endpoint");
|
|
488
605
|
auth.push(serviceNameKey(t.endpoint));
|
|
489
606
|
break;
|
|
607
|
+
// A caller operation writes its op, the grantee's policy row and the payload precondition's
|
|
608
|
+
// withdrawal, plus the instance index for a grant or the cutoff marker for a revoke. No custody key.
|
|
609
|
+
case "caller-grant":
|
|
610
|
+
if (t.endpoint === null || t.instanceId === null || t.granteeUid === undefined)
|
|
611
|
+
bad("a caller grant scope names its selector and grantee");
|
|
612
|
+
auth.push(opKeyOf(), servicePolicyKey(t.granteeUid), callerIndexKey(t.endpoint, t.instanceId), SERVICE_CALLER_PAYLOAD_KEY);
|
|
613
|
+
break;
|
|
614
|
+
case "caller-revoke":
|
|
615
|
+
if (t.granteeUid === undefined || t.cutoffOperationId === undefined)
|
|
616
|
+
bad("a caller revoke scope names its grantee and cutoff operation");
|
|
617
|
+
auth.push(opKeyOf(), servicePolicyKey(t.granteeUid), cutoffKey(t.cutoffOperationId), SERVICE_CALLER_PAYLOAD_KEY);
|
|
618
|
+
break;
|
|
490
619
|
case "serve": {
|
|
491
620
|
// A serving request writes its intent and its instance's index, then only its own effect's keys.
|
|
492
621
|
if (t.endpoint === null || t.instanceId === null || t.serve === undefined)
|
|
@@ -599,11 +728,10 @@ export async function openServiceHostOperations(opts) {
|
|
|
599
728
|
/** One invocation: an effect connection for the discovered scope and its original deadline, one
|
|
600
729
|
* custody host over it sharing the process hold and writing only the scope's keys, and the
|
|
601
730
|
* engine latch. The connection's loss trips the host. Every awaited call returns before close. */
|
|
602
|
-
async function invoke(target, run) {
|
|
731
|
+
async function invoke(target, run, deadlineMs = Date.now() + S2A_INVOCATION_MS) {
|
|
603
732
|
if (tripped !== undefined)
|
|
604
733
|
throw new Held("readiness", "open", `the service host is fenced: ${tripped}`);
|
|
605
734
|
const keys = serviceHostScopeKeys({ accountPublicKey, space }, target), reads = serviceHostScopeReads(target);
|
|
606
|
-
const deadlineMs = Date.now() + S2A_INVOCATION_MS;
|
|
607
735
|
const scope = Object.freeze({ ...target, keys, reads, deadlineMs });
|
|
608
736
|
let effect;
|
|
609
737
|
try {
|
|
@@ -1017,7 +1145,7 @@ export async function openServiceHostOperations(opts) {
|
|
|
1017
1145
|
if (name.revision !== req.input.expectedNameRevision)
|
|
1018
1146
|
return out(denied("conflict"));
|
|
1019
1147
|
const ceiling = parseEndpointCapabilities(req.input.callerCeiling);
|
|
1020
|
-
if (ceiling.some((c) =>
|
|
1148
|
+
if (ceiling.some((c) => !ceilingEntryAdmitted(c, p)))
|
|
1021
1149
|
return out(denied("bad-request"));
|
|
1022
1150
|
const reservation = { v: 1, kind: "native-service-uid", native: { accountPublicKey, space, lifecycleUid },
|
|
1023
1151
|
assignment: { owner: p.owner, actor: serviceEndpointActor({ accountPublicKey, space, owner: p.owner, endpoint: p.endpoint, instanceId: p.instanceId }),
|
|
@@ -1467,6 +1595,657 @@ export async function openServiceHostOperations(opts) {
|
|
|
1467
1595
|
}
|
|
1468
1596
|
}
|
|
1469
1597
|
}
|
|
1598
|
+
// ---- caller policy: grant, revoke and the policy reads ------------------------------------------
|
|
1599
|
+
const readPolicy = (ctx, uid) => read(ctx, servicePolicyKey(uid), "policy", parseNativeServiceCallerPolicy);
|
|
1600
|
+
const readCallerIndex = (ctx, e, i) => read(ctx, callerIndexKey(e, i), "caller-index", parseServiceCallerIndexRecord);
|
|
1601
|
+
const readPayload = (ctx) => read(ctx, SERVICE_CALLER_PAYLOAD_KEY, "payload", parseServiceCallerPayloadRecord);
|
|
1602
|
+
/** Whether a row at a grantee's key is bound to that grantee in this account and space. */
|
|
1603
|
+
const boundTo = (row, g) => row.accountPublicKey === accountPublicKey && row.space === space && canonicalJson(row.grantee) === canonicalJson(g);
|
|
1604
|
+
const emptyPolicy = (g) => ({ v: 1, accountPublicKey, space, grantee: g, grants: [] });
|
|
1605
|
+
/** This connection's live limits: its server's `max_payload` and the auth stream's `max_msg_size`. */
|
|
1606
|
+
async function payloadLimits(ctx) {
|
|
1607
|
+
const maxPayload = registryMaxPayload(ctx.h.registry);
|
|
1608
|
+
const r = await bounded(ctx, () => ctx.jsm.streams.info(`KV_${ctx.bucket.auth}`));
|
|
1609
|
+
const size = r.ok ? r.value.config.max_msg_size : undefined;
|
|
1610
|
+
return { maxPayload: Number.isSafeInteger(maxPayload) ? maxPayload : undefined, authMaxMsgSize: Number.isSafeInteger(size) ? size : undefined };
|
|
1611
|
+
}
|
|
1612
|
+
/** Withdraw an asserted payload precondition after a failed live check or a write that would not
|
|
1613
|
+
* fit; only an operator's assertion records it again. A withdrawal whose own write does not land
|
|
1614
|
+
* is made again by the next writer that sees the same failure. */
|
|
1615
|
+
async function withdrawPayload(ctx, rec, reason, operationId) {
|
|
1616
|
+
if (rec.state !== "value" || rec.row.state !== "asserted")
|
|
1617
|
+
return;
|
|
1618
|
+
const step = ctx.step;
|
|
1619
|
+
try {
|
|
1620
|
+
await put(ctx, SERVICE_CALLER_PAYLOAD_KEY, { v: 1, kind: "service-caller-payload", state: "withdrawn", reason, operationId, atMs: Date.now() }, parseServiceCallerPayloadRecord, rec);
|
|
1621
|
+
}
|
|
1622
|
+
catch (e) {
|
|
1623
|
+
if (!(e instanceof Held))
|
|
1624
|
+
throw e;
|
|
1625
|
+
}
|
|
1626
|
+
finally {
|
|
1627
|
+
ctx.step = step;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
/** Whether the payload precondition holds now, from its durable record and this connection's live limits. */
|
|
1631
|
+
async function payloadHolds(ctx, operationId) {
|
|
1632
|
+
const rec = await readPayload(ctx), check = serviceCallerPayloadCheck(rec.state === "value" ? rec.row : undefined, await payloadLimits(ctx));
|
|
1633
|
+
if (check.holds && rec.state === "value" && rec.row.state === "asserted" && rec.row.broker) {
|
|
1634
|
+
const broker = rec.row.broker, proof = genuinePlaneHold(opts.hold);
|
|
1635
|
+
if (!proof || broker.serverId !== registryServerId(ctx.h.registry))
|
|
1636
|
+
return false;
|
|
1637
|
+
const observed = await bounded(ctx, () => readPlaneClaim(registryStores(ctx.h.registry).authKv, space));
|
|
1638
|
+
if (!observed.ok || !observed.value || observed.value.state !== "held" || observed.value.claimId !== broker.claimId || observed.value.generation !== broker.claimGeneration)
|
|
1639
|
+
return false;
|
|
1640
|
+
const held = await bounded(ctx, () => proof.assertHeld("after"));
|
|
1641
|
+
if (!held.ok)
|
|
1642
|
+
return false;
|
|
1643
|
+
}
|
|
1644
|
+
if (!check.holds && (check.why === "server-limit" || check.why === "stream-limit"))
|
|
1645
|
+
await withdrawPayload(ctx, rec, "live-check", operationId);
|
|
1646
|
+
return check.holds;
|
|
1647
|
+
}
|
|
1648
|
+
/** Before a policy row write: a row that would not fit the live limits is never issued. The step
|
|
1649
|
+
* holds `payload-precondition`, a known non-issue, never an unknown outcome. */
|
|
1650
|
+
async function assertWriteFits(ctx, row, operationId) {
|
|
1651
|
+
if (serviceCallerWriteFits(encodeRoundTrip(row, parseNativeServiceCallerPolicy).length, await payloadLimits(ctx)))
|
|
1652
|
+
return;
|
|
1653
|
+
await withdrawPayload(ctx, await readPayload(ctx), "payload-precondition", operationId);
|
|
1654
|
+
held("payload-precondition", ctx.step, "the policy row write would not fit the live payload limits; nothing was issued");
|
|
1655
|
+
}
|
|
1656
|
+
/** Containment within the approval's ceiling image for grantee G. The image keeps only entries on
|
|
1657
|
+
* this endpoint pinned to this instance with `instanceOnly`, and drops `any` and `ledger` entries
|
|
1658
|
+
* (no operator target policy is wired) and `owner` or `child` entries for another owner, which can
|
|
1659
|
+
* never cover G. Mode filtering of stored entries waits on grant-time command declarations. A
|
|
1660
|
+
* lowering throw counts as not contained. */
|
|
1661
|
+
const withinCeiling = (ceiling, capabilities, g, sel) => {
|
|
1662
|
+
const image = ceiling.filter((c) => {
|
|
1663
|
+
const t = c.target;
|
|
1664
|
+
return c.endpoint === sel.endpoint && c.instanceId === sel.instanceId && c.instanceOnly === true && t?.mode !== "any" && t?.mode !== "ledger"
|
|
1665
|
+
&& !((t?.mode === "owner" || t?.mode === "child") && t.tOwner !== g.owner);
|
|
1666
|
+
});
|
|
1667
|
+
const caller = { owner: g.owner, actor: g.actor, uid: g.lifecycleUid };
|
|
1668
|
+
try {
|
|
1669
|
+
return endpointCapabilitiesWithin({ space, child: { caller, capabilities }, parent: { caller, capabilities: image } });
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
const grantedArm = (r) => { const g = r; return { state: "granted", grant: structuredClone(g.grant), revision: g.revision }; };
|
|
1676
|
+
/** caller-grant (G1-G6): an interactive owner grants a current exact-lifecycle grantee rights on
|
|
1677
|
+
* its own approved instance, within the approval's ceiling and covering managed ancestry. A grant
|
|
1678
|
+
* records policy only; the separately composed callout establishes effective authority. */
|
|
1679
|
+
async function callerGrant(req, authorized) {
|
|
1680
|
+
// Authorization precedes every request-key read, and its refusal is one answer whether or not the request is bound.
|
|
1681
|
+
if (!authorized)
|
|
1682
|
+
return unread(req.requestId, "permission-denied", "authorize");
|
|
1683
|
+
const operationId = serviceHostOperationId(space, req.requestId), requestDigest = requestDigestOf(req);
|
|
1684
|
+
const granterOwner = req.granter.kind === "owner" ? req.granter.owner : badRequest("a caller grant requires an owner decision");
|
|
1685
|
+
const { grantee, service, expectedPolicyRevision } = req.input;
|
|
1686
|
+
// The scope comes from the input alone, so a grant has no discovery read.
|
|
1687
|
+
const scope = { operation: "caller-grant", operationId, proposalId: null, owner: granterOwner, endpoint: service.endpoint,
|
|
1688
|
+
instanceId: service.instanceId, lifecycleUid: null, granteeUid: grantee.lifecycleUid };
|
|
1689
|
+
try {
|
|
1690
|
+
return await invoke(scope, async (inv) => {
|
|
1691
|
+
inv.operationId = operationId;
|
|
1692
|
+
try {
|
|
1693
|
+
const ctx = readCtx(inv, "G1-binding");
|
|
1694
|
+
for (let n = 0;; n++) {
|
|
1695
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
1696
|
+
held("contention", ctx.step);
|
|
1697
|
+
const op = await readOp(ctx, operationId);
|
|
1698
|
+
if (op.state === "value")
|
|
1699
|
+
return op.row.requestDigest === requestDigest ? await advanceGrant(inv, op.row) : denied("conflict");
|
|
1700
|
+
inv.bound = false;
|
|
1701
|
+
const admitted = await admitGrant(inv, ctx);
|
|
1702
|
+
if (admitted.kind !== "service-host-operation")
|
|
1703
|
+
return admitted.out;
|
|
1704
|
+
// The create may be issued from here, so a held step names the operation.
|
|
1705
|
+
inv.bound = true;
|
|
1706
|
+
if ((await put(ctx, opKey(operationId), admitted, parseServiceHostOperationRecord)) !== "moved")
|
|
1707
|
+
return await advanceGrant(inv, admitted);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
catch (err) {
|
|
1711
|
+
if (err instanceof Held)
|
|
1712
|
+
return heldArm(inv, err);
|
|
1713
|
+
throw err;
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
if (err instanceof Held)
|
|
1719
|
+
return unread(req.requestId, err.reason, err.step);
|
|
1720
|
+
throw err;
|
|
1721
|
+
}
|
|
1722
|
+
/** G2: the payload precondition, then each eligibility check from one leader read, point in
|
|
1723
|
+
* time. Every refusal is a zero-write denial; the only write before binding is the precondition's
|
|
1724
|
+
* withdrawal after a failed live check. */
|
|
1725
|
+
async function admitGrant(inv, ctx) {
|
|
1726
|
+
const out = (o) => ({ kind: "out", out: o });
|
|
1727
|
+
ctx.step = "G2-precondition";
|
|
1728
|
+
if (!(await payloadHolds(ctx, null)))
|
|
1729
|
+
return out(denied("unsupported"));
|
|
1730
|
+
ctx.step = "G2-eligibility";
|
|
1731
|
+
const adm = await readAdmission(ctx);
|
|
1732
|
+
if (adm.state === "absent" || adm.row.state !== "open")
|
|
1733
|
+
return out(denied("admission-closed"));
|
|
1734
|
+
let row;
|
|
1735
|
+
try {
|
|
1736
|
+
row = findActorUnified(opts.ledgerDir, grantee.owner, grantee.actor);
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
return out(denied("not-found"));
|
|
1740
|
+
}
|
|
1741
|
+
if (row === undefined)
|
|
1742
|
+
return out(denied("not-found"));
|
|
1743
|
+
if (row.kind === "interactive" && row.parent !== undefined)
|
|
1744
|
+
return out(denied("unsupported"));
|
|
1745
|
+
if (row.kind === "managed-agent" && row.parent === undefined)
|
|
1746
|
+
return out(denied("unsupported"));
|
|
1747
|
+
if (row.lifecycleUid !== grantee.lifecycleUid)
|
|
1748
|
+
return out(denied("not-found"));
|
|
1749
|
+
const asg = await readAssignment(ctx, service.endpoint, service.instanceId);
|
|
1750
|
+
if (asg.state === "absent" || asg.row.owner !== granterOwner || asg.row.state !== "bound" || asg.row.cancel !== null)
|
|
1751
|
+
return out(denied("not-found"));
|
|
1752
|
+
const a = asg.row;
|
|
1753
|
+
const gate = await endpoint(ctx, serviceAssignmentGate(inv.host, a.reservation));
|
|
1754
|
+
if (gate.kind !== "approved" || gate.row.serviceAuthority.approvalDigest !== service.approvalDigest)
|
|
1755
|
+
return out(denied("conflict"));
|
|
1756
|
+
const now = Date.now();
|
|
1757
|
+
if (!serviceGateApprovalLive(gate.row.serviceAuthority, seconds(now)))
|
|
1758
|
+
return out(denied("expired"));
|
|
1759
|
+
const name = await nameOf(ctx, service.endpoint);
|
|
1760
|
+
if (name === undefined || name.owner !== granterOwner || name.revision !== gate.row.nameAuthorityRevision)
|
|
1761
|
+
return out(denied("conflict"));
|
|
1762
|
+
const capabilities = parseEndpointCapabilities(req.input.capabilities);
|
|
1763
|
+
if (capabilities.length === 0 || capabilities.some((c) => c.endpoint !== service.endpoint || c.instanceId !== service.instanceId || c.instanceOnly !== true))
|
|
1764
|
+
return out(denied("bad-request"));
|
|
1765
|
+
// A journal row has no instance segment, so it would reach the command's journal on every instance.
|
|
1766
|
+
if (capabilities.some((c) => c.journal === true))
|
|
1767
|
+
return out(denied("unsupported"));
|
|
1768
|
+
if (capabilities.some((c) => c.target?.mode === "any" || c.target?.mode === "ledger"))
|
|
1769
|
+
return out(denied("permission-denied"));
|
|
1770
|
+
if (!withinCeiling(gate.row.serviceAuthority.callerCeiling, capabilities, grantee, service))
|
|
1771
|
+
return out(denied("permission-denied"));
|
|
1772
|
+
let parentRefs;
|
|
1773
|
+
try {
|
|
1774
|
+
parentRefs = await callerParentRefs({ space, ledgerDir: opts.ledgerDir, grantee, capabilities, service,
|
|
1775
|
+
readPolicy: async (p) => {
|
|
1776
|
+
const read = await readPolicy(ctx, p.lifecycleUid);
|
|
1777
|
+
if (read.state === "absent")
|
|
1778
|
+
return undefined;
|
|
1779
|
+
if (!boundTo(read.row, p))
|
|
1780
|
+
throw new EpEnvelopeError("permission-denied", "parent policy has another exact principal");
|
|
1781
|
+
return read.row;
|
|
1782
|
+
} });
|
|
1783
|
+
}
|
|
1784
|
+
catch (e) {
|
|
1785
|
+
if (e instanceof EpEnvelopeError)
|
|
1786
|
+
return out(denied("permission-denied"));
|
|
1787
|
+
throw e;
|
|
1788
|
+
}
|
|
1789
|
+
const policy = await readPolicy(ctx, grantee.lifecycleUid);
|
|
1790
|
+
if (policy.state === "value" && !boundTo(policy.row, grantee))
|
|
1791
|
+
return out(denied("conflict"));
|
|
1792
|
+
if ((policy.state === "value" ? policy.revision : 0) !== expectedPolicyRevision)
|
|
1793
|
+
return out(denied("conflict"));
|
|
1794
|
+
const body = { grantId: callerGrantId(space, operationId), endpoint: service.endpoint, instanceId: service.instanceId,
|
|
1795
|
+
serviceApprovalDigest: service.approvalDigest, capabilities,
|
|
1796
|
+
source: { kind: "service-owner", owner: granterOwner, approvalDigest: service.approvalDigest, nameAuthorityRevision: gate.row.nameAuthorityRevision }, parentRefs };
|
|
1797
|
+
const step = callerAppendStep(policy.state === "value" ? { row: policy.row, revision: policy.revision } : undefined, emptyPolicy(grantee), expectedPolicyRevision, withStatus(body, { state: "active" }));
|
|
1798
|
+
if (step.kind === "headroom")
|
|
1799
|
+
return out(denied("resource-exhausted"));
|
|
1800
|
+
if (step.kind !== "write")
|
|
1801
|
+
return out(denied("conflict"));
|
|
1802
|
+
const index = await readCallerIndex(ctx, service.endpoint, service.instanceId);
|
|
1803
|
+
if (index.state === "value" && index.row.entries.length >= S3_MAX_CALLER_GRANTS_PER_INSTANCE)
|
|
1804
|
+
return out(denied("resource-exhausted"));
|
|
1805
|
+
ctx.step = "G3-binding";
|
|
1806
|
+
return { v: 1, kind: "service-host-operation", operationId, requestId: req.requestId, requestDigest, operation: "grant", account: { accountPublicKey, space },
|
|
1807
|
+
granter: req.granter, admittedAtMs: now, notAfterMs: now + S2A_OPERATION_TTL_MS, admissionEpoch: adm.row.epoch,
|
|
1808
|
+
projection: { subjectOwner: granterOwner, endpoint: service.endpoint, instanceId: service.instanceId, proposalId: a.proposalId },
|
|
1809
|
+
plan: { kind: "caller-grant", grantee, assignmentDigest: a.assignmentDigest, approvalDigest: service.approvalDigest,
|
|
1810
|
+
nameAuthorityRevision: gate.row.nameAuthorityRevision, expectedPolicyRevision, request: req.input, grant: body },
|
|
1811
|
+
progress: { state: "admitted" }, debt: [] };
|
|
1812
|
+
}
|
|
1813
|
+
/** G4-G6 from the stored plan. A positive write is recorded `issued` first; past `notAfterMs`
|
|
1814
|
+
* a replay makes no positive write and settles from one read of the policy row. */
|
|
1815
|
+
async function advanceGrant(inv, first) {
|
|
1816
|
+
const ctx = readCtx(inv, "G-replay");
|
|
1817
|
+
const stored = outcomeOf(first);
|
|
1818
|
+
if (stored !== undefined)
|
|
1819
|
+
return stored.state === "complete" ? grantedArm(stored.result) : finalArm(operationId, stored, debtOf(first));
|
|
1820
|
+
let op = first;
|
|
1821
|
+
const p = op.plan, uid = p.grantee.lifecycleUid, candidate = withStatus(p.grant, { state: "active" });
|
|
1822
|
+
const view = immutableGrantView(candidate), indexKey = callerIndexKey(p.grant.endpoint, p.grant.instanceId), policyKey = servicePolicyKey(uid);
|
|
1823
|
+
const mine = (e) => e.granteeUid === uid && e.grantId === p.grant.grantId;
|
|
1824
|
+
const finish = async (f) => {
|
|
1825
|
+
const s = await settle(ctx, operationId, f, []), o = outcomeOf(s.op);
|
|
1826
|
+
return o.state === "complete" ? grantedArm(o.result) : finalArm(operationId, o, debtOf(s.op));
|
|
1827
|
+
};
|
|
1828
|
+
const refuse = (reason) => finish({ state: "refused", reason });
|
|
1829
|
+
const adopt = (grant, revision) => finish({ state: "complete", result: { grant, revision } });
|
|
1830
|
+
/** Move the live progress from what this invocation last saw; another invocation's record holds. */
|
|
1831
|
+
const progressTo = async (next) => {
|
|
1832
|
+
const seen = canonicalJson(op.progress), want = canonicalJson(next);
|
|
1833
|
+
const r = await updateOp(ctx, operationId, (cur) => {
|
|
1834
|
+
const now = canonicalJson(cur.progress);
|
|
1835
|
+
if (isFinal(cur.progress) || now === want)
|
|
1836
|
+
return undefined;
|
|
1837
|
+
if (now !== seen)
|
|
1838
|
+
held("contention", ctx.step, "another invocation advanced this operation");
|
|
1839
|
+
return { ...cur, progress: next };
|
|
1840
|
+
});
|
|
1841
|
+
if (r.op.kind !== "service-host-operation" || isFinal(r.op.progress))
|
|
1842
|
+
held("contention", ctx.step, "the operation settled under another invocation");
|
|
1843
|
+
op = r.op;
|
|
1844
|
+
};
|
|
1845
|
+
/** Record the write as issued, never with too little of the authority window left to start it:
|
|
1846
|
+
* an open record is settled only by a later read, so one without its write holds until the key moves. */
|
|
1847
|
+
const issue = async (step, key, atRevision) => {
|
|
1848
|
+
if (Date.now() + 2 * S1_REQUEST_TIMEOUT_MS >= Math.min(inv.endMs, op.notAfterMs))
|
|
1849
|
+
held("deadline", step, "too little of the authority window remains to issue the write");
|
|
1850
|
+
await progressTo({ state: "issued", step, key, atRevision });
|
|
1851
|
+
};
|
|
1852
|
+
/** The grant in the row from one read: present with this plan's immutable view, or absent. Anything else holds. */
|
|
1853
|
+
const lookup = async () => {
|
|
1854
|
+
const pol = await readPolicy(ctx, uid);
|
|
1855
|
+
if (pol.state === "value" && !boundTo(pol.row, p.grantee))
|
|
1856
|
+
held("witness-mismatch", ctx.step, "the policy row at the grantee key is bound to another principal");
|
|
1857
|
+
const grant = pol.state === "value" ? pol.row.grants.find((x) => x.grantId === p.grant.grantId) : undefined;
|
|
1858
|
+
if (grant !== undefined && immutableGrantView(grant) !== view)
|
|
1859
|
+
held("witness-mismatch", ctx.step, "the stored grant differs from the operation's witness");
|
|
1860
|
+
return { pol, grant, revision: pol.state === "value" ? pol.revision : 0 };
|
|
1861
|
+
};
|
|
1862
|
+
// An open issued write: one read of its own key settles it, or its outcome stays unknown.
|
|
1863
|
+
if (op.progress.state === "issued") {
|
|
1864
|
+
const iss = op.progress;
|
|
1865
|
+
ctx.step = iss.step;
|
|
1866
|
+
if (iss.key === policyKey) {
|
|
1867
|
+
const found = await lookup();
|
|
1868
|
+
if (found.grant !== undefined)
|
|
1869
|
+
return adopt(found.grant, found.revision);
|
|
1870
|
+
if (found.revision <= iss.atRevision)
|
|
1871
|
+
held("outcome-unknown", ctx.step, "the issued append's outcome stays unknown until its key moves");
|
|
1872
|
+
await progressTo({ state: "custody", step: "G5-not-landed" });
|
|
1873
|
+
}
|
|
1874
|
+
else {
|
|
1875
|
+
const idx = await readCallerIndex(ctx, p.grant.endpoint, p.grant.instanceId);
|
|
1876
|
+
const landed = idx.state === "value" && idx.row.entries.some(mine);
|
|
1877
|
+
if (!landed && (idx.state === "value" ? idx.revision : 0) <= iss.atRevision)
|
|
1878
|
+
held("outcome-unknown", ctx.step, "the issued index write's outcome stays unknown until its key moves");
|
|
1879
|
+
await progressTo({ state: "custody", step: landed ? "G4-indexed" : "G4-not-landed" });
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
// Past the operation's bound: no positive write. Adopt a landed grant, else the attempt expired.
|
|
1883
|
+
if (Date.now() >= op.notAfterMs) {
|
|
1884
|
+
ctx.step = "G-expired";
|
|
1885
|
+
const found = await lookup();
|
|
1886
|
+
return found.grant !== undefined ? adopt(found.grant, found.revision) : refuse("expired");
|
|
1887
|
+
}
|
|
1888
|
+
// A bound grant whose precondition stopped holding holds before G4 or G5; nothing is issued.
|
|
1889
|
+
ctx.step = "G4-precondition";
|
|
1890
|
+
if (!(await payloadHolds(ctx, operationId)))
|
|
1891
|
+
held("payload-precondition", ctx.step, "the payload precondition does not hold; nothing was issued");
|
|
1892
|
+
for (let n = 0;; n++) {
|
|
1893
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
1894
|
+
held("contention", "G4-index");
|
|
1895
|
+
ctx.step = "G4-index";
|
|
1896
|
+
const idx = await readCallerIndex(ctx, p.grant.endpoint, p.grant.instanceId);
|
|
1897
|
+
if (idx.state === "value" && idx.row.entries.some(mine)) {
|
|
1898
|
+
if (op.progress.state === "issued")
|
|
1899
|
+
await progressTo({ state: "custody", step: "G4-indexed" });
|
|
1900
|
+
break;
|
|
1901
|
+
}
|
|
1902
|
+
if (idx.state === "value" && idx.row.entries.length >= S3_MAX_CALLER_GRANTS_PER_INSTANCE)
|
|
1903
|
+
return refuse("resource-exhausted");
|
|
1904
|
+
const entries = [...(idx.state === "value" ? idx.row.entries : []), { granteeUid: uid, grantId: p.grant.grantId }].sort(byEntry);
|
|
1905
|
+
await issue("G4-index", indexKey, idx.state === "value" ? idx.revision : 0);
|
|
1906
|
+
const wrote = await put(authorityCtx(inv, op.notAfterMs, "G4-index"), indexKey, { v: 1, kind: "service-caller-grant-index", entries }, parseServiceCallerIndexRecord, idx.state === "value" ? idx : undefined);
|
|
1907
|
+
if (wrote !== "moved") {
|
|
1908
|
+
await progressTo({ state: "custody", step: "G4-indexed" });
|
|
1909
|
+
break;
|
|
1910
|
+
}
|
|
1911
|
+
// A lost write: the next read of the same key settles it before anything is issued again.
|
|
1912
|
+
}
|
|
1913
|
+
// G5: the grant may already be in the row; otherwise re-check the approval's authority, then append.
|
|
1914
|
+
ctx.step = "G5-recheck";
|
|
1915
|
+
const found = await lookup();
|
|
1916
|
+
if (found.grant !== undefined)
|
|
1917
|
+
return adopt(found.grant, found.revision);
|
|
1918
|
+
const asg = await readAssignment(ctx, p.grant.endpoint, p.grant.instanceId);
|
|
1919
|
+
if (asg.state === "absent" || asg.row.assignmentDigest !== p.assignmentDigest || asg.row.owner !== op.projection.subjectOwner)
|
|
1920
|
+
return refuse("conflict");
|
|
1921
|
+
if (asg.row.state === "fenced" || asg.row.cancel !== null)
|
|
1922
|
+
return refuse("revoked");
|
|
1923
|
+
const gate = await endpoint(ctx, serviceAssignmentGate(inv.host, asg.row.reservation));
|
|
1924
|
+
if (gate.kind === "revoked")
|
|
1925
|
+
return refuse("revoked");
|
|
1926
|
+
if (gate.kind !== "approved" || gate.row.serviceAuthority.approvalDigest !== p.approvalDigest || gate.row.nameAuthorityRevision !== p.nameAuthorityRevision)
|
|
1927
|
+
return refuse("conflict");
|
|
1928
|
+
if (!serviceGateApprovalLive(gate.row.serviceAuthority, seconds(Date.now())))
|
|
1929
|
+
return refuse("expired");
|
|
1930
|
+
const name = await nameOf(ctx, p.grant.endpoint);
|
|
1931
|
+
if (name === undefined || name.owner !== op.projection.subjectOwner || name.revision !== p.nameAuthorityRevision)
|
|
1932
|
+
return refuse("conflict");
|
|
1933
|
+
try {
|
|
1934
|
+
const refs = await callerParentRefs({ space, ledgerDir: opts.ledgerDir, grantee: p.grantee, capabilities: p.grant.capabilities, expectedRefs: p.grant.parentRefs,
|
|
1935
|
+
service: { endpoint: p.grant.endpoint, instanceId: p.grant.instanceId, approvalDigest: p.approvalDigest },
|
|
1936
|
+
readPolicy: async (parent) => {
|
|
1937
|
+
const read = await readPolicy(ctx, parent.lifecycleUid);
|
|
1938
|
+
if (read.state === "absent")
|
|
1939
|
+
return undefined;
|
|
1940
|
+
if (!boundTo(read.row, parent))
|
|
1941
|
+
throw new EpEnvelopeError("permission-denied", "parent policy binding changed");
|
|
1942
|
+
return read.row;
|
|
1943
|
+
} });
|
|
1944
|
+
if (canonicalJson(refs) !== canonicalJson(p.grant.parentRefs))
|
|
1945
|
+
return refuse("revoked");
|
|
1946
|
+
}
|
|
1947
|
+
catch (e) {
|
|
1948
|
+
if (e instanceof EpEnvelopeError)
|
|
1949
|
+
return refuse("revoked");
|
|
1950
|
+
throw e;
|
|
1951
|
+
}
|
|
1952
|
+
const step = callerAppendStep(found.pol.state === "value" ? { row: found.pol.row, revision: found.pol.revision } : undefined, emptyPolicy(p.grantee), p.expectedPolicyRevision, candidate);
|
|
1953
|
+
if (step.kind === "moved")
|
|
1954
|
+
return refuse("conflict");
|
|
1955
|
+
if (step.kind === "headroom")
|
|
1956
|
+
return refuse("resource-exhausted");
|
|
1957
|
+
if (step.kind !== "write")
|
|
1958
|
+
held("witness-mismatch", ctx.step, "the stored grant differs from the operation's witness");
|
|
1959
|
+
ctx.step = "G5-precondition";
|
|
1960
|
+
if (!(await payloadHolds(ctx, operationId)))
|
|
1961
|
+
held("payload-precondition", ctx.step, "the payload precondition does not hold; nothing was issued");
|
|
1962
|
+
await issue("G5-append", policyKey, p.expectedPolicyRevision);
|
|
1963
|
+
const wrote = await put(authorityCtx(inv, op.notAfterMs, "G5-append"), policyKey, step.row, parseNativeServiceCallerPolicy, found.pol.state === "value" ? found.pol : undefined);
|
|
1964
|
+
if (wrote !== "moved")
|
|
1965
|
+
return adopt(candidate, wrote);
|
|
1966
|
+
ctx.step = "G5-append";
|
|
1967
|
+
const after = await lookup();
|
|
1968
|
+
if (after.grant !== undefined)
|
|
1969
|
+
return adopt(after.grant, after.revision);
|
|
1970
|
+
if (after.revision > p.expectedPolicyRevision)
|
|
1971
|
+
return refuse("conflict");
|
|
1972
|
+
return held("outcome-unknown", ctx.step, "the issued append's outcome stays unknown until its key moves");
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/** caller-revoke (K0-K3): deny-new, the op-bound cutoff marker and its fixed deadline. It answers
|
|
1976
|
+
* `pending` and never completes here: a terminal caller revocation needs the post-cutoff receipt
|
|
1977
|
+
* inventory and absence proof this host does not build. */
|
|
1978
|
+
async function callerRevoke(req, authorized) {
|
|
1979
|
+
if (!authorized)
|
|
1980
|
+
return unread(req.requestId, "permission-denied", "authorize");
|
|
1981
|
+
const operationId = serviceHostOperationId(space, req.requestId), requestDigest = requestDigestOf(req);
|
|
1982
|
+
const { grantee, grantId, expectedPolicyRevision } = req.input;
|
|
1983
|
+
const scope = { operation: "caller-revoke", operationId, proposalId: null, owner: null, endpoint: null, instanceId: null,
|
|
1984
|
+
lifecycleUid: null, granteeUid: grantee.lifecycleUid, cutoffOperationId: operationId };
|
|
1985
|
+
let found;
|
|
1986
|
+
// Discovery, read only: the cutoff operation is the one the grant's revoking status names, else this request's own.
|
|
1987
|
+
try {
|
|
1988
|
+
const dctx = discoveryCtx("discover");
|
|
1989
|
+
found = await readOp(dctx, operationId);
|
|
1990
|
+
const pol = await readPolicy(dctx, grantee.lifecycleUid);
|
|
1991
|
+
const g = pol.state === "value" ? pol.row.grants.find((x) => x.grantId === grantId) : undefined;
|
|
1992
|
+
if (g !== undefined && g.status.state !== "active")
|
|
1993
|
+
scope.cutoffOperationId = g.status.operationId;
|
|
1994
|
+
}
|
|
1995
|
+
catch (err) {
|
|
1996
|
+
if (err instanceof Held)
|
|
1997
|
+
return discoveryHeld(operationId, requestDigest, found, err);
|
|
1998
|
+
throw err;
|
|
1999
|
+
}
|
|
2000
|
+
const deadlineMs = Date.now() + S2A_INVOCATION_MS;
|
|
2001
|
+
try {
|
|
2002
|
+
const first = await invoke(scope, (inv) => attempt(inv, scope.cutoffOperationId), deadlineMs);
|
|
2003
|
+
if (!("rescope" in first))
|
|
2004
|
+
return first;
|
|
2005
|
+
// Another operation landed deny-new first. Every write this invocation issued had a known
|
|
2006
|
+
// outcome and its effect closed; reopen once, under the same deadline, for that operation's marker.
|
|
2007
|
+
if (Date.now() >= deadlineMs)
|
|
2008
|
+
return { state: "held", operationId, reason: "reconcile-pending", phase: "K1-rescope" };
|
|
2009
|
+
const second = await invoke({ ...scope, cutoffOperationId: first.rescope }, (inv) => attempt(inv, first.rescope), deadlineMs);
|
|
2010
|
+
return "rescope" in second ? { state: "held", operationId, reason: "scope-mismatch", phase: "K1-rescope" } : second;
|
|
2011
|
+
}
|
|
2012
|
+
catch (err) {
|
|
2013
|
+
if (err instanceof Held)
|
|
2014
|
+
return discoveryHeld(operationId, requestDigest, found, err);
|
|
2015
|
+
throw err;
|
|
2016
|
+
}
|
|
2017
|
+
async function attempt(inv, cutoff) {
|
|
2018
|
+
inv.operationId = operationId;
|
|
2019
|
+
try {
|
|
2020
|
+
const ctx = readCtx(inv, "K0-binding");
|
|
2021
|
+
for (let n = 0;; n++) {
|
|
2022
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
2023
|
+
held("contention", ctx.step);
|
|
2024
|
+
const op = await readOp(ctx, operationId);
|
|
2025
|
+
if (op.state === "value" && op.row.requestDigest === requestDigest) {
|
|
2026
|
+
// The digest covers the granter, so a replay is by the recorded granter: an operator, or an
|
|
2027
|
+
// owner who is the witness's source owner or grantee owner.
|
|
2028
|
+
if (req.granter.kind === "owner" && op.row.kind === "service-host-operation" && op.row.plan.kind === "caller-revoke") {
|
|
2029
|
+
const w = op.row.plan;
|
|
2030
|
+
if (!((w.grant.source.kind === "service-owner" && w.grant.source.owner === req.granter.owner) || w.grantee.owner === req.granter.owner))
|
|
2031
|
+
return { state: "held", operationId, reason: "permission-denied", phase: "authorize" };
|
|
2032
|
+
}
|
|
2033
|
+
return await advanceCallerRevoke(inv, op.row, cutoff);
|
|
2034
|
+
}
|
|
2035
|
+
if (op.state === "value")
|
|
2036
|
+
return denied("conflict");
|
|
2037
|
+
inv.bound = false;
|
|
2038
|
+
ctx.step = "K0-admit";
|
|
2039
|
+
const pol = await readPolicy(ctx, grantee.lifecycleUid);
|
|
2040
|
+
const g = pol.state === "value" && boundTo(pol.row, grantee) ? pol.row.grants.find((x) => x.grantId === grantId) : undefined;
|
|
2041
|
+
if (g === undefined)
|
|
2042
|
+
return denied("not-found");
|
|
2043
|
+
// A delegated source is never written here, and only a source or grantee owner may revoke as an owner.
|
|
2044
|
+
if (g.source.kind !== "service-owner")
|
|
2045
|
+
return denied("unsupported");
|
|
2046
|
+
if (req.granter.kind === "owner" && req.granter.owner !== g.source.owner && req.granter.owner !== grantee.owner)
|
|
2047
|
+
return denied("not-found");
|
|
2048
|
+
if (g.status.state === "revoked")
|
|
2049
|
+
return denied("conflict");
|
|
2050
|
+
if (g.status.state === "active" && pol.revision !== expectedPolicyRevision)
|
|
2051
|
+
return denied("conflict");
|
|
2052
|
+
const adm = await readAdmission(ctx), now = Date.now();
|
|
2053
|
+
const { status: _status, ...body } = g;
|
|
2054
|
+
ctx.step = "K0-binding";
|
|
2055
|
+
const binding = { v: 1, kind: "service-host-operation", operationId, requestId: req.requestId, requestDigest, operation: "revoke",
|
|
2056
|
+
account: { accountPublicKey, space }, granter: req.granter, admittedAtMs: now, notAfterMs: now + S2A_OPERATION_TTL_MS,
|
|
2057
|
+
admissionEpoch: adm.state === "value" ? adm.row.epoch : 0,
|
|
2058
|
+
projection: { subjectOwner: g.source.owner, endpoint: g.endpoint, instanceId: g.instanceId, proposalId: null },
|
|
2059
|
+
plan: { kind: "caller-revoke", grantee, grantId, expectedPolicyRevision, request: req.input, grant: body }, progress: { state: "admitted" }, debt: [] };
|
|
2060
|
+
inv.bound = true;
|
|
2061
|
+
if ((await put(ctx, opKey(operationId), binding, parseServiceHostOperationRecord)) !== "moved")
|
|
2062
|
+
return await advanceCallerRevoke(inv, binding, cutoff);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
catch (err) {
|
|
2066
|
+
if (err instanceof Held)
|
|
2067
|
+
return heldArm(inv, err);
|
|
2068
|
+
throw err;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
/** K1-K3 from the stored witness. Each step narrows authority only, so none is bounded by the
|
|
2072
|
+
* operation's `notAfterMs`; each still ends at its invocation's deadline. */
|
|
2073
|
+
async function advanceCallerRevoke(inv, first, cutoff) {
|
|
2074
|
+
const ctx = readCtx(inv, "K-replay");
|
|
2075
|
+
const stored = outcomeOf(first);
|
|
2076
|
+
if (stored !== undefined)
|
|
2077
|
+
return stored.state === "complete" ? held("operation-malformed", ctx.step, "a caller revoke never completes") : finalArm(operationId, stored, debtOf(first));
|
|
2078
|
+
const p = first.plan, policyKey = servicePolicyKey(p.grantee.lifecycleUid);
|
|
2079
|
+
const witness = immutableGrantView(withStatus(p.grant, { state: "active" }));
|
|
2080
|
+
/** The grant from one read; anything but the witness's immutable view holds and writes nothing. */
|
|
2081
|
+
const lookup = async () => {
|
|
2082
|
+
const pol = await readPolicy(ctx, p.grantee.lifecycleUid);
|
|
2083
|
+
const grant = pol.state === "value" && boundTo(pol.row, p.grantee) ? pol.row.grants.find((x) => x.grantId === p.grantId) : undefined;
|
|
2084
|
+
if (grant === undefined || immutableGrantView(grant) !== witness)
|
|
2085
|
+
return held("witness-mismatch", ctx.step, "the grant no longer matches the operation's witness");
|
|
2086
|
+
return { pol: pol, grant };
|
|
2087
|
+
};
|
|
2088
|
+
// K1: deny-new under this operation, or the operation that already holds the grant's intent.
|
|
2089
|
+
let holder;
|
|
2090
|
+
for (let n = 0; holder === undefined; n++) {
|
|
2091
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
2092
|
+
held("contention", "K1-deny-new");
|
|
2093
|
+
ctx.step = "K1-deny-new";
|
|
2094
|
+
const { pol, grant } = await lookup();
|
|
2095
|
+
if (grant.status.state === "revoked")
|
|
2096
|
+
held("terminal-unsupported", ctx.step, "a terminal caller revocation is outside this host");
|
|
2097
|
+
const step = callerBeginStep(pol.row, p.grantId, operationId);
|
|
2098
|
+
if (step.kind === "held") {
|
|
2099
|
+
holder = step.operationId;
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
if (step.kind !== "write")
|
|
2103
|
+
held("witness-mismatch", ctx.step, "the grant no longer matches the operation's witness");
|
|
2104
|
+
await assertWriteFits(ctx, step.row, operationId);
|
|
2105
|
+
if ((await put(ctx, policyKey, step.row, parseNativeServiceCallerPolicy, pol)) !== "moved")
|
|
2106
|
+
holder = operationId;
|
|
2107
|
+
}
|
|
2108
|
+
if (holder !== cutoff)
|
|
2109
|
+
return { rescope: holder };
|
|
2110
|
+
// K2: the holder's marker, created once from the witness; identical bytes are adopted.
|
|
2111
|
+
ctx.step = "K2-marker";
|
|
2112
|
+
const markerKey = cutoffKey(holder);
|
|
2113
|
+
const marker = { v: 1, kind: "service-cutoff-marker", accountPublicKey, space, operationId: holder,
|
|
2114
|
+
target: { kind: "caller-grant", grantee: p.grantee, grantId: p.grantId, endpoint: p.grant.endpoint, instanceId: p.grant.instanceId },
|
|
2115
|
+
authorityDigest: contractDigest({ v: 1, kind: "service-caller-grant-authority", accountPublicKey, space, grantee: p.grantee, grant: p.grant }) };
|
|
2116
|
+
let m = await leader(ctx, "auth", markerKey);
|
|
2117
|
+
if (m.state === "absent") {
|
|
2118
|
+
await put(ctx, markerKey, marker, parseServiceCutoffMarkerRecord);
|
|
2119
|
+
m = await leader(ctx, "auth", markerKey);
|
|
2120
|
+
}
|
|
2121
|
+
if (m.state === "marker")
|
|
2122
|
+
held("marker-tombstone", ctx.step, `${markerKey} carries a ${m.op} marker`);
|
|
2123
|
+
let same = false;
|
|
2124
|
+
if (m.state === "value") {
|
|
2125
|
+
try {
|
|
2126
|
+
same = contractDigest(parseServiceCutoffMarkerRecord(m.value)) === contractDigest(marker);
|
|
2127
|
+
}
|
|
2128
|
+
catch {
|
|
2129
|
+
same = false;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
if (m.state !== "value" || !same)
|
|
2133
|
+
return held("marker-conflict", ctx.step, "the cutoff marker key holds another value; it is never overwritten");
|
|
2134
|
+
const at = serviceMarkerCeilSeconds(m.timestamp);
|
|
2135
|
+
if (at === undefined)
|
|
2136
|
+
return held("marker-time-unknown", ctx.step, "the marker's stored time is not a readable server time; now is never substituted");
|
|
2137
|
+
const cut = { operationId: holder, markerKey, markerRevision: m.revision, notBefore: at + SERVICE_CUTOFF_MARGIN_S };
|
|
2138
|
+
// K3: the deadline, recorded once and never reset.
|
|
2139
|
+
for (let n = 0;; n++) {
|
|
2140
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
2141
|
+
held("contention", "K3-deadline");
|
|
2142
|
+
ctx.step = "K3-deadline";
|
|
2143
|
+
const { pol } = await lookup();
|
|
2144
|
+
const step = callerCutoffStep(pol.row, p.grantId, cut);
|
|
2145
|
+
if (step.kind === "same")
|
|
2146
|
+
break;
|
|
2147
|
+
if (step.kind === "conflict")
|
|
2148
|
+
held("marker-conflict", ctx.step, step.detail);
|
|
2149
|
+
if (step.kind !== "write")
|
|
2150
|
+
held("witness-mismatch", ctx.step, "the grant no longer matches the operation's witness");
|
|
2151
|
+
await assertWriteFits(ctx, step.row, operationId);
|
|
2152
|
+
if ((await put(ctx, policyKey, step.row, parseNativeServiceCallerPolicy, pol)) !== "moved")
|
|
2153
|
+
break;
|
|
2154
|
+
}
|
|
2155
|
+
await updateOp(ctx, operationId, (cur) => (cur.progress.state === "custody" && cur.progress.step === "cutoff-recorded" ? undefined
|
|
2156
|
+
: isFinal(cur.progress) ? undefined : { ...cur, progress: { state: "custody", step: "cutoff-recorded" } })).catch((err) => { if (!(err instanceof Held))
|
|
2157
|
+
throw err; });
|
|
2158
|
+
// The phase is advisory, in the gate's whole-second convention; a terminal proof compares broker time.
|
|
2159
|
+
return { state: "pending", operationId, phase: seconds(Date.now()) < cut.notBefore ? "cutoff-wait" : "awaiting-terminal-proof", notBefore: cut.notBefore, denyNew: "landed" };
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
/** A view of one row; a filtered view carries only that service's grants, and the revision is always the whole row's. */
|
|
2163
|
+
const policyView = (row, service) => ({
|
|
2164
|
+
grantee: { ...row.grantee }, service: service === null ? null : { endpoint: service.endpoint, instanceId: service.instanceId },
|
|
2165
|
+
grants: structuredClone(service === null ? row.grants : row.grants.filter((g) => g.endpoint === service.endpoint && g.instanceId === service.instanceId))
|
|
2166
|
+
});
|
|
2167
|
+
/** caller-policy-read: an operator reads any grantee; an owner reads its own grantees in full or
|
|
2168
|
+
* filtered, and another owner's grantee only filtered to a service the owner holds. */
|
|
2169
|
+
async function callerPolicyRead(req) {
|
|
2170
|
+
try {
|
|
2171
|
+
const ctx = discoveryCtx("caller-policy-read");
|
|
2172
|
+
const { grantee, service } = req.input, g = req.granter;
|
|
2173
|
+
if (g.kind === "owner" && grantee.owner !== g.owner) {
|
|
2174
|
+
if (service === null)
|
|
2175
|
+
return { state: "absent" };
|
|
2176
|
+
const asg = await readAssignment(ctx, service.endpoint, service.instanceId);
|
|
2177
|
+
if (asg.state === "absent" || asg.row.owner !== g.owner)
|
|
2178
|
+
return { state: "absent" };
|
|
2179
|
+
}
|
|
2180
|
+
const pol = await readPolicy(ctx, grantee.lifecycleUid);
|
|
2181
|
+
if (pol.state === "absent")
|
|
2182
|
+
return { state: "absent" };
|
|
2183
|
+
if (!boundTo(pol.row, grantee))
|
|
2184
|
+
return denied("conflict");
|
|
2185
|
+
return { state: "present", record: { value: policyView(pol.row, service), revision: pol.revision } };
|
|
2186
|
+
}
|
|
2187
|
+
catch (err) {
|
|
2188
|
+
if (err instanceof Held)
|
|
2189
|
+
return denied("unavailable");
|
|
2190
|
+
throw err;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
const listCursor = (service, ownerFilter, after) => Buffer.from(canonicalJson({ v: 1, op: "caller-policy-list", accountPublicKey, space, service, owner: ownerFilter, after }), "utf8").toString("base64url");
|
|
2194
|
+
/** caller-policy-list: one service's grantees, from its instance index, in uid order. Space-wide
|
|
2195
|
+
* enumeration is unsupported. An owner lists only a service it holds; an operator any. Orphan
|
|
2196
|
+
* entries are skipped, and an unreadable row fails the whole page. */
|
|
2197
|
+
async function callerPolicyList(req) {
|
|
2198
|
+
const { service, owner: ownerFilter, cursor, limit } = req.input;
|
|
2199
|
+
if (service === null)
|
|
2200
|
+
return denied("unsupported");
|
|
2201
|
+
const sel = { endpoint: service.endpoint, instanceId: service.instanceId };
|
|
2202
|
+
let after = null;
|
|
2203
|
+
if (cursor !== null) {
|
|
2204
|
+
try {
|
|
2205
|
+
after = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))?.after;
|
|
2206
|
+
}
|
|
2207
|
+
catch {
|
|
2208
|
+
return denied("bad-request");
|
|
2209
|
+
}
|
|
2210
|
+
if (typeof after !== "string" || listCursor(sel, ownerFilter, after) !== cursor)
|
|
2211
|
+
return denied("bad-request");
|
|
2212
|
+
}
|
|
2213
|
+
try {
|
|
2214
|
+
const ctx = discoveryCtx("caller-policy-list");
|
|
2215
|
+
const empty = { state: "page", records: [], nextCursor: null };
|
|
2216
|
+
if (req.granter.kind === "owner") {
|
|
2217
|
+
const asg = await readAssignment(ctx, sel.endpoint, sel.instanceId);
|
|
2218
|
+
if (asg.state === "absent" || asg.row.owner !== req.granter.owner)
|
|
2219
|
+
return empty;
|
|
2220
|
+
}
|
|
2221
|
+
const idx = await readCallerIndex(ctx, sel.endpoint, sel.instanceId);
|
|
2222
|
+
const uids = [...new Set((idx.state === "value" ? idx.row.entries : []).map((e) => e.granteeUid))].filter((u) => after === null || u > after);
|
|
2223
|
+
const records = [];
|
|
2224
|
+
let last, more = false;
|
|
2225
|
+
for (const uid of uids) {
|
|
2226
|
+
const pol = await readPolicy(ctx, uid);
|
|
2227
|
+
if (pol.state === "absent")
|
|
2228
|
+
continue;
|
|
2229
|
+
if (pol.row.grantee.lifecycleUid !== uid || pol.row.accountPublicKey !== accountPublicKey || pol.row.space !== space)
|
|
2230
|
+
return denied("unavailable");
|
|
2231
|
+
const value = policyView(pol.row, sel);
|
|
2232
|
+
if (value.grants.length === 0 || (ownerFilter !== null && pol.row.grantee.owner !== ownerFilter))
|
|
2233
|
+
continue;
|
|
2234
|
+
if (records.length === limit) {
|
|
2235
|
+
more = true;
|
|
2236
|
+
break;
|
|
2237
|
+
}
|
|
2238
|
+
records.push({ value, revision: pol.revision });
|
|
2239
|
+
last = uid;
|
|
2240
|
+
}
|
|
2241
|
+
return { state: "page", records, nextCursor: more && last !== undefined ? listCursor(sel, ownerFilter, last) : null };
|
|
2242
|
+
}
|
|
2243
|
+
catch (err) {
|
|
2244
|
+
if (err instanceof Held)
|
|
2245
|
+
return denied("unavailable");
|
|
2246
|
+
throw err;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
1470
2249
|
async function operationRead(req) {
|
|
1471
2250
|
try {
|
|
1472
2251
|
const ctx = discoveryCtx("operation-read");
|
|
@@ -1480,11 +2259,14 @@ export async function openServiceHostOperations(opts) {
|
|
|
1480
2259
|
if (o.kind === "service-host-operation-terminal")
|
|
1481
2260
|
return { state: "present", record: { value: { operationId: o.operationId, requestDigest: o.requestDigest, operation: o.operation, projection, outcome: finalView(o.outcome) }, revision: op.revision } };
|
|
1482
2261
|
const p = o.plan, assignmentDigest = p.kind === "approve" ? serviceAssignmentGate(discovery.host, p.reservation).binding.assignmentDigest : p.kind === "revoke" ? p.expectedAssignmentDigest : null;
|
|
2262
|
+
const target = p.kind === "caller-grant" || p.kind === "caller-revoke" ? { kind: "caller", grantee: { ...p.grantee } }
|
|
2263
|
+
: { kind: "service", endpoint: o.projection.endpoint, instanceId: o.projection.instanceId, assignmentDigest };
|
|
1483
2264
|
const g2 = o.progress;
|
|
1484
|
-
const progress = g2.state === "admitted" ? { state: "pending", phase: "admitted" }
|
|
1485
|
-
: g2.state === "
|
|
2265
|
+
const progress = g2.state === "admitted" ? { state: "pending", phase: "admitted" }
|
|
2266
|
+
: g2.state === "custody" || g2.state === "issued" ? { state: "pending", phase: g2.step }
|
|
2267
|
+
: g2.state === "held" ? { state: "held", reason: g2.reason, phase: g2.phase } : finalView(g2);
|
|
1486
2268
|
return { state: "present", record: { value: { operationId: o.operationId, requestDigest: o.requestDigest, operation: o.operation,
|
|
1487
|
-
target
|
|
2269
|
+
target, projection, progress, debt: o.debt.map((d) => ({ ...d })) }, revision: op.revision } };
|
|
1488
2270
|
}
|
|
1489
2271
|
catch (err) {
|
|
1490
2272
|
if (err instanceof Held)
|
|
@@ -2141,9 +2923,9 @@ export async function openServiceHostOperations(opts) {
|
|
|
2141
2923
|
const out = (r) => ({ ...env, ...r });
|
|
2142
2924
|
// The latch comes before the ledger and every broker read. A request that could bind holds under its
|
|
2143
2925
|
// operation coordinate, with no deny-new claim because no gate was read; a read never binds.
|
|
2926
|
+
const binds = req.operation === "decide" || req.operation === "revoke" || req.operation === "name-bind" || req.operation === "caller-grant" || req.operation === "caller-revoke";
|
|
2144
2927
|
if (tripped !== undefined || closed)
|
|
2145
|
-
return out(req.
|
|
2146
|
-
? unread(req.requestId, "readiness", "open", req.operation === "revoke" ? req.input.expectedAssignmentDigest : undefined) : denied("unavailable"));
|
|
2928
|
+
return out(binds ? unread(req.requestId, "readiness", "open", req.operation === "revoke" ? req.input.expectedAssignmentDigest : undefined) : denied("unavailable"));
|
|
2147
2929
|
const authorized = req.granter.kind === "operator" || interactive(req.granter.owner);
|
|
2148
2930
|
switch (req.operation) {
|
|
2149
2931
|
case "decide": return out(await decide(req, authorized));
|
|
@@ -2151,6 +2933,10 @@ export async function openServiceHostOperations(opts) {
|
|
|
2151
2933
|
case "name-bind": return out(await nameBind(req));
|
|
2152
2934
|
case "operation-read": return out(authorized ? await operationRead(req) : denied("permission-denied"));
|
|
2153
2935
|
case "proposal-read": return out(authorized ? await proposalRead(req) : denied("permission-denied"));
|
|
2936
|
+
case "caller-grant": return out(await callerGrant(req, authorized));
|
|
2937
|
+
case "caller-revoke": return out(await callerRevoke(req, authorized));
|
|
2938
|
+
case "caller-policy-read": return out(authorized ? await callerPolicyRead(req) : denied("permission-denied"));
|
|
2939
|
+
case "caller-policy-list": return out(authorized ? await callerPolicyList(req) : denied("permission-denied"));
|
|
2154
2940
|
default: return out(denied("unsupported"));
|
|
2155
2941
|
}
|
|
2156
2942
|
}
|