@blamejs/core 0.8.43 → 0.8.49
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 +92 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.tenantQuota
|
|
4
|
+
* @nav Production
|
|
5
|
+
* @title Tenant Quota
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Per-tenant rate / byte / row quotas with enforcement helpers and
|
|
9
|
+
* audit emission on breach. Multi-tenant deployments need three
|
|
10
|
+
* things the framework's DB layer doesn't natively provide:
|
|
11
|
+
*
|
|
12
|
+
* 1. Storage caps — refuse INSERT when a tenant has consumed
|
|
13
|
+
* more than its allowance (`defaultBytesCap`,
|
|
14
|
+
* or a `perTenantBytesCap[tenantId]` override).
|
|
15
|
+
* 2. Query budgets — refuse SELECT when a tenant exceeds its
|
|
16
|
+
* rolling-window QPS or rows-read totals.
|
|
17
|
+
* 3. Isolation — every row a query reads under a claimed
|
|
18
|
+
* tenantId MUST belong to that tenant.
|
|
19
|
+
* Cross-tenant rows surface as
|
|
20
|
+
* `db.tenant.crossover` audit events.
|
|
21
|
+
*
|
|
22
|
+
* Replaces the global `maxRowsPerQuery` knob for tenant-scoped
|
|
23
|
+
* scenarios — operators were previously forced to pick one global
|
|
24
|
+
* cap that would starve large tenants or under-cap small ones.
|
|
25
|
+
*
|
|
26
|
+
* Storage-cap accounting: `bytesUsed` is computed by walking every
|
|
27
|
+
* table whose schema declares the configured `tenantField` and
|
|
28
|
+
* summing the textual length of every column for matching rows.
|
|
29
|
+
* The framework caches the per-tenant total for `cacheTtlMs`
|
|
30
|
+
* (default 30s) so a hot path doesn't pay the scan on every assert.
|
|
31
|
+
*
|
|
32
|
+
* Query budget: sliding-window counter keyed `(tenantId, windowStart)`.
|
|
33
|
+
* Window defaults to 60s. `observe()` rejects when either the
|
|
34
|
+
* QPS-equivalent call count exceeds `perTenantQpsCap * window` or
|
|
35
|
+
* the rows-read total exceeds `perTenantTotalRowsRead`.
|
|
36
|
+
*
|
|
37
|
+
* Audit emissions:
|
|
38
|
+
* - `tenant.quota.exceeded` — `assert()` refused an insert/update
|
|
39
|
+
* - `tenant.budget.exceeded` — `observe()` refused a query
|
|
40
|
+
* - `db.tenant.crossover` — `instrumentQuery` saw rows belonging
|
|
41
|
+
* to the wrong tenant under the
|
|
42
|
+
* operator-claimed tenantId
|
|
43
|
+
*
|
|
44
|
+
* SOC 2 CC6.1 ("logical access controls") + ISO 27001 A.8.1.5
|
|
45
|
+
* ("classification of information") map directly onto this
|
|
46
|
+
* primitive — operators wire its emissions into the same audit
|
|
47
|
+
* chain auditors read.
|
|
48
|
+
*
|
|
49
|
+
* @card
|
|
50
|
+
* Per-tenant rate / byte / row quotas with enforcement helpers and audit emission on breach.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
var C = require("./constants");
|
|
54
|
+
var lazyRequire = require("./lazy-require");
|
|
55
|
+
var validateOpts = require("./validate-opts");
|
|
56
|
+
var { defineClass } = require("./framework-error");
|
|
57
|
+
|
|
58
|
+
var TenantQuotaError = defineClass("TenantQuotaError", { alwaysPermanent: true });
|
|
59
|
+
|
|
60
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
61
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
62
|
+
|
|
63
|
+
var DEFAULT_CACHE_TTL_MS = C.TIME.seconds(30);
|
|
64
|
+
var DEFAULT_WINDOW_MS = C.TIME.minutes(1);
|
|
65
|
+
var DEFAULT_QPS_CAP = 100; // allow:raw-byte-literal — request count, not bytes
|
|
66
|
+
var DEFAULT_ROWS_READ = 50000; // allow:raw-byte-literal — row count, not bytes
|
|
67
|
+
var DEFAULT_BYTES_CAP = C.BYTES.gib(1);
|
|
68
|
+
|
|
69
|
+
// ---- Per-tenant storage cap (assert / snapshot / list) ----
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @primitive b.tenantQuota.create
|
|
73
|
+
* @signature b.tenantQuota.create(opts)
|
|
74
|
+
* @since 0.7.0
|
|
75
|
+
* @compliance soc2, gdpr
|
|
76
|
+
* @related b.tenantQuota.budget, b.tenantQuota.instrumentQuery
|
|
77
|
+
*
|
|
78
|
+
* Build a per-tenant storage-cap enforcer. Returns an object exposing
|
|
79
|
+
* `assert(tenantId)` (throws `TenantQuotaError` on breach),
|
|
80
|
+
* `snapshot(tenantId)` (returns `{ tenantId, bytesUsed, bytesCap,
|
|
81
|
+
* percent }`), `list()` (snapshot every distinct tenant), and
|
|
82
|
+
* `invalidate(tenantId?)` (drop the per-tenant cache so the next
|
|
83
|
+
* assert recomputes). The cache TTL trades freshness for cost on
|
|
84
|
+
* the hot path; bump it down for stricter limits.
|
|
85
|
+
*
|
|
86
|
+
* @opts
|
|
87
|
+
* {
|
|
88
|
+
* db: object, // required, b.db namespace
|
|
89
|
+
* tenantField: string, // required, e.g. "tenantId"
|
|
90
|
+
* defaultBytesCap?: number, // default: 1 GiB (C.BYTES.gib(1))
|
|
91
|
+
* perTenantBytesCap?: { [tenantId: string]: number },
|
|
92
|
+
* tables?: string[], // override auto-detection
|
|
93
|
+
* audit?: boolean, // default: true
|
|
94
|
+
* cacheTtlMs?: number, // default: 30_000
|
|
95
|
+
* }
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* var quota = b.tenantQuota.create({
|
|
99
|
+
* db: b.db,
|
|
100
|
+
* tenantField: "tenantId",
|
|
101
|
+
* defaultBytesCap: b.constants.BYTES.gib(1),
|
|
102
|
+
* perTenantBytesCap: { "tenant-vip": b.constants.BYTES.gib(10) },
|
|
103
|
+
* });
|
|
104
|
+
* await quota.assert("tenant-acme");
|
|
105
|
+
* // → { tenantId: "tenant-acme", bytesUsed: 12345, bytesCap: 1073741824, percent: 0.0000115 }
|
|
106
|
+
*/
|
|
107
|
+
function create(opts) {
|
|
108
|
+
validateOpts.requireObject(opts, "tenantQuota.create", TenantQuotaError);
|
|
109
|
+
validateOpts(opts, [
|
|
110
|
+
"db", "tenantField", "defaultBytesCap", "perTenantBytesCap",
|
|
111
|
+
"tables", "audit", "cacheTtlMs",
|
|
112
|
+
], "tenantQuota.create");
|
|
113
|
+
|
|
114
|
+
if (!opts.db || typeof opts.db.from !== "function" ||
|
|
115
|
+
typeof opts.db.getTableMetadata !== "function") {
|
|
116
|
+
throw new TenantQuotaError("tenantQuota/bad-db",
|
|
117
|
+
"tenantQuota.create: opts.db must be the framework's b.db namespace");
|
|
118
|
+
}
|
|
119
|
+
validateOpts.requireNonEmptyString(opts.tenantField,
|
|
120
|
+
"tenantQuota.create: tenantField", TenantQuotaError, "tenantQuota/bad-field");
|
|
121
|
+
|
|
122
|
+
var defaultBytesCap = (opts.defaultBytesCap == null)
|
|
123
|
+
? DEFAULT_BYTES_CAP
|
|
124
|
+
: opts.defaultBytesCap;
|
|
125
|
+
if (typeof defaultBytesCap !== "number" || !isFinite(defaultBytesCap) || defaultBytesCap <= 0) {
|
|
126
|
+
throw new TenantQuotaError("tenantQuota/bad-cap",
|
|
127
|
+
"tenantQuota.create: defaultBytesCap must be a positive finite number");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
var perTenantBytesCap = opts.perTenantBytesCap || {};
|
|
131
|
+
if (typeof perTenantBytesCap !== "object" || Array.isArray(perTenantBytesCap)) {
|
|
132
|
+
throw new TenantQuotaError("tenantQuota/bad-per-tenant",
|
|
133
|
+
"tenantQuota.create: perTenantBytesCap must be a plain object {tenantId: bytes}");
|
|
134
|
+
}
|
|
135
|
+
// Validate every per-tenant override at config time so a typo
|
|
136
|
+
// surfaces here rather than as a silent fall-through to default.
|
|
137
|
+
var ptKeys = Object.keys(perTenantBytesCap);
|
|
138
|
+
for (var pi = 0; pi < ptKeys.length; pi++) {
|
|
139
|
+
var v = perTenantBytesCap[ptKeys[pi]];
|
|
140
|
+
if (typeof v !== "number" || !isFinite(v) || v <= 0) {
|
|
141
|
+
throw new TenantQuotaError("tenantQuota/bad-per-tenant",
|
|
142
|
+
"tenantQuota.create: perTenantBytesCap['" + ptKeys[pi] +
|
|
143
|
+
"'] must be a positive finite number");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
var auditOn = opts.audit !== false;
|
|
148
|
+
var cacheTtlMs = (opts.cacheTtlMs == null) ? DEFAULT_CACHE_TTL_MS : opts.cacheTtlMs;
|
|
149
|
+
if (typeof cacheTtlMs !== "number" || !isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
|
150
|
+
throw new TenantQuotaError("tenantQuota/bad-ttl",
|
|
151
|
+
"tenantQuota.create: cacheTtlMs must be a non-negative finite number");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
var db = opts.db;
|
|
155
|
+
var tenantField = opts.tenantField;
|
|
156
|
+
|
|
157
|
+
// tables — operator-supplied table list (must include tenantField).
|
|
158
|
+
// When omitted we walk getTableMetadata() and pick every table whose
|
|
159
|
+
// schema declares the configured field.
|
|
160
|
+
var tablesOverride = Array.isArray(opts.tables) ? opts.tables.slice() : null;
|
|
161
|
+
|
|
162
|
+
function _resolveTables() {
|
|
163
|
+
if (tablesOverride) return tablesOverride;
|
|
164
|
+
var meta = db.getTableMetadata();
|
|
165
|
+
var out = [];
|
|
166
|
+
var keys = Object.keys(meta || {});
|
|
167
|
+
for (var i = 0; i < keys.length; i++) {
|
|
168
|
+
var t = meta[keys[i]];
|
|
169
|
+
if (t && t.columns && Object.prototype.hasOwnProperty.call(t.columns, tenantField)) {
|
|
170
|
+
out.push(keys[i]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Per-tenant cached snapshot — { bytesUsed, takenAt }
|
|
177
|
+
var cache = new Map();
|
|
178
|
+
|
|
179
|
+
function _capFor(tenantId) {
|
|
180
|
+
if (Object.prototype.hasOwnProperty.call(perTenantBytesCap, tenantId)) {
|
|
181
|
+
return perTenantBytesCap[tenantId];
|
|
182
|
+
}
|
|
183
|
+
return defaultBytesCap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _emitAudit(action, outcome, metadata) {
|
|
187
|
+
if (!auditOn) return;
|
|
188
|
+
try {
|
|
189
|
+
audit().safeEmit({
|
|
190
|
+
action: action,
|
|
191
|
+
outcome: outcome,
|
|
192
|
+
metadata: metadata || {},
|
|
193
|
+
});
|
|
194
|
+
} catch (_e) { /* audit best-effort */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _emitMetric(name, n) {
|
|
198
|
+
try { observability().safeEvent(name, n || 1, {}); }
|
|
199
|
+
catch (_e) { /* drop-silent */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function _computeBytesUsed(tenantId) {
|
|
203
|
+
var tables = _resolveTables();
|
|
204
|
+
var total = 0;
|
|
205
|
+
for (var i = 0; i < tables.length; i++) {
|
|
206
|
+
// SUM(LENGTH(...)) across every column wins over a per-row
|
|
207
|
+
// serializer — SQLite computes it in one scan and the framework's
|
|
208
|
+
// sealed-column ciphertext is already on disk under this length.
|
|
209
|
+
// We sum the textual length of every column to approximate row
|
|
210
|
+
// bytes; a small under-count for INTEGER columns is acceptable
|
|
211
|
+
// when the cap is a soft limit operators raise long before
|
|
212
|
+
// hitting hard storage.
|
|
213
|
+
var rows = db.from(tables[i])
|
|
214
|
+
.where(tenantField, "=", tenantId)
|
|
215
|
+
.select(["*"])
|
|
216
|
+
.all();
|
|
217
|
+
for (var r = 0; r < rows.length; r++) {
|
|
218
|
+
var row = rows[r];
|
|
219
|
+
var keys = Object.keys(row);
|
|
220
|
+
for (var k = 0; k < keys.length; k++) {
|
|
221
|
+
var v = row[keys[k]];
|
|
222
|
+
if (v == null) continue;
|
|
223
|
+
if (Buffer.isBuffer(v)) total += v.length;
|
|
224
|
+
else total += String(v).length;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return total;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function snapshot(tenantId) {
|
|
232
|
+
validateOpts.requireNonEmptyString(tenantId,
|
|
233
|
+
"tenantQuota.snapshot: tenantId", TenantQuotaError, "tenantQuota/bad-tenant");
|
|
234
|
+
var now = Date.now();
|
|
235
|
+
var cached = cache.get(tenantId);
|
|
236
|
+
var bytesUsed;
|
|
237
|
+
if (cached && (now - cached.takenAt) < cacheTtlMs) {
|
|
238
|
+
bytesUsed = cached.bytesUsed;
|
|
239
|
+
} else {
|
|
240
|
+
bytesUsed = await _computeBytesUsed(tenantId);
|
|
241
|
+
cache.set(tenantId, { bytesUsed: bytesUsed, takenAt: now });
|
|
242
|
+
}
|
|
243
|
+
var bytesCap = _capFor(tenantId);
|
|
244
|
+
return {
|
|
245
|
+
tenantId: tenantId,
|
|
246
|
+
bytesUsed: bytesUsed,
|
|
247
|
+
bytesCap: bytesCap,
|
|
248
|
+
percent: bytesCap === 0 ? 0 : bytesUsed / bytesCap,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function assert(tenantId) {
|
|
253
|
+
var snap = await snapshot(tenantId);
|
|
254
|
+
if (snap.bytesUsed >= snap.bytesCap) {
|
|
255
|
+
_emitAudit("tenant.quota.exceeded", "denied", {
|
|
256
|
+
tenantId: tenantId,
|
|
257
|
+
bytesUsed: snap.bytesUsed,
|
|
258
|
+
bytesCap: snap.bytesCap,
|
|
259
|
+
});
|
|
260
|
+
_emitMetric("tenant.quota.exceeded", 1);
|
|
261
|
+
throw new TenantQuotaError("tenantQuota/exceeded",
|
|
262
|
+
"tenantQuota.assert: tenant '" + tenantId + "' is at " +
|
|
263
|
+
snap.bytesUsed + " of " + snap.bytesCap + " bytes; insert refused");
|
|
264
|
+
}
|
|
265
|
+
return snap;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function list() {
|
|
269
|
+
// Walk every table, distinct tenantId values, and snapshot each.
|
|
270
|
+
var tables = _resolveTables();
|
|
271
|
+
var seen = Object.create(null);
|
|
272
|
+
for (var i = 0; i < tables.length; i++) {
|
|
273
|
+
var ids = db.from(tables[i])
|
|
274
|
+
.select([tenantField])
|
|
275
|
+
.all();
|
|
276
|
+
for (var j = 0; j < ids.length; j++) {
|
|
277
|
+
var v = ids[j] && ids[j][tenantField];
|
|
278
|
+
if (typeof v === "string" && v.length > 0) seen[v] = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
var out = [];
|
|
282
|
+
var tenantIds = Object.keys(seen);
|
|
283
|
+
for (var t = 0; t < tenantIds.length; t++) {
|
|
284
|
+
out.push(await snapshot(tenantIds[t]));
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _invalidate(tenantId) {
|
|
290
|
+
if (tenantId === undefined) cache.clear();
|
|
291
|
+
else cache.delete(tenantId);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
assert: assert,
|
|
296
|
+
snapshot: snapshot,
|
|
297
|
+
list: list,
|
|
298
|
+
invalidate: _invalidate,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- Per-tenant query budget (observe() — sliding window) ----
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @primitive b.tenantQuota.budget
|
|
306
|
+
* @signature b.tenantQuota.budget(opts)
|
|
307
|
+
* @since 0.7.0
|
|
308
|
+
* @compliance soc2
|
|
309
|
+
* @related b.tenantQuota.create, b.tenantQuota.instrumentQuery
|
|
310
|
+
*
|
|
311
|
+
* Build a per-tenant query-budget enforcer. Returns an object exposing
|
|
312
|
+
* `observe(tenantId, info)` (throws `TenantQuotaError` on breach),
|
|
313
|
+
* `snapshot(tenantId)` (returns the current window's counters), and
|
|
314
|
+
* `reset(tenantId?)` (drop counters). Sliding-window: every breach
|
|
315
|
+
* past the configured QPS or rows-read total emits
|
|
316
|
+
* `tenant.budget.exceeded` and refuses the call.
|
|
317
|
+
*
|
|
318
|
+
* @opts
|
|
319
|
+
* {
|
|
320
|
+
* db: object, // required, b.db namespace
|
|
321
|
+
* tenantField: string, // required
|
|
322
|
+
* perTenantQpsCap?: number, // default: 100 calls/sec
|
|
323
|
+
* perTenantTotalRowsRead?: number, // default: 50_000 rows per window
|
|
324
|
+
* window?: number, // default: 60_000 ms (C.TIME.minutes(1))
|
|
325
|
+
* audit?: boolean, // default: true
|
|
326
|
+
* }
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* var budget = b.tenantQuota.budget({
|
|
330
|
+
* db: b.db,
|
|
331
|
+
* tenantField: "tenantId",
|
|
332
|
+
* perTenantQpsCap: 100,
|
|
333
|
+
* perTenantTotalRowsRead: 50000,
|
|
334
|
+
* window: b.constants.TIME.minutes(1),
|
|
335
|
+
* });
|
|
336
|
+
* var snap = budget.observe("tenant-acme", { rowsRead: 12 });
|
|
337
|
+
* // → { calls: 1, rowsRead: 12, windowMs: 60000 }
|
|
338
|
+
*/
|
|
339
|
+
function budget(opts) {
|
|
340
|
+
validateOpts.requireObject(opts, "tenantQuota.budget", TenantQuotaError);
|
|
341
|
+
validateOpts(opts, [
|
|
342
|
+
"db", "tenantField", "perTenantQpsCap", "perTenantTotalRowsRead",
|
|
343
|
+
"window", "audit",
|
|
344
|
+
], "tenantQuota.budget");
|
|
345
|
+
|
|
346
|
+
validateOpts.requireNonEmptyString(opts.tenantField,
|
|
347
|
+
"tenantQuota.budget: tenantField", TenantQuotaError, "tenantQuota/bad-field");
|
|
348
|
+
|
|
349
|
+
var qpsCap = (opts.perTenantQpsCap == null) ? DEFAULT_QPS_CAP : opts.perTenantQpsCap;
|
|
350
|
+
if (typeof qpsCap !== "number" || !isFinite(qpsCap) || qpsCap <= 0) {
|
|
351
|
+
throw new TenantQuotaError("tenantQuota/bad-qps",
|
|
352
|
+
"tenantQuota.budget: perTenantQpsCap must be a positive finite number");
|
|
353
|
+
}
|
|
354
|
+
var rowsCap = (opts.perTenantTotalRowsRead == null) ? DEFAULT_ROWS_READ : opts.perTenantTotalRowsRead;
|
|
355
|
+
if (typeof rowsCap !== "number" || !isFinite(rowsCap) || rowsCap <= 0) {
|
|
356
|
+
throw new TenantQuotaError("tenantQuota/bad-rows",
|
|
357
|
+
"tenantQuota.budget: perTenantTotalRowsRead must be a positive finite number");
|
|
358
|
+
}
|
|
359
|
+
var windowMs = (opts.window == null) ? DEFAULT_WINDOW_MS : opts.window;
|
|
360
|
+
if (typeof windowMs !== "number" || !isFinite(windowMs) || windowMs <= 0) {
|
|
361
|
+
throw new TenantQuotaError("tenantQuota/bad-window",
|
|
362
|
+
"tenantQuota.budget: window must be a positive finite number");
|
|
363
|
+
}
|
|
364
|
+
var auditOn = opts.audit !== false;
|
|
365
|
+
|
|
366
|
+
// tenantId → { windowStart, calls, rowsRead }
|
|
367
|
+
var counters = new Map();
|
|
368
|
+
|
|
369
|
+
function _slot(tenantId, now) {
|
|
370
|
+
var c = counters.get(tenantId);
|
|
371
|
+
if (!c || (now - c.windowStart) >= windowMs) {
|
|
372
|
+
c = { windowStart: now, calls: 0, rowsRead: 0 };
|
|
373
|
+
counters.set(tenantId, c);
|
|
374
|
+
}
|
|
375
|
+
return c;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function _emitAudit(action, outcome, metadata) {
|
|
379
|
+
if (!auditOn) return;
|
|
380
|
+
try {
|
|
381
|
+
audit().safeEmit({
|
|
382
|
+
action: action,
|
|
383
|
+
outcome: outcome,
|
|
384
|
+
metadata: metadata || {},
|
|
385
|
+
});
|
|
386
|
+
} catch (_e) { /* audit best-effort */ }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function _emitMetric(name, n) {
|
|
390
|
+
try { observability().safeEvent(name, n || 1, {}); }
|
|
391
|
+
catch (_e) { /* drop-silent */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function observe(tenantId, info) {
|
|
395
|
+
validateOpts.requireNonEmptyString(tenantId,
|
|
396
|
+
"tenantQuota.budget.observe: tenantId", TenantQuotaError, "tenantQuota/bad-tenant");
|
|
397
|
+
info = info || {};
|
|
398
|
+
var rowsRead = (typeof info.rowsRead === "number" && info.rowsRead >= 0) ? info.rowsRead : 0;
|
|
399
|
+
var now = Date.now();
|
|
400
|
+
var c = _slot(tenantId, now);
|
|
401
|
+
c.calls += 1;
|
|
402
|
+
c.rowsRead += rowsRead;
|
|
403
|
+
var maxCalls = Math.max(1, Math.floor(qpsCap * (windowMs / C.TIME.seconds(1))));
|
|
404
|
+
if (c.calls > maxCalls || c.rowsRead > rowsCap) {
|
|
405
|
+
_emitAudit("tenant.budget.exceeded", "denied", {
|
|
406
|
+
tenantId: tenantId,
|
|
407
|
+
calls: c.calls,
|
|
408
|
+
rowsRead: c.rowsRead,
|
|
409
|
+
qpsCap: qpsCap,
|
|
410
|
+
rowsCap: rowsCap,
|
|
411
|
+
windowMs: windowMs,
|
|
412
|
+
});
|
|
413
|
+
_emitMetric("tenant.budget.exceeded", 1);
|
|
414
|
+
throw new TenantQuotaError("tenantQuota/budget-exceeded",
|
|
415
|
+
"tenantQuota.budget: tenant '" + tenantId + "' exceeded budget " +
|
|
416
|
+
"(calls=" + c.calls + "/" + maxCalls + ", rowsRead=" + c.rowsRead +
|
|
417
|
+
"/" + rowsCap + ", windowMs=" + windowMs + ")");
|
|
418
|
+
}
|
|
419
|
+
return { calls: c.calls, rowsRead: c.rowsRead, windowMs: windowMs };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function snapshot(tenantId) {
|
|
423
|
+
var now = Date.now();
|
|
424
|
+
var c = counters.get(tenantId);
|
|
425
|
+
if (!c || (now - c.windowStart) >= windowMs) {
|
|
426
|
+
return { tenantId: tenantId, calls: 0, rowsRead: 0, windowMs: windowMs };
|
|
427
|
+
}
|
|
428
|
+
return { tenantId: tenantId, calls: c.calls, rowsRead: c.rowsRead, windowMs: windowMs };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function reset(tenantId) {
|
|
432
|
+
if (tenantId === undefined) counters.clear();
|
|
433
|
+
else counters.delete(tenantId);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
observe: observe,
|
|
438
|
+
snapshot: snapshot,
|
|
439
|
+
reset: reset,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---- Tenant-isolation breach detection (instrumentQuery) ----
|
|
444
|
+
//
|
|
445
|
+
// instrumentQuery wraps a result set + the operator-claimed tenantId
|
|
446
|
+
// and emits db.tenant.crossover when any row's tenantField value
|
|
447
|
+
// disagrees with the claim. Used by the framework's _readQuery /
|
|
448
|
+
// query primitives at the seam where a query result lands.
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* @primitive b.tenantQuota.instrumentQuery
|
|
452
|
+
* @signature b.tenantQuota.instrumentQuery(opts)
|
|
453
|
+
* @since 0.7.0
|
|
454
|
+
* @compliance soc2, gdpr
|
|
455
|
+
* @related b.tenantQuota.create, b.tenantQuota.budget
|
|
456
|
+
*
|
|
457
|
+
* Walk a result set and detect rows whose `tenantField` value
|
|
458
|
+
* disagrees with the operator-claimed `tenantId` — a multi-tenant
|
|
459
|
+
* isolation breach. Returns `{ ok, crossover }` where `crossover` is
|
|
460
|
+
* the list of offending row indexes + their actual tenantId values.
|
|
461
|
+
* Audit emission `db.tenant.crossover` fires with a five-row sample
|
|
462
|
+
* when any breach is detected so the framework's chain-signed audit
|
|
463
|
+
* carries the forensic trail without dumping the whole result set.
|
|
464
|
+
*
|
|
465
|
+
* @opts
|
|
466
|
+
* {
|
|
467
|
+
* rows: object[], // required, the query result rows
|
|
468
|
+
* tenantField: string, // required, e.g. "tenantId"
|
|
469
|
+
* tenantId: string, // required, the operator-claimed tenant
|
|
470
|
+
* table?: string, // optional, recorded in the audit metadata
|
|
471
|
+
* audit?: boolean, // default: true
|
|
472
|
+
* }
|
|
473
|
+
*
|
|
474
|
+
* @example
|
|
475
|
+
* var rows = [
|
|
476
|
+
* { _id: 1, tenantId: "tenant-acme", name: "ok" },
|
|
477
|
+
* { _id: 2, tenantId: "tenant-other", name: "leak" },
|
|
478
|
+
* ];
|
|
479
|
+
* var result = b.tenantQuota.instrumentQuery({
|
|
480
|
+
* rows: rows,
|
|
481
|
+
* tenantField: "tenantId",
|
|
482
|
+
* tenantId: "tenant-acme",
|
|
483
|
+
* table: "orders",
|
|
484
|
+
* });
|
|
485
|
+
* // → { ok: false, crossover: [{ index: 1, actualTenantId: "tenant-other" }] }
|
|
486
|
+
*/
|
|
487
|
+
function instrumentQuery(opts) {
|
|
488
|
+
if (!opts || typeof opts !== "object") {
|
|
489
|
+
throw new TenantQuotaError("tenantQuota/bad-instr",
|
|
490
|
+
"tenantQuota.instrumentQuery: opts object is required");
|
|
491
|
+
}
|
|
492
|
+
validateOpts(opts, [
|
|
493
|
+
"rows", "tenantField", "tenantId", "audit", "table",
|
|
494
|
+
], "tenantQuota.instrumentQuery");
|
|
495
|
+
|
|
496
|
+
if (!Array.isArray(opts.rows)) {
|
|
497
|
+
throw new TenantQuotaError("tenantQuota/bad-rows",
|
|
498
|
+
"tenantQuota.instrumentQuery: rows must be an array");
|
|
499
|
+
}
|
|
500
|
+
validateOpts.requireNonEmptyString(opts.tenantField,
|
|
501
|
+
"tenantQuota.instrumentQuery: tenantField", TenantQuotaError, "tenantQuota/bad-field");
|
|
502
|
+
validateOpts.requireNonEmptyString(opts.tenantId,
|
|
503
|
+
"tenantQuota.instrumentQuery: tenantId", TenantQuotaError, "tenantQuota/bad-tenant");
|
|
504
|
+
var auditOn = opts.audit !== false;
|
|
505
|
+
|
|
506
|
+
var crossover = [];
|
|
507
|
+
for (var i = 0; i < opts.rows.length; i++) {
|
|
508
|
+
var row = opts.rows[i];
|
|
509
|
+
if (!row || typeof row !== "object") continue;
|
|
510
|
+
var actual = row[opts.tenantField];
|
|
511
|
+
if (actual !== undefined && actual !== null && actual !== opts.tenantId) {
|
|
512
|
+
crossover.push({ index: i, actualTenantId: String(actual) });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (crossover.length > 0) {
|
|
516
|
+
if (auditOn) {
|
|
517
|
+
try {
|
|
518
|
+
audit().safeEmit({
|
|
519
|
+
action: "db.tenant.crossover",
|
|
520
|
+
outcome: "failure",
|
|
521
|
+
metadata: {
|
|
522
|
+
tenantField: opts.tenantField,
|
|
523
|
+
claimedTenant: opts.tenantId,
|
|
524
|
+
table: opts.table || null,
|
|
525
|
+
rowCount: crossover.length,
|
|
526
|
+
sample: crossover.slice(0, 5), // allow:raw-byte-literal — sample size, not bytes
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
} catch (_e) { /* audit best-effort */ }
|
|
530
|
+
}
|
|
531
|
+
try { observability().safeEvent("db.tenant.crossover", crossover.length, {}); }
|
|
532
|
+
catch (_e) { /* drop-silent */ }
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
ok: crossover.length === 0,
|
|
536
|
+
crossover: crossover,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
module.exports = {
|
|
541
|
+
create: create,
|
|
542
|
+
budget: budget,
|
|
543
|
+
instrumentQuery: instrumentQuery,
|
|
544
|
+
TenantQuotaError: TenantQuotaError,
|
|
545
|
+
};
|