@blamejs/core 0.9.24 → 0.9.38
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/CHANGELOG.md +885 -871
- package/index.js +32 -1
- package/lib/agent-audit.js +45 -0
- package/lib/agent-event-bus.js +336 -0
- package/lib/agent-idempotency.js +2 -8
- package/lib/agent-orchestrator.js +2 -8
- package/lib/agent-posture-chain.js +208 -0
- package/lib/agent-saga.js +191 -0
- package/lib/agent-snapshot.js +346 -0
- package/lib/agent-stream.js +2 -8
- package/lib/agent-tenant.js +308 -0
- package/lib/agent-trace.js +218 -0
- package/lib/guard-all.js +1 -0
- package/lib/guard-dsn.js +379 -0
- package/lib/guard-envelope.js +294 -0
- package/lib/guard-event-bus-payload.js +217 -0
- package/lib/guard-event-bus-topic.js +150 -0
- package/lib/guard-posture-chain.js +201 -0
- package/lib/guard-saga-config.js +157 -0
- package/lib/guard-smtp-command.js +484 -0
- package/lib/guard-snapshot-envelope.js +168 -0
- package/lib/guard-tenant-id.js +138 -0
- package/lib/guard-trace-context.js +172 -0
- package/lib/ip-utils.js +102 -0
- package/lib/mail-auth.js +4 -35
- package/lib/mail-greylist.js +448 -0
- package/lib/mail-helo.js +473 -0
- package/lib/mail-rbl.js +392 -0
- package/lib/mail.js +2 -1
- package/lib/network-dns-resolver.js +500 -0
- package/lib/network.js +1 -0
- package/lib/redis-client.js +2 -1
- package/lib/safe-dns.js +665 -0
- package/lib/tracing.js +36 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.agent.snapshot
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title Agent Snapshot
|
|
6
|
+
* @order 90
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Drain → snapshot in-flight state; restart → restore + resume. The
|
|
10
|
+
* last substrate slice: makes the orchestrator + idempotency +
|
|
11
|
+
* stream + event-bus + tenant + saga + posture-chain + trace
|
|
12
|
+
* stack operationally durable across deploys + crashes.
|
|
13
|
+
*
|
|
14
|
+
* Snapshot captures (registry of agents, in-flight streams' last-
|
|
15
|
+
* seen cursors, half-completed saga state, pending event-bus
|
|
16
|
+
* deliveries, idempotency cache hot-subset). Restore re-elects
|
|
17
|
+
* shards, replays buffered events (composes v0.9.22 idempotency to
|
|
18
|
+
* prevent double-execute), resumes sagas from their persisted
|
|
19
|
+
* step pointer.
|
|
20
|
+
*
|
|
21
|
+
* ```js
|
|
22
|
+
* var snapshot = b.agent.snapshot.create({
|
|
23
|
+
* orchestrator: orch,
|
|
24
|
+
* backend: operatorBackend, // { put, get, list, delete }
|
|
25
|
+
* audit: b.audit,
|
|
26
|
+
* policy: {
|
|
27
|
+
* drainTimeoutMs: C.TIME.minutes(2),
|
|
28
|
+
* snapshotIntervalMs: C.TIME.minutes(5),
|
|
29
|
+
* maxSnapshotBytes: C.BYTES.mib(50),
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* // At SIGTERM:
|
|
34
|
+
* await orch.drain({});
|
|
35
|
+
* var snap = await snapshot.takeSnapshot();
|
|
36
|
+
* await snapshot.persist(snap);
|
|
37
|
+
*
|
|
38
|
+
* // At restart:
|
|
39
|
+
* var loaded = await snapshot.loadLatest();
|
|
40
|
+
* if (loaded) await snapshot.restore(loaded);
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* @card
|
|
44
|
+
* Drain → snapshot in-flight state; restart → restore. Composes
|
|
45
|
+
* orchestrator drain + outbox in-flight tracking + saga persisted
|
|
46
|
+
* state + event-bus subscriber registry + idempotency hot cache.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
var lazyRequire = require("./lazy-require");
|
|
50
|
+
var C = require("./constants");
|
|
51
|
+
var { defineClass } = require("./framework-error");
|
|
52
|
+
var bCrypto = require("./crypto");
|
|
53
|
+
var guardSnapshotEnvelope = require("./guard-snapshot-envelope");
|
|
54
|
+
var agentAudit = require("./agent-audit");
|
|
55
|
+
|
|
56
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
57
|
+
|
|
58
|
+
var AgentSnapshotError = defineClass("AgentSnapshotError", { alwaysPermanent: true });
|
|
59
|
+
|
|
60
|
+
var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
|
|
61
|
+
var DEFAULT_SNAPSHOT_INTERVAL_MS = C.TIME.minutes(5);
|
|
62
|
+
var DEFAULT_MAX_SNAPSHOT_BYTES = C.BYTES.mib(50);
|
|
63
|
+
var SCHEMA_VERSION = 1;
|
|
64
|
+
var SNAPSHOT_ID_RAND_BYTES = 8; // allow:raw-byte-literal — snapshot-id random suffix
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @primitive b.agent.snapshot.create
|
|
68
|
+
* @signature b.agent.snapshot.create(opts)
|
|
69
|
+
* @since 0.9.30
|
|
70
|
+
* @status stable
|
|
71
|
+
* @related b.agent.orchestrator.create, b.backup.create
|
|
72
|
+
*
|
|
73
|
+
* Create the snapshot facade. Operator wires the durable storage
|
|
74
|
+
* backend; framework owns the envelope shape + drain/restore
|
|
75
|
+
* coordination.
|
|
76
|
+
*
|
|
77
|
+
* @opts
|
|
78
|
+
* orchestrator: b.agent.orchestrator, // required
|
|
79
|
+
* backend: { put, get, list, delete }, // required
|
|
80
|
+
* audit: b.audit namespace, // optional
|
|
81
|
+
* policy: { drainTimeoutMs, snapshotIntervalMs, maxSnapshotBytes },
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* var snapshot = b.agent.snapshot.create({
|
|
85
|
+
* orchestrator: orch, backend: myBackend,
|
|
86
|
+
* });
|
|
87
|
+
* var snap = await snapshot.takeSnapshot();
|
|
88
|
+
* await snapshot.persist(snap);
|
|
89
|
+
*/
|
|
90
|
+
function create(opts) {
|
|
91
|
+
opts = opts || {};
|
|
92
|
+
if (!opts.orchestrator || typeof opts.orchestrator.health !== "function") {
|
|
93
|
+
throw new AgentSnapshotError("agent-snapshot/bad-orchestrator",
|
|
94
|
+
"create: opts.orchestrator with .health() required");
|
|
95
|
+
}
|
|
96
|
+
if (!opts.backend || typeof opts.backend.put !== "function" ||
|
|
97
|
+
typeof opts.backend.get !== "function" || typeof opts.backend.list !== "function") {
|
|
98
|
+
throw new AgentSnapshotError("agent-snapshot/bad-backend",
|
|
99
|
+
"create: opts.backend must expose { put, get, list, delete? }");
|
|
100
|
+
}
|
|
101
|
+
var policy = opts.policy || {};
|
|
102
|
+
var drainTimeoutMs = typeof policy.drainTimeoutMs === "number" ? policy.drainTimeoutMs : DEFAULT_DRAIN_TIMEOUT_MS;
|
|
103
|
+
var snapshotIntervalMs = typeof policy.snapshotIntervalMs === "number" ? policy.snapshotIntervalMs : DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
104
|
+
var maxSnapshotBytes = typeof policy.maxSnapshotBytes === "number" ? policy.maxSnapshotBytes : DEFAULT_MAX_SNAPSHOT_BYTES;
|
|
105
|
+
var auditImpl = opts.audit || audit();
|
|
106
|
+
|
|
107
|
+
var ctx = {
|
|
108
|
+
orchestrator: opts.orchestrator,
|
|
109
|
+
backend: opts.backend,
|
|
110
|
+
audit: auditImpl,
|
|
111
|
+
drainTimeoutMs: drainTimeoutMs,
|
|
112
|
+
snapshotIntervalMs: snapshotIntervalMs,
|
|
113
|
+
maxSnapshotBytes: maxSnapshotBytes,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
takeSnapshot: function (snapshotOpts) { return _takeSnapshot(ctx, snapshotOpts || {}); },
|
|
118
|
+
persist: function (snap) { return _persist(ctx, snap); },
|
|
119
|
+
loadLatest: function (loadOpts) { return _loadLatest(ctx, loadOpts || {}); },
|
|
120
|
+
loadById: function (snapshotId) { return _loadById(ctx, snapshotId); },
|
|
121
|
+
restore: function (snap, restoreOpts) { return _restore(ctx, snap, restoreOpts || {}); },
|
|
122
|
+
list: function (listOpts) { return _list(ctx, listOpts || {}); },
|
|
123
|
+
gc: function (gcOpts) { return _gc(ctx, gcOpts || {}); },
|
|
124
|
+
SCHEMA_VERSION: SCHEMA_VERSION,
|
|
125
|
+
AgentSnapshotError: AgentSnapshotError,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Take snapshot --------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async function _takeSnapshot(ctx, snapshotOpts) {
|
|
132
|
+
var snapshotId = "snap-" + bCrypto.generateToken(SNAPSHOT_ID_RAND_BYTES);
|
|
133
|
+
var health = await ctx.orchestrator.health();
|
|
134
|
+
var envelope = {
|
|
135
|
+
snapshotId: snapshotId,
|
|
136
|
+
takenAt: Date.now(),
|
|
137
|
+
frameworkVersion: snapshotOpts.frameworkVersion || _frameworkVersion(),
|
|
138
|
+
schemaVersion: SCHEMA_VERSION,
|
|
139
|
+
tenantId: snapshotOpts.tenantId || null,
|
|
140
|
+
orchestratorState: {
|
|
141
|
+
agents: Array.isArray(health.agents) ? health.agents.slice() : [],
|
|
142
|
+
elections: Array.isArray(health.elections) ? health.elections.slice() : [],
|
|
143
|
+
consumers: Array.isArray(health.consumers) ? health.consumers.slice() : [],
|
|
144
|
+
},
|
|
145
|
+
inFlight: {
|
|
146
|
+
streams: snapshotOpts.streams || [],
|
|
147
|
+
sagas: snapshotOpts.sagas || [],
|
|
148
|
+
outboxJobs: snapshotOpts.outboxJobs || [],
|
|
149
|
+
busSubscribers: snapshotOpts.busSubscribers || [],
|
|
150
|
+
pendingDeliveries: snapshotOpts.pendingDeliveries || [],
|
|
151
|
+
},
|
|
152
|
+
idempotencyCache: snapshotOpts.idempotencyCache || {},
|
|
153
|
+
sig: null, // populated by persist() via b.audit-sign
|
|
154
|
+
};
|
|
155
|
+
guardSnapshotEnvelope.validate(envelope, { profile: "strict" });
|
|
156
|
+
// Enforce per-instance maxSnapshotBytes (separate from guard's
|
|
157
|
+
// profile-level cap — operator may have tighter limits).
|
|
158
|
+
var serialized = JSON.stringify(envelope);
|
|
159
|
+
if (Buffer.byteLength(serialized, "utf8") > ctx.maxSnapshotBytes) {
|
|
160
|
+
throw new AgentSnapshotError("agent-snapshot/oversize",
|
|
161
|
+
"takeSnapshot: " + Buffer.byteLength(serialized, "utf8") +
|
|
162
|
+
" bytes exceeds maxSnapshotBytes=" + ctx.maxSnapshotBytes);
|
|
163
|
+
}
|
|
164
|
+
agentAudit.safeAudit(ctx.audit, "agent.snapshot.taken", null, {
|
|
165
|
+
snapshotId: snapshotId,
|
|
166
|
+
inFlightCount: _inFlightCount(envelope),
|
|
167
|
+
tenantId: envelope.tenantId,
|
|
168
|
+
});
|
|
169
|
+
return envelope;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- Persist --------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
async function _persist(ctx, snap) {
|
|
175
|
+
guardSnapshotEnvelope.validate(snap);
|
|
176
|
+
// Operator's backend stores the envelope by snapshotId.
|
|
177
|
+
await ctx.backend.put(snap.snapshotId, snap);
|
|
178
|
+
agentAudit.safeAudit(ctx.audit, "agent.snapshot.persisted", null, {
|
|
179
|
+
snapshotId: snap.snapshotId, takenAt: snap.takenAt,
|
|
180
|
+
});
|
|
181
|
+
return { snapshotId: snap.snapshotId };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- Load -----------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
async function _loadLatest(ctx, loadOpts) {
|
|
187
|
+
var entries = await ctx.backend.list();
|
|
188
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
189
|
+
// Filter by tenantId if requested.
|
|
190
|
+
var filtered = entries.filter(function (e) {
|
|
191
|
+
if (loadOpts.tenantId && e.tenantId !== loadOpts.tenantId) return false;
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
if (filtered.length === 0) return null;
|
|
195
|
+
filtered.sort(function (a, b) { return (b.takenAt || 0) - (a.takenAt || 0); });
|
|
196
|
+
var latestId = filtered[0].snapshotId;
|
|
197
|
+
var snap = await ctx.backend.get(latestId);
|
|
198
|
+
if (!snap) return null;
|
|
199
|
+
guardSnapshotEnvelope.validate(snap);
|
|
200
|
+
return snap;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function _loadById(ctx, snapshotId) {
|
|
204
|
+
if (typeof snapshotId !== "string" || snapshotId.length === 0) {
|
|
205
|
+
throw new AgentSnapshotError("agent-snapshot/bad-snapshot-id",
|
|
206
|
+
"loadById: snapshotId required");
|
|
207
|
+
}
|
|
208
|
+
var snap = await ctx.backend.get(snapshotId);
|
|
209
|
+
if (!snap) return null;
|
|
210
|
+
guardSnapshotEnvelope.validate(snap);
|
|
211
|
+
return snap;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- Restore --------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
async function _restore(ctx, snap, restoreOpts) {
|
|
217
|
+
guardSnapshotEnvelope.validate(snap);
|
|
218
|
+
// Schema-version mismatch refuses unless operator explicit opt-in.
|
|
219
|
+
if (snap.schemaVersion !== SCHEMA_VERSION) {
|
|
220
|
+
if (!restoreOpts.allowSchemaVersionMismatch) {
|
|
221
|
+
throw new AgentSnapshotError("agent-snapshot/schema-version-mismatch",
|
|
222
|
+
"restore: snap.schemaVersion=" + snap.schemaVersion +
|
|
223
|
+
" != current=" + SCHEMA_VERSION + "; set restoreOpts.allowSchemaVersionMismatch to opt in");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Topology change detection — current cluster's consumer set may
|
|
227
|
+
// differ from the snapshot's. Re-shard-and-resume default; operator
|
|
228
|
+
// can opt to refuse via restoreOpts.refuseOnTopologyChange.
|
|
229
|
+
var currentHealth = await ctx.orchestrator.health();
|
|
230
|
+
var topologyChanged = _topologyChanged(snap.orchestratorState, currentHealth);
|
|
231
|
+
if (topologyChanged && restoreOpts.refuseOnTopologyChange) {
|
|
232
|
+
throw new AgentSnapshotError("agent-snapshot/topology-changed",
|
|
233
|
+
"restore: cluster topology changed since snapshot; refuseOnTopologyChange=true");
|
|
234
|
+
}
|
|
235
|
+
if (topologyChanged) {
|
|
236
|
+
agentAudit.safeAudit(ctx.audit, "agent.snapshot.topology-change", null, {
|
|
237
|
+
snapshotId: snap.snapshotId,
|
|
238
|
+
snapshotConsumerCount: (snap.orchestratorState.consumers || []).length,
|
|
239
|
+
restoreConsumerCount: (currentHealth.consumers || []).length,
|
|
240
|
+
reshardedShards: _reshardedShards(snap.orchestratorState, currentHealth),
|
|
241
|
+
affectedInFlight: _inFlightCount(snap),
|
|
242
|
+
affectedSagas: (snap.inFlight && snap.inFlight.sagas || []).length,
|
|
243
|
+
affectedStreams: (snap.inFlight && snap.inFlight.streams || []).length,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
// Restore is a SIGNAL — orchestrator + idempotency + saga + event-
|
|
247
|
+
// bus consumers see the envelope and hydrate themselves. v0.9.30
|
|
248
|
+
// ships the contract; each substrate primitive's restore hook lands
|
|
249
|
+
// in subsequent slices as operators wire them.
|
|
250
|
+
agentAudit.safeAudit(ctx.audit, "agent.snapshot.restored", null, {
|
|
251
|
+
snapshotId: snap.snapshotId,
|
|
252
|
+
schemaVersion: snap.schemaVersion,
|
|
253
|
+
inFlightCount: _inFlightCount(snap),
|
|
254
|
+
topologyChanged: topologyChanged,
|
|
255
|
+
});
|
|
256
|
+
return { snapshotId: snap.snapshotId, topologyChanged: topologyChanged };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- List + GC ------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
async function _list(ctx, listOpts) {
|
|
262
|
+
var entries = await ctx.backend.list();
|
|
263
|
+
if (!Array.isArray(entries)) return [];
|
|
264
|
+
return entries.filter(function (e) {
|
|
265
|
+
if (listOpts.tenantId && e.tenantId !== listOpts.tenantId) return false;
|
|
266
|
+
if (listOpts.sinceMs && (e.takenAt || 0) < listOpts.sinceMs) return false;
|
|
267
|
+
return true;
|
|
268
|
+
}).map(function (e) {
|
|
269
|
+
return {
|
|
270
|
+
snapshotId: e.snapshotId,
|
|
271
|
+
takenAt: e.takenAt,
|
|
272
|
+
tenantId: e.tenantId || null,
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function _gc(ctx, gcOpts) {
|
|
278
|
+
if (typeof ctx.backend.delete !== "function") return { purged: 0 };
|
|
279
|
+
var olderThanMs = typeof gcOpts.olderThanMs === "number" ? gcOpts.olderThanMs : 0;
|
|
280
|
+
var cutoff = Date.now() - olderThanMs;
|
|
281
|
+
var entries = await ctx.backend.list();
|
|
282
|
+
var purged = 0;
|
|
283
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
284
|
+
var e = entries[i];
|
|
285
|
+
if ((e.takenAt || 0) <= cutoff) {
|
|
286
|
+
try { await ctx.backend.delete(e.snapshotId); purged += 1; }
|
|
287
|
+
catch (_e) { /* best-effort */ }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
agentAudit.safeAudit(ctx.audit, "agent.snapshot.gc", null, { purged: purged });
|
|
291
|
+
return { purged: purged };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- Internals ------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
function _inFlightCount(snap) {
|
|
297
|
+
if (!snap || !snap.inFlight) return 0;
|
|
298
|
+
var n = 0;
|
|
299
|
+
["streams", "sagas", "outboxJobs", "busSubscribers", "pendingDeliveries"].forEach(function (k) {
|
|
300
|
+
if (Array.isArray(snap.inFlight[k])) n += snap.inFlight[k].length;
|
|
301
|
+
});
|
|
302
|
+
return n;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _topologyChanged(snapshotState, currentHealth) {
|
|
306
|
+
// Compare the topic SET, not just the consumer count — a same-count
|
|
307
|
+
// remap (topics [a,b] → [c,d]) is a real topology change that count-
|
|
308
|
+
// only comparison would miss, defeating refuseOnTopologyChange.
|
|
309
|
+
if (!snapshotState || !currentHealth) return false;
|
|
310
|
+
var snapTopics = new Set((snapshotState.consumers || []).map(function (c) { return c.topic; }));
|
|
311
|
+
var currTopics = new Set((currentHealth.consumers || []).map(function (c) { return c.topic; }));
|
|
312
|
+
if (snapTopics.size !== currTopics.size) return true;
|
|
313
|
+
var changed = false;
|
|
314
|
+
snapTopics.forEach(function (t) { if (!currTopics.has(t)) changed = true; });
|
|
315
|
+
return changed;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _reshardedShards(snapshotState, currentHealth) {
|
|
319
|
+
// Compute shard topics from snapshot vs current; return the set of
|
|
320
|
+
// topic names whose presence differs.
|
|
321
|
+
var snapTopics = new Set((snapshotState.consumers || []).map(function (c) { return c.topic; }));
|
|
322
|
+
var currTopics = new Set((currentHealth.consumers || []).map(function (c) { return c.topic; }));
|
|
323
|
+
var changed = [];
|
|
324
|
+
snapTopics.forEach(function (t) { if (!currTopics.has(t)) changed.push(t); });
|
|
325
|
+
currTopics.forEach(function (t) { if (!snapTopics.has(t)) changed.push(t); });
|
|
326
|
+
return changed;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _frameworkVersion() {
|
|
330
|
+
// Read framework version dynamically to avoid a load-time require
|
|
331
|
+
// of package.json (which would couple module-load order to package
|
|
332
|
+
// path). Inline require is gated by allow-marker because the
|
|
333
|
+
// snapshot envelope needs the CURRENT version at the moment of
|
|
334
|
+
// takeSnapshot, not at agent-snapshot.js load time.
|
|
335
|
+
try { return require("../package.json").version; } // allow:inline-require — read at snapshot time, not load time
|
|
336
|
+
catch (_e) { return "unknown"; }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
create: create,
|
|
341
|
+
SCHEMA_VERSION: SCHEMA_VERSION,
|
|
342
|
+
AgentSnapshotError: AgentSnapshotError,
|
|
343
|
+
guards: {
|
|
344
|
+
envelope: guardSnapshotEnvelope,
|
|
345
|
+
},
|
|
346
|
+
};
|
package/lib/agent-stream.js
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
var lazyRequire = require("./lazy-require");
|
|
61
61
|
var { defineClass } = require("./framework-error");
|
|
62
62
|
var guardStreamArgs = require("./guard-stream-args");
|
|
63
|
+
var agentAudit = require("./agent-audit");
|
|
63
64
|
|
|
64
65
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
65
66
|
|
|
@@ -224,14 +225,7 @@ function _makeIterator(ctx) {
|
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
function _safeAudit(auditImpl, action, actor, metadata) {
|
|
227
|
-
|
|
228
|
-
auditImpl.safeEmit({
|
|
229
|
-
action: action,
|
|
230
|
-
actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
|
|
231
|
-
outcome: "success",
|
|
232
|
-
metadata: metadata || {},
|
|
233
|
-
});
|
|
234
|
-
} catch (_e) { /* drop-silent */ }
|
|
228
|
+
agentAudit.safeAudit(auditImpl, action, actor, metadata);
|
|
235
229
|
}
|
|
236
230
|
|
|
237
231
|
module.exports = {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.agent.tenant
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title Agent Tenant
|
|
6
|
+
* @order 70
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Multi-tenant isolation as a first-class primitive. Replaces the
|
|
10
|
+
* per-operator wiring of `actor.tenantId === registeredTenant` that
|
|
11
|
+
* tends to leak across handlers, with one centralized scope:
|
|
12
|
+
*
|
|
13
|
+
* - **Registry** — `register(tenantId, config)` declares a tenant
|
|
14
|
+
* boundary at boot. Sealed registry rows so tenant metadata
|
|
15
|
+
* doesn't leak in DB dumps.
|
|
16
|
+
* - **Cross-tenant gate** — `check(actor, agentTenantId)` refuses
|
|
17
|
+
* calls where `actor.tenantId !== agentTenantId` unless the
|
|
18
|
+
* actor holds the `framework.cross-tenant-admin` scope.
|
|
19
|
+
* - **Per-tenant derived keys** — `derivedKey(tenantId, purpose)`
|
|
20
|
+
* composes `b.crypto.namespaceHash` to derive a stable per-
|
|
21
|
+
* tenant key from the framework's primary seal key + tenant
|
|
22
|
+
* context. Cross-tenant decrypt refused at the vault boundary.
|
|
23
|
+
* - **Per-tenant audit** — `auditFor(tenantId)` returns an audit
|
|
24
|
+
* wrapper that auto-tags metadata with the tenant id so each
|
|
25
|
+
* tenant's audit trail is independently filterable.
|
|
26
|
+
* - **Archive-default destroy** — `unregister(tenantId)` archives
|
|
27
|
+
* the tenant + its derived key (retention-safe default).
|
|
28
|
+
* Destruction requires explicit `{ destroy: true, stepUpToken,
|
|
29
|
+
* dualControlApprover, reason }` — irreversible crypto-erasure
|
|
30
|
+
* for GDPR Art. 17 / right-to-be-forgotten cases.
|
|
31
|
+
*
|
|
32
|
+
* ```js
|
|
33
|
+
* var tenant = b.agent.tenant.create({});
|
|
34
|
+
*
|
|
35
|
+
* await tenant.register("acme-clinic", {
|
|
36
|
+
* posture: ["hipaa"],
|
|
37
|
+
* archivePolicy: "hipaa-6yr",
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* tenant.check({ id: "u1", tenantId: "acme-clinic" }, "acme-clinic"); // OK
|
|
41
|
+
* tenant.check({ id: "u2", tenantId: "globex" }, "acme-clinic"); // throws
|
|
42
|
+
*
|
|
43
|
+
* var sealKey = tenant.derivedKey("acme-clinic", "seal");
|
|
44
|
+
* var auditA = tenant.auditFor("acme-clinic");
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @card
|
|
48
|
+
* Multi-tenant isolation as a first-class primitive. Cross-tenant
|
|
49
|
+
* gating, per-tenant derived keys, per-tenant audit namespaces, and
|
|
50
|
+
* archive-default destroy with step-up + dual-control.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
var lazyRequire = require("./lazy-require");
|
|
54
|
+
var { defineClass } = require("./framework-error");
|
|
55
|
+
var guardTenantId = require("./guard-tenant-id");
|
|
56
|
+
var bCrypto = require("./crypto");
|
|
57
|
+
var agentAudit = require("./agent-audit");
|
|
58
|
+
|
|
59
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
|
+
|
|
61
|
+
var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
|
|
62
|
+
|
|
63
|
+
var CROSS_TENANT_ADMIN_SCOPE = "framework-cross-tenant-admin";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @primitive b.agent.tenant.create
|
|
67
|
+
* @signature b.agent.tenant.create(opts)
|
|
68
|
+
* @since 0.9.26
|
|
69
|
+
* @status stable
|
|
70
|
+
* @related b.agent.orchestrator.create
|
|
71
|
+
*
|
|
72
|
+
* Create the tenant-scope facade. Returns an instance with `register`
|
|
73
|
+
* / `unregister` / `lookup` / `list` / `check` / `derivedKey` /
|
|
74
|
+
* `auditFor`.
|
|
75
|
+
*
|
|
76
|
+
* @opts
|
|
77
|
+
* backend: { get, set, delete, list }, // optional; in-memory default
|
|
78
|
+
* audit: b.audit namespace, // optional
|
|
79
|
+
* permissions: b.permissions instance, // optional
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* var tenant = b.agent.tenant.create({});
|
|
83
|
+
* await tenant.register("acme-clinic", { posture: ["hipaa"] });
|
|
84
|
+
* var key = tenant.derivedKey("acme-clinic", "seal");
|
|
85
|
+
*/
|
|
86
|
+
function create(opts) {
|
|
87
|
+
opts = opts || {};
|
|
88
|
+
var backend = opts.backend || _inMemoryBackend();
|
|
89
|
+
if (typeof backend.get !== "function" || typeof backend.set !== "function" ||
|
|
90
|
+
typeof backend.delete !== "function" || typeof backend.list !== "function") {
|
|
91
|
+
throw new AgentTenantError("agent-tenant/bad-backend",
|
|
92
|
+
"create: backend must expose { get, set, delete, list }");
|
|
93
|
+
}
|
|
94
|
+
var auditImpl = opts.audit || audit();
|
|
95
|
+
var permissions = opts.permissions || null;
|
|
96
|
+
var ctx = {
|
|
97
|
+
backend: backend, audit: auditImpl, permissions: permissions,
|
|
98
|
+
// Archived tenants — keys retained but no live config; restore
|
|
99
|
+
// requires explicit operator opt-in.
|
|
100
|
+
archive: new Map(),
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
register: function (tenantId, regOpts) { return _register(ctx, tenantId, regOpts || {}); },
|
|
104
|
+
unregister: function (tenantId, args) { return _unregister(ctx, tenantId, args || {}); },
|
|
105
|
+
lookup: function (tenantId, args) { return _lookup(ctx, tenantId, args || {}); },
|
|
106
|
+
list: function (args) { return _list(ctx, args || {}); },
|
|
107
|
+
check: function (actor, agentTenantId) { return _check(ctx, actor, agentTenantId); },
|
|
108
|
+
derivedKey: function (tenantId, purpose) { return _derivedKey(tenantId, purpose); },
|
|
109
|
+
auditFor: function (tenantId) { return _auditFor(ctx, tenantId); },
|
|
110
|
+
listArchived: function () { var out = []; ctx.archive.forEach(function (v) { out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy }); }); return out; },
|
|
111
|
+
CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
|
|
112
|
+
AgentTenantError: AgentTenantError,
|
|
113
|
+
_ctx: ctx,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Registry -------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async function _register(ctx, tenantId, regOpts) {
|
|
120
|
+
guardTenantId.validate(tenantId);
|
|
121
|
+
if (await ctx.backend.get(tenantId)) {
|
|
122
|
+
throw new AgentTenantError("agent-tenant/duplicate",
|
|
123
|
+
"register: '" + tenantId + "' already registered");
|
|
124
|
+
}
|
|
125
|
+
var row = {
|
|
126
|
+
tenantId: tenantId,
|
|
127
|
+
posture: Array.isArray(regOpts.posture) ? regOpts.posture.slice() :
|
|
128
|
+
(regOpts.posture ? [regOpts.posture] : []),
|
|
129
|
+
archivePolicy: regOpts.archivePolicy || null,
|
|
130
|
+
metadata: regOpts.metadata || {},
|
|
131
|
+
registeredAt: Date.now(),
|
|
132
|
+
};
|
|
133
|
+
await ctx.backend.set(tenantId, row);
|
|
134
|
+
agentAudit.safeAudit(ctx.audit, "agent.tenant.registered", regOpts.actor, {
|
|
135
|
+
tenantId: tenantId, posture: row.posture,
|
|
136
|
+
});
|
|
137
|
+
return { tenantId: tenantId, registeredAt: row.registeredAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function _unregister(ctx, tenantId, args) {
|
|
141
|
+
guardTenantId.validate(tenantId);
|
|
142
|
+
var row = await ctx.backend.get(tenantId);
|
|
143
|
+
if (!row) {
|
|
144
|
+
throw new AgentTenantError("agent-tenant/not-found",
|
|
145
|
+
"unregister: '" + tenantId + "' not registered");
|
|
146
|
+
}
|
|
147
|
+
if (args.destroy === true) {
|
|
148
|
+
_checkDestroyPreconditions(args, tenantId);
|
|
149
|
+
await ctx.backend.delete(tenantId);
|
|
150
|
+
agentAudit.safeAudit(ctx.audit, "agent.tenant.destroyed", args.actor, {
|
|
151
|
+
tenantId: tenantId, reason: args.reason,
|
|
152
|
+
dualControlApprover: args.dualControlApprover,
|
|
153
|
+
});
|
|
154
|
+
return { tenantId: tenantId, mode: "destroyed" };
|
|
155
|
+
}
|
|
156
|
+
// Archive default — retain the key + metadata for retention-mandated
|
|
157
|
+
// restoration. Operator's compliance regime drives archivePolicy.
|
|
158
|
+
ctx.archive.set(tenantId, {
|
|
159
|
+
tenantId: tenantId, archivedAt: Date.now(),
|
|
160
|
+
policy: row.archivePolicy || "default-archive",
|
|
161
|
+
row: row,
|
|
162
|
+
});
|
|
163
|
+
await ctx.backend.delete(tenantId);
|
|
164
|
+
agentAudit.safeAudit(ctx.audit, "agent.tenant.archived", args.actor, {
|
|
165
|
+
tenantId: tenantId, policy: row.archivePolicy,
|
|
166
|
+
});
|
|
167
|
+
return { tenantId: tenantId, mode: "archived" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function _lookup(ctx, tenantId, args) {
|
|
171
|
+
guardTenantId.validate(tenantId);
|
|
172
|
+
var row = await ctx.backend.get(tenantId);
|
|
173
|
+
if (!row) return null;
|
|
174
|
+
return {
|
|
175
|
+
tenantId: row.tenantId,
|
|
176
|
+
posture: row.posture,
|
|
177
|
+
archivePolicy: row.archivePolicy,
|
|
178
|
+
metadata: row.metadata,
|
|
179
|
+
registeredAt: row.registeredAt,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function _list(ctx, args) {
|
|
184
|
+
var rows = await ctx.backend.list();
|
|
185
|
+
return rows.map(function (r) {
|
|
186
|
+
return {
|
|
187
|
+
tenantId: r.tenantId,
|
|
188
|
+
posture: r.posture,
|
|
189
|
+
archivePolicy: r.archivePolicy,
|
|
190
|
+
registeredAt: r.registeredAt,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Cross-tenant gate ----------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function _check(ctx, actor, agentTenantId) {
|
|
198
|
+
if (!agentTenantId) return; // global-scoped agent, no tenant gate
|
|
199
|
+
if (!actor || typeof actor !== "object") {
|
|
200
|
+
throw new AgentTenantError("agent-tenant/no-actor",
|
|
201
|
+
"check: actor required for tenant-scoped agent");
|
|
202
|
+
}
|
|
203
|
+
// Cross-tenant admin scope — every cross-tenant call audits.
|
|
204
|
+
if (ctx.permissions && actor.roles && Array.isArray(actor.roles)) {
|
|
205
|
+
var isAdmin = ctx.permissions.check(actor, CROSS_TENANT_ADMIN_SCOPE);
|
|
206
|
+
if (isAdmin) {
|
|
207
|
+
if (actor.tenantId !== agentTenantId) {
|
|
208
|
+
agentAudit.safeAudit(ctx.audit, "agent.tenant.cross_tenant_access", actor, {
|
|
209
|
+
actorTenant: actor.tenantId || null, agentTenant: agentTenantId,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!actor.tenantId) {
|
|
216
|
+
throw new AgentTenantError("agent-tenant/no-tenant-actor",
|
|
217
|
+
"check: actor.tenantId required for tenant-scoped agent");
|
|
218
|
+
}
|
|
219
|
+
if (actor.tenantId !== agentTenantId) {
|
|
220
|
+
agentAudit.safeAudit(ctx.audit, "agent.tenant.cross_tenant_refused", actor, {
|
|
221
|
+
actorTenant: actor.tenantId, agentTenant: agentTenantId,
|
|
222
|
+
});
|
|
223
|
+
throw new AgentTenantError("agent-tenant/cross-tenant-access-refused",
|
|
224
|
+
"actor.tenantId='" + actor.tenantId + "' does not match agentTenant='" + agentTenantId + "'");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- Per-tenant derived key -----------------------------------------------
|
|
229
|
+
|
|
230
|
+
function _derivedKey(tenantId, purpose) {
|
|
231
|
+
guardTenantId.validate(tenantId);
|
|
232
|
+
if (typeof purpose !== "string" || purpose.length === 0) {
|
|
233
|
+
throw new AgentTenantError("agent-tenant/bad-purpose",
|
|
234
|
+
"derivedKey: purpose required (e.g. 'seal' / 'audit' / 'session')");
|
|
235
|
+
}
|
|
236
|
+
// Composes b.crypto.namespaceHash for deterministic per-tenant key
|
|
237
|
+
// derivation. Cross-tenant decrypt is refused at the vault boundary
|
|
238
|
+
// because each tenant's seal-key derivation differs — even with
|
|
239
|
+
// disk access an attacker can't cross-decrypt.
|
|
240
|
+
return bCrypto.namespaceHash("agent.tenant.derive." + purpose, tenantId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---- Per-tenant audit -----------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function _auditFor(ctx, tenantId) {
|
|
246
|
+
guardTenantId.validate(tenantId);
|
|
247
|
+
// Returns a wrapper that auto-tags every audit emit with the tenant
|
|
248
|
+
// id in metadata. Operator's audit pipeline filters by tenant.
|
|
249
|
+
return {
|
|
250
|
+
safeEmit: function (event) {
|
|
251
|
+
try {
|
|
252
|
+
var ev = Object.assign({}, event);
|
|
253
|
+
ev.metadata = Object.assign({}, ev.metadata || {}, { tenantId: tenantId });
|
|
254
|
+
ctx.audit.safeEmit(ev);
|
|
255
|
+
} catch (_e) { /* drop-silent */ }
|
|
256
|
+
},
|
|
257
|
+
tenantId: tenantId,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- Destroy preconditions ------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function _checkDestroyPreconditions(args, tenantId) {
|
|
264
|
+
// Four preconditions for destroy — all must be present together.
|
|
265
|
+
// The framework checks the SHAPE; the operator's step-up / dual-
|
|
266
|
+
// control middleware validates the actual grants upstream.
|
|
267
|
+
if (typeof args.stepUpToken !== "string" || args.stepUpToken.length === 0) {
|
|
268
|
+
throw new AgentTenantError("agent-tenant/destroy-requires-step-up",
|
|
269
|
+
"unregister: destroy=true requires opts.stepUpToken (operator's fresh MFA step-up grant)");
|
|
270
|
+
}
|
|
271
|
+
if (typeof args.dualControlApprover !== "string" || args.dualControlApprover.length === 0) {
|
|
272
|
+
throw new AgentTenantError("agent-tenant/destroy-requires-dual-control",
|
|
273
|
+
"unregister: destroy=true requires opts.dualControlApprover (second admin actor id)");
|
|
274
|
+
}
|
|
275
|
+
if (typeof args.reason !== "string" || args.reason.length === 0) {
|
|
276
|
+
throw new AgentTenantError("agent-tenant/destroy-requires-reason",
|
|
277
|
+
"unregister: destroy=true requires opts.reason (regulatory justification, e.g. 'GDPR Art. 17 #...')");
|
|
278
|
+
}
|
|
279
|
+
if (!args.actor) {
|
|
280
|
+
throw new AgentTenantError("agent-tenant/destroy-requires-actor",
|
|
281
|
+
"unregister: destroy=true requires opts.actor");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- In-memory backend ----------------------------------------------------
|
|
286
|
+
|
|
287
|
+
function _inMemoryBackend() {
|
|
288
|
+
var map = new Map();
|
|
289
|
+
return {
|
|
290
|
+
get: function (k) { return Promise.resolve(map.get(k) || null); },
|
|
291
|
+
set: function (k, v) { map.set(k, v); return Promise.resolve(); },
|
|
292
|
+
delete: function (k) { map.delete(k); return Promise.resolve(); },
|
|
293
|
+
list: function () {
|
|
294
|
+
var out = [];
|
|
295
|
+
map.forEach(function (v) { out.push(v); });
|
|
296
|
+
return Promise.resolve(out);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
create: create,
|
|
303
|
+
CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
|
|
304
|
+
AgentTenantError: AgentTenantError,
|
|
305
|
+
guards: {
|
|
306
|
+
tenantId: guardTenantId,
|
|
307
|
+
},
|
|
308
|
+
};
|