@blamejs/core 0.8.43 → 0.8.50
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 +93 -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
package/lib/external-db.js
CHANGED
|
@@ -1,54 +1,39 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.externalDb
|
|
4
|
+
* @nav Data
|
|
5
|
+
* @title External Database
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* of b.db.from() for those tables.
|
|
7
|
+
* @intro
|
|
8
|
+
* External-database integration for app data — Postgres / MySQL /
|
|
9
|
+
* SQLite / MongoDB connection pooling, retry, circuit breaker,
|
|
10
|
+
* classification routing, residency enforcement, and audit hooks.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* db.getDataResidency().region)
|
|
20
|
-
* - Audit hooks (system.externaldb.{query,transaction,connect.failure})
|
|
12
|
+
* Framework state (audit_log, consent_log, _blamejs_*) stays in the
|
|
13
|
+
* local SQLite via `b.db`. This module is for APP DATA — when an
|
|
14
|
+
* operator keeps domain tables in Postgres / MySQL / MongoDB / libsql,
|
|
15
|
+
* they configure a backend here and use `b.externalDb.query()` instead
|
|
16
|
+
* of `b.db.from()` for those tables. The same surface also serves
|
|
17
|
+
* cluster-mode coordination (leader election advisory locks,
|
|
18
|
+
* cross-replica routing) when the cluster provider points at the same
|
|
19
|
+
* backend.
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
21
|
+
* Bring-your-own-client design (per "zero npm runtime deps" rule):
|
|
22
|
+
* the operator supplies the actual DB driver via each backend's
|
|
23
|
+
* `connect` / `query` / `close` hooks. The framework layers
|
|
24
|
+
* connection pooling (lazy-create, idle reaping), transient-error
|
|
25
|
+
* retry, per-backend circuit breaker, classification routing
|
|
26
|
+
* (which backend serves which data class), residency enforcement
|
|
27
|
+
* against `db.getDataResidency().region`, and audit hooks
|
|
28
|
+
* (`system.externaldb.{query,transaction,read}`).
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
* externalDb.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* externalDb.transaction(fn, opts?) → fn's return value
|
|
34
|
-
* externalDb.healthCheck(backendName?) → backend status
|
|
35
|
-
* externalDb.listBackends()
|
|
36
|
-
* externalDb.shutdown()
|
|
30
|
+
* Read-replica routing exposes `b.externalDb.read.query()` and
|
|
31
|
+
* `b.externalDb.write.query()` — reads weight-round-robin across
|
|
32
|
+
* declared replicas with health tracking and primary fallback;
|
|
33
|
+
* writes always route to primary.
|
|
37
34
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* connect(): async () → client (returns operator's DB client)
|
|
41
|
-
* query(client, sql, params): async → { rows, rowCount }
|
|
42
|
-
* close(client): async → void
|
|
43
|
-
* ping(client): async → bool (optional health check)
|
|
44
|
-
* beginTx(client): async → void (optional; default 'BEGIN')
|
|
45
|
-
* commit(client): async → void (optional; default 'COMMIT')
|
|
46
|
-
* rollback(client): async → void (optional; default 'ROLLBACK')
|
|
47
|
-
* pool: { min: 1, max: 10, idleTimeoutMs: C.TIME.minutes(1) }
|
|
48
|
-
* classifications: ['personal' | 'operational' | 'public' | <custom>]
|
|
49
|
-
* residencyTag: 'EU' | 'US' | ...
|
|
50
|
-
* retry, breaker
|
|
51
|
-
* }
|
|
35
|
+
* @card
|
|
36
|
+
* External-database integration for app data — Postgres / MySQL / SQLite / MongoDB connection pooling, retry, circuit breaker, classification routing, residency enforcement, and audit hooks.
|
|
52
37
|
*/
|
|
53
38
|
var retryHelper = require("./retry");
|
|
54
39
|
var C = require("./constants");
|
|
@@ -71,6 +56,92 @@ function _emitMetric(name, value, labels) {
|
|
|
71
56
|
catch (_e) { /* hot-path observability sink — drop silent by design */ }
|
|
72
57
|
}
|
|
73
58
|
|
|
59
|
+
// Statement-class classifier for auth-failure forensics (D-M2). Inspects
|
|
60
|
+
// the leading keyword only so an attacker-controlled trailing fragment
|
|
61
|
+
// can't smuggle a false classification. Skips leading whitespace plus
|
|
62
|
+
// SQL line / block comments before reading the keyword.
|
|
63
|
+
var _STATEMENT_CLASS_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*([A-Za-z]+)/;
|
|
64
|
+
var _STATEMENT_CLASS_MAP = Object.freeze({
|
|
65
|
+
SELECT: "SELECT", WITH: "SELECT", VALUES: "SELECT", TABLE: "SELECT",
|
|
66
|
+
INSERT: "DML", UPDATE: "DML", DELETE: "DML", MERGE: "DML", UPSERT: "DML",
|
|
67
|
+
CREATE: "DDL", DROP: "DDL", ALTER: "DDL", TRUNCATE: "DDL",
|
|
68
|
+
RENAME: "DDL", COMMENT: "DDL",
|
|
69
|
+
GRANT: "DCL", REVOKE: "DCL",
|
|
70
|
+
SET: "SESSION", RESET: "SESSION",
|
|
71
|
+
BEGIN: "TX", START: "TX", COMMIT: "TX", ROLLBACK: "TX",
|
|
72
|
+
SAVEPOINT: "TX", RELEASE: "TX",
|
|
73
|
+
CALL: "ROUTINE", EXECUTE: "ROUTINE",
|
|
74
|
+
COPY: "BULK",
|
|
75
|
+
EXPLAIN: "META", ANALYZE: "META", VACUUM: "META",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function _classifyStatement(sql) {
|
|
79
|
+
if (typeof sql !== "string" || sql.length === 0) return "UNKNOWN";
|
|
80
|
+
var m = _STATEMENT_CLASS_RE.exec(sql);
|
|
81
|
+
if (!m) return "UNKNOWN";
|
|
82
|
+
return _STATEMENT_CLASS_MAP[m[1].toUpperCase()] || "OTHER";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Postgres SQLSTATE classes that indicate authentication / authorization
|
|
86
|
+
// failure at the DB level. SOC2 forensic gap (D-M2) — every match emits
|
|
87
|
+
// db.auth.failed with the SQL identity attempted, the database, and
|
|
88
|
+
// the statement class.
|
|
89
|
+
var _AUTH_FAILURE_CODES = Object.freeze({
|
|
90
|
+
"28000": "invalid_authorization_specification",
|
|
91
|
+
"28P01": "invalid_password",
|
|
92
|
+
"42501": "insufficient_privilege",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function _emitAuthFailureAudit(backend, role, sql, e) {
|
|
96
|
+
if (!e || !e.code) return;
|
|
97
|
+
var kind = _AUTH_FAILURE_CODES[e.code];
|
|
98
|
+
if (!kind) return;
|
|
99
|
+
audit().safeEmit({
|
|
100
|
+
action: "db.auth.failed",
|
|
101
|
+
actor: {},
|
|
102
|
+
resource: { kind: "db.backend", id: backend.name },
|
|
103
|
+
outcome: "denied",
|
|
104
|
+
reason: kind,
|
|
105
|
+
metadata: {
|
|
106
|
+
backend: backend.name,
|
|
107
|
+
dialect: backend.dialect,
|
|
108
|
+
sqlIdentity: role || null,
|
|
109
|
+
sqlstate: e.code,
|
|
110
|
+
statementClass: _classifyStatement(sql),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
_emitMetric("db.auth.failed", 1, {
|
|
114
|
+
backend: backend.name,
|
|
115
|
+
sqlstate: e.code,
|
|
116
|
+
statementClass: _classifyStatement(sql),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Slow-query bucket emitter (D-L7). Single-shot per query — highest
|
|
121
|
+
// matched bucket wins. Operators dashboard on the `bucket` label
|
|
122
|
+
// rather than separate counters per threshold.
|
|
123
|
+
var _SLOW_QUERY_BUCKETS = Object.freeze([
|
|
124
|
+
{ ms: C.TIME.seconds(30), label: "30s" },
|
|
125
|
+
{ ms: C.TIME.seconds(5), label: "5s" },
|
|
126
|
+
{ ms: C.TIME.seconds(1), label: "1s" },
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function _emitSlowQuery(backendName, role, durationMs, statementClass) {
|
|
130
|
+
if (typeof durationMs !== "number" || !isFinite(durationMs)) return;
|
|
131
|
+
for (var i = 0; i < _SLOW_QUERY_BUCKETS.length; i++) {
|
|
132
|
+
var bucket = _SLOW_QUERY_BUCKETS[i];
|
|
133
|
+
if (durationMs >= bucket.ms) {
|
|
134
|
+
_emitMetric("db.query.slow", durationMs, {
|
|
135
|
+
backend: backendName,
|
|
136
|
+
role: role || "(none)",
|
|
137
|
+
bucket: bucket.label,
|
|
138
|
+
statementClass: statementClass || "UNKNOWN",
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
74
145
|
var _err = ExternalDbError.factory;
|
|
75
146
|
|
|
76
147
|
var initialized = false;
|
|
@@ -184,6 +255,64 @@ class Pool {
|
|
|
184
255
|
|
|
185
256
|
// ---- Init ----
|
|
186
257
|
|
|
258
|
+
/**
|
|
259
|
+
* @primitive b.externalDb.init
|
|
260
|
+
* @signature b.externalDb.init(opts)
|
|
261
|
+
* @since 0.4.0
|
|
262
|
+
* @related b.externalDb.query, b.externalDb.shutdown, b.externalDb.adapters.connectAs
|
|
263
|
+
*
|
|
264
|
+
* Register one or more app-data backends. Each backend declares its
|
|
265
|
+
* `connect` / `query` driver hooks plus optional pooling, classification,
|
|
266
|
+
* residency, retry, and replica configuration. Throws synchronously on
|
|
267
|
+
* malformed input (missing hooks, unknown dialect, residency mismatch
|
|
268
|
+
* against `db.getDataResidency()`, dotted GUC names that fail
|
|
269
|
+
* identifier validation).
|
|
270
|
+
*
|
|
271
|
+
* Boot-time residency check: when `db.getDataResidency().region` is set,
|
|
272
|
+
* any backend serving `personal` (or `*`) data must carry a
|
|
273
|
+
* `residencyTag` in the allowed-region list — refused with
|
|
274
|
+
* `RESIDENCY_VIOLATION` when not.
|
|
275
|
+
*
|
|
276
|
+
* @opts
|
|
277
|
+
* backends: { [name]: BackendConfig }, // required; one or more named backends
|
|
278
|
+
* defaultBackend?: string, // pool used when no opts.backend / classification / role match (defaults to first)
|
|
279
|
+
* dbRoleBackends?: { [sqlRole]: backendName }, // request-time role → backend mapping for the dbRoleFor middleware
|
|
280
|
+
*
|
|
281
|
+
* // BackendConfig shape:
|
|
282
|
+
* // connect(): async () → driver client (required)
|
|
283
|
+
* // query(client, sql, p): async → { rows, rowCount } (required)
|
|
284
|
+
* // close(client): async → void (optional; default no-op)
|
|
285
|
+
* // ping(client): async → void (optional; default `SELECT 1`)
|
|
286
|
+
* // beginTx / commit / rollback(client): async → void (optional; default `BEGIN`/`COMMIT`/`ROLLBACK`)
|
|
287
|
+
* // dialect: "postgres" | "mysql" | "sqlite" | "mongodb" | "other" (default "postgres")
|
|
288
|
+
* // applicationName: string ≤ 63 bytes, no CR/LF/NUL (Postgres pg_stat_activity tag; default null)
|
|
289
|
+
* // pool: { min, max, idleTimeoutMs } (defaults: 1 / 10 / C.TIME.minutes(1))
|
|
290
|
+
* // classifications: string[] (defaults to ["*"])
|
|
291
|
+
* // residencyTag: "EU" | "US" | "unrestricted" | ... (defaults to "unrestricted")
|
|
292
|
+
* // retry, breaker: passthrough to b.retry / CircuitBreaker
|
|
293
|
+
* // replicas: [{ connect, query, weight?, residencyTag?, allowCrossBorder? }]
|
|
294
|
+
* // replicaFallbackToPrimary: boolean (default true)
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* var pg = require("pg");
|
|
298
|
+
* var pool = new pg.Pool({ connectionString: "postgres://app:pw@db.example.com/app" });
|
|
299
|
+
*
|
|
300
|
+
* b.externalDb.init({
|
|
301
|
+
* backends: {
|
|
302
|
+
* main: {
|
|
303
|
+
* dialect: "postgres",
|
|
304
|
+
* applicationName: "blamejs-app",
|
|
305
|
+
* connect: function () { return pool.connect(); },
|
|
306
|
+
* query: function (client, sql, params) { return client.query(sql, params); },
|
|
307
|
+
* close: function (client) { return client.release(); },
|
|
308
|
+
* classifications: ["personal", "operational"],
|
|
309
|
+
* residencyTag: "EU",
|
|
310
|
+
* pool: { min: 2, max: 20, idleTimeoutMs: 60000 },
|
|
311
|
+
* },
|
|
312
|
+
* },
|
|
313
|
+
* defaultBackend: "main",
|
|
314
|
+
* });
|
|
315
|
+
*/
|
|
187
316
|
function init(opts) {
|
|
188
317
|
if (initialized) return;
|
|
189
318
|
if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
|
|
@@ -210,10 +339,70 @@ function init(opts) {
|
|
|
210
339
|
"backend '" + name + "': dialect must be one of " +
|
|
211
340
|
"'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
|
|
212
341
|
}
|
|
342
|
+
// OWASP-3 — application_name normalization for Postgres backends.
|
|
343
|
+
// Always set on every fresh connection (not just connectAs branch)
|
|
344
|
+
// so pg_stat_activity / log_line_prefix / audit log surfaces show
|
|
345
|
+
// a stable identifier instead of falling back to the driver's
|
|
346
|
+
// bare process name. CR / LF / NUL refused at config-time —
|
|
347
|
+
// those characters terminate the SET statement early in some
|
|
348
|
+
// drivers and have no legitimate use in an application_name.
|
|
349
|
+
// OWASP-3 — application_name normalization for Postgres backends.
|
|
350
|
+
// Opt-in via `cfg.applicationName` to surface a stable identifier
|
|
351
|
+
// in pg_stat_activity / log_line_prefix / Postgres audit log
|
|
352
|
+
// surfaces. Default leaves application_name to the driver — issuing
|
|
353
|
+
// a SET on every fresh connection at framework default would
|
|
354
|
+
// double-count queries for operators counting per-pool query
|
|
355
|
+
// activity (and break test fakes that count tracker.query calls).
|
|
356
|
+
var applicationName = cfg.applicationName !== undefined ? cfg.applicationName : null;
|
|
357
|
+
if (applicationName !== null && (typeof applicationName !== "string" || applicationName.length === 0)) {
|
|
358
|
+
throw _err("INVALID_CONFIG",
|
|
359
|
+
"backend '" + name + "': applicationName must be a non-empty string", true);
|
|
360
|
+
}
|
|
361
|
+
if (applicationName !== null) {
|
|
362
|
+
// eslint-disable-next-line no-control-regex
|
|
363
|
+
if (/[\r\n\u0000]/.test(applicationName)) {
|
|
364
|
+
throw _err("INVALID_CONFIG",
|
|
365
|
+
"backend '" + name + "': applicationName must not contain CR, LF, or NUL characters", true);
|
|
366
|
+
}
|
|
367
|
+
if (applicationName.length > C.BYTES.bytes(63)) {
|
|
368
|
+
throw _err("INVALID_CONFIG",
|
|
369
|
+
"backend '" + name + "': applicationName exceeds Postgres 63-byte limit (got " +
|
|
370
|
+
applicationName.length + ")", true);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
var rawConnect = cfg.connect;
|
|
374
|
+
var rawQuery = cfg.query;
|
|
375
|
+
var connectFn = rawConnect;
|
|
376
|
+
if (dialect === "postgres" && applicationName !== null) {
|
|
377
|
+
// IIFE captures per-iteration rawConnect/rawQuery; without this
|
|
378
|
+
// the var-hoisted bindings are shared across the for-loop and
|
|
379
|
+
// every backend's connectFn ends up calling the LAST iteration's
|
|
380
|
+
// rawQuery (classic closure-in-loop bug).
|
|
381
|
+
connectFn = (function (cn, qn, appName) {
|
|
382
|
+
var quotedAppName = "'" + appName.replace(/'/g, "''") + "'";
|
|
383
|
+
return async function () {
|
|
384
|
+
var client = await cn();
|
|
385
|
+
try {
|
|
386
|
+
await qn(client, "SET application_name TO " + quotedAppName, []);
|
|
387
|
+
} catch (_e) {
|
|
388
|
+
// Best-effort. Real Postgres always supports SET
|
|
389
|
+
// application_name; a driver that refuses it is a shim
|
|
390
|
+
// (test fake / non-PG backend mislabeled "postgres") and
|
|
391
|
+
// there's nothing useful to surface — keep the connection
|
|
392
|
+
// and let the operator hit any real query failure
|
|
393
|
+
// immediately afterwards.
|
|
394
|
+
void _e;
|
|
395
|
+
}
|
|
396
|
+
return client;
|
|
397
|
+
};
|
|
398
|
+
})(rawConnect, rawQuery, applicationName);
|
|
399
|
+
}
|
|
400
|
+
var poolCfg = Object.assign({}, cfg, { connect: connectFn });
|
|
213
401
|
backends[name] = {
|
|
214
402
|
name: name,
|
|
215
403
|
dialect: dialect,
|
|
216
|
-
|
|
404
|
+
applicationName: applicationName,
|
|
405
|
+
pool: new Pool(name, poolCfg),
|
|
217
406
|
query: cfg.query,
|
|
218
407
|
ping: cfg.ping || null,
|
|
219
408
|
beginTx: cfg.beginTx || function (client) { return cfg.query(client, "BEGIN", []); },
|
|
@@ -332,6 +521,39 @@ function _servesClassification(b, cls) {
|
|
|
332
521
|
|
|
333
522
|
// ---- Public API ----
|
|
334
523
|
|
|
524
|
+
/**
|
|
525
|
+
* @primitive b.externalDb.query
|
|
526
|
+
* @signature b.externalDb.query(sql, params, opts)
|
|
527
|
+
* @since 0.4.0
|
|
528
|
+
* @related b.externalDb.transaction, b.externalDb.read.query, b.externalDb.write.query
|
|
529
|
+
*
|
|
530
|
+
* Execute a single statement against the picked backend. Returns the
|
|
531
|
+
* driver-shaped `{ rows, rowCount }` from the backend's `query` hook.
|
|
532
|
+
* Wraps the call in `b.retry.withRetry` for transient driver errors
|
|
533
|
+
* and the per-backend circuit breaker; emits `system.externaldb.query`
|
|
534
|
+
* audit events plus duration / slow-query metrics; surfaces Postgres
|
|
535
|
+
* SQLSTATE 28000 / 28P01 / 42501 as `db.auth.failed` audit rows for
|
|
536
|
+
* SOC2 forensic walks.
|
|
537
|
+
*
|
|
538
|
+
* Backend selection precedence: `opts.backend` (explicit) →
|
|
539
|
+
* `opts.classification` (first backend serving the class) → ALS-bound
|
|
540
|
+
* dbRole + `dbRoleBackends` map (set by `b.middleware.dbRoleFor` or
|
|
541
|
+
* `b.externalDb.runAs`) → the configured `defaultBackend`.
|
|
542
|
+
*
|
|
543
|
+
* @opts
|
|
544
|
+
* backend?: string, // explicit backend name; bypasses classification + role pick
|
|
545
|
+
* classification?: string, // route to first backend whose classifications include this value
|
|
546
|
+
* includeSqlInAudit?: boolean, // emit SQL text in audit metadata (off by default — may carry literal PII)
|
|
547
|
+
*
|
|
548
|
+
* @example
|
|
549
|
+
* var res = await b.externalDb.query(
|
|
550
|
+
* "SELECT id, email FROM users WHERE tenant_id = $1",
|
|
551
|
+
* ["acme"],
|
|
552
|
+
* { classification: "personal" }
|
|
553
|
+
* );
|
|
554
|
+
* res.rowCount; // → 42
|
|
555
|
+
* res.rows[0]; // → { id: 1, email: "ada@example.com" }
|
|
556
|
+
*/
|
|
335
557
|
async function query(sql, params, opts) {
|
|
336
558
|
_requireInit();
|
|
337
559
|
opts = opts || {};
|
|
@@ -380,6 +602,7 @@ async function query(sql, params, opts) {
|
|
|
380
602
|
{ backend: b.name, role: role || "(none)" });
|
|
381
603
|
_emitMetric("externaldb.query.duration_ms", durationMs,
|
|
382
604
|
{ backend: b.name, role: role || "(none)" });
|
|
605
|
+
_emitSlowQuery(b.name, role, durationMs, _classifyStatement(sql));
|
|
383
606
|
return result;
|
|
384
607
|
} catch (e) {
|
|
385
608
|
var failureMs = Date.now() - t0;
|
|
@@ -392,6 +615,7 @@ async function query(sql, params, opts) {
|
|
|
392
615
|
}, (e && e.message) || String(e));
|
|
393
616
|
_emitMetric("externaldb.query.failure", 1,
|
|
394
617
|
{ backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
|
|
618
|
+
_emitSlowQuery(b.name, role, failureMs, _classifyStatement(sql));
|
|
395
619
|
// Postgres signals authorization-denied as SQLSTATE 42501
|
|
396
620
|
// (insufficient_privilege). RLS-shaped writes that violate a
|
|
397
621
|
// policy and GRANT-denied SELECTs both surface this code. The
|
|
@@ -403,10 +627,53 @@ async function query(sql, params, opts) {
|
|
|
403
627
|
_emitMetric("db.role.denied", 1,
|
|
404
628
|
{ backend: b.name, role: role || "(none)" });
|
|
405
629
|
}
|
|
630
|
+
// D-M2 — DB-auth audit visibility. Every 28000 / 28P01 / 42501
|
|
631
|
+
// surfaces an auditable db.auth.failed row tagged with the SQL
|
|
632
|
+
// identity and the statement class so SOC2 reviewers can
|
|
633
|
+
// reconstruct the denial timeline.
|
|
634
|
+
_emitAuthFailureAudit(b, role, sql, e);
|
|
406
635
|
throw e;
|
|
407
636
|
}
|
|
408
637
|
}
|
|
409
638
|
|
|
639
|
+
/**
|
|
640
|
+
* @primitive b.externalDb.transaction
|
|
641
|
+
* @signature b.externalDb.transaction(fn, opts)
|
|
642
|
+
* @since 0.4.0
|
|
643
|
+
* @related b.externalDb.query, b.externalDb.write.query
|
|
644
|
+
*
|
|
645
|
+
* Run `fn(tx)` inside a transaction on the picked backend. Wraps the
|
|
646
|
+
* body in `BEGIN` / `COMMIT` / `ROLLBACK` via the backend's hooks;
|
|
647
|
+
* commits on resolve, rolls back on throw. Transient deadlock /
|
|
648
|
+
* serialization failures (Postgres SQLSTATE `40P01` / `40001`) retry
|
|
649
|
+
* automatically with a small jittered backoff (default 3 attempts;
|
|
650
|
+
* tune via `opts.deadlockRetries`).
|
|
651
|
+
*
|
|
652
|
+
* `tx.query(sql, params)` runs against the same client used by
|
|
653
|
+
* `BEGIN`, so RLS state set by `sessionGucs` (`SET LOCAL`) applies for
|
|
654
|
+
* the duration of the transaction and resets at COMMIT/ROLLBACK.
|
|
655
|
+
*
|
|
656
|
+
* @opts
|
|
657
|
+
* backend?: string, // explicit backend name
|
|
658
|
+
* classification?: string, // route by data class
|
|
659
|
+
* sessionGucs?: { [name]: string|number|boolean }, // SET LOCAL bindings (e.g. { "app.tenant_id": "acme" })
|
|
660
|
+
* statementTimeoutMs?: number, // SET LOCAL statement_timeout
|
|
661
|
+
* idleInTransactionTimeoutMs?: number, // SET LOCAL idle_in_transaction_session_timeout
|
|
662
|
+
* deadlockRetries?: number, // retries for 40P01 / 40001 (default 3)
|
|
663
|
+
*
|
|
664
|
+
* @example
|
|
665
|
+
* var summary = await b.externalDb.transaction(async function (tx) {
|
|
666
|
+
* await tx.query("INSERT INTO orders(id, total) VALUES ($1, $2)", ["o-1", 4200]);
|
|
667
|
+
* await tx.query("UPDATE inventory SET qty = qty - 1 WHERE sku = $1", ["sku-7"]);
|
|
668
|
+
* var res = await tx.query("SELECT count(*) AS n FROM orders WHERE id = $1", ["o-1"]);
|
|
669
|
+
* return res.rows[0];
|
|
670
|
+
* }, {
|
|
671
|
+
* classification: "operational",
|
|
672
|
+
* sessionGucs: { "app.tenant_id": "acme" },
|
|
673
|
+
* statementTimeoutMs: 5000,
|
|
674
|
+
* });
|
|
675
|
+
* summary.n; // → 1
|
|
676
|
+
*/
|
|
410
677
|
async function transaction(fn, opts) {
|
|
411
678
|
_requireInit();
|
|
412
679
|
if (typeof fn !== "function") throw _err("INVALID_FN", "transaction requires a function", true);
|
|
@@ -504,6 +771,11 @@ async function transaction(fn, opts) {
|
|
|
504
771
|
_emitMetric("db.role.denied", 1,
|
|
505
772
|
{ backend: b.name, role: role || "(none)" });
|
|
506
773
|
}
|
|
774
|
+
// D-M2 — DB-auth audit visibility on transaction-shaped denials.
|
|
775
|
+
// Statement class always reads as "TX" since the failure
|
|
776
|
+
// surface inside a transaction body could be any statement;
|
|
777
|
+
// operators correlate via the transaction's audit row.
|
|
778
|
+
_emitAuthFailureAudit(b, role, "BEGIN", txErr);
|
|
507
779
|
throw txErr;
|
|
508
780
|
}
|
|
509
781
|
}
|
|
@@ -513,6 +785,28 @@ async function transaction(fn, opts) {
|
|
|
513
785
|
});
|
|
514
786
|
}
|
|
515
787
|
|
|
788
|
+
/**
|
|
789
|
+
* @primitive b.externalDb.healthCheck
|
|
790
|
+
* @signature b.externalDb.healthCheck(backendName)
|
|
791
|
+
* @since 0.4.0
|
|
792
|
+
* @related b.externalDb.listBackends, b.externalDb.shutdown
|
|
793
|
+
*
|
|
794
|
+
* Ping a backend by acquiring a client and running its `ping` hook (or
|
|
795
|
+
* `SELECT 1` when none is supplied). Returns `{ ok, breakerState, pool }`
|
|
796
|
+
* for a single backend, or a `{ [name]: result }` map when called with
|
|
797
|
+
* no argument. Connection-shape errors destroy the client; the breaker
|
|
798
|
+
* state is reflected in the returned record so health endpoints can
|
|
799
|
+
* surface circuit-open conditions.
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* var all = await b.externalDb.healthCheck();
|
|
803
|
+
* all.main.ok; // → true
|
|
804
|
+
* all.main.breakerState; // → "closed"
|
|
805
|
+
* all.main.pool; // → { idle: 1, active: 0, waiters: 0 }
|
|
806
|
+
*
|
|
807
|
+
* var one = await b.externalDb.healthCheck("main");
|
|
808
|
+
* one.ok; // → true
|
|
809
|
+
*/
|
|
516
810
|
async function healthCheck(backendName) {
|
|
517
811
|
_requireInit();
|
|
518
812
|
if (backendName) {
|
|
@@ -543,6 +837,25 @@ async function _pingBackend(b) {
|
|
|
543
837
|
}
|
|
544
838
|
}
|
|
545
839
|
|
|
840
|
+
/**
|
|
841
|
+
* @primitive b.externalDb.listBackends
|
|
842
|
+
* @signature b.externalDb.listBackends()
|
|
843
|
+
* @since 0.4.0
|
|
844
|
+
* @related b.externalDb.healthCheck, b.externalDb.init
|
|
845
|
+
*
|
|
846
|
+
* Snapshot every registered backend's name, dialect, classifications,
|
|
847
|
+
* residency tag, breaker state, and live pool stats. Returns `[]` when
|
|
848
|
+
* `init()` has not run. Cheap — does not open any new connections.
|
|
849
|
+
*
|
|
850
|
+
* @example
|
|
851
|
+
* var rows = b.externalDb.listBackends();
|
|
852
|
+
* rows[0].name; // → "main"
|
|
853
|
+
* rows[0].dialect; // → "postgres"
|
|
854
|
+
* rows[0].classifications; // → ["personal", "operational"]
|
|
855
|
+
* rows[0].residencyTag; // → "EU"
|
|
856
|
+
* rows[0].breakerState; // → "closed"
|
|
857
|
+
* rows[0].pool; // → { idle: 2, active: 0, waiters: 0 }
|
|
858
|
+
*/
|
|
546
859
|
function listBackends() {
|
|
547
860
|
if (!initialized) return [];
|
|
548
861
|
return Object.keys(backends).map(function (name) {
|
|
@@ -558,6 +871,24 @@ function listBackends() {
|
|
|
558
871
|
});
|
|
559
872
|
}
|
|
560
873
|
|
|
874
|
+
/**
|
|
875
|
+
* @primitive b.externalDb.shutdown
|
|
876
|
+
* @signature b.externalDb.shutdown()
|
|
877
|
+
* @since 0.4.0
|
|
878
|
+
* @related b.externalDb.init, b.externalDb.healthCheck
|
|
879
|
+
*
|
|
880
|
+
* Drain every backend pool (and replica pool), close idle clients,
|
|
881
|
+
* then clear all registry state so a subsequent `init()` starts from
|
|
882
|
+
* scratch. Idempotent — calling before `init()` is a no-op. Wire to
|
|
883
|
+
* `b.appShutdown` so process exit waits for in-flight queries to
|
|
884
|
+
* release their clients.
|
|
885
|
+
*
|
|
886
|
+
* @example
|
|
887
|
+
* process.on("SIGTERM", async function () {
|
|
888
|
+
* await b.externalDb.shutdown();
|
|
889
|
+
* process.exit(0);
|
|
890
|
+
* });
|
|
891
|
+
*/
|
|
561
892
|
async function shutdown() {
|
|
562
893
|
if (!initialized) return;
|
|
563
894
|
for (var name in backends) {
|
|
@@ -686,12 +1017,46 @@ function _requireInit() {
|
|
|
686
1017
|
|
|
687
1018
|
var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
|
|
688
1019
|
|
|
1020
|
+
// F-CBT-2 — replica residency-tag compatibility.
|
|
1021
|
+
//
|
|
1022
|
+
// A primary tagged "EU" replicating to a "US" replica is a GDPR
|
|
1023
|
+
// Article 46 cross-border transfer; without an explicit operator
|
|
1024
|
+
// opt-in the framework refuses init under gdpr / dpdp / pipl-cn /
|
|
1025
|
+
// uk-gdpr postures. Operator suppresses the gate per replica via
|
|
1026
|
+
// allowCrossBorder: true (which the framework records in the audit
|
|
1027
|
+
// chain so a compliance reviewer sees the conscious decision).
|
|
1028
|
+
//
|
|
1029
|
+
// Compatible-residency rules:
|
|
1030
|
+
// - Identical tags (EU↔EU, US↔US): always compatible.
|
|
1031
|
+
// - "unrestricted" tag on either side: compatible (operator
|
|
1032
|
+
// declared no constraint).
|
|
1033
|
+
// - Different tags: compatible only when allowCrossBorder is true.
|
|
1034
|
+
var CROSS_BORDER_REGULATED_POSTURES = Object.freeze([
|
|
1035
|
+
"gdpr", "uk-gdpr", "dpdp", "pipl-cn", "lgpd-br", "appi-jp", "pdpa-sg",
|
|
1036
|
+
]);
|
|
1037
|
+
|
|
1038
|
+
function _residencyCompatible(primaryTag, replicaTag) {
|
|
1039
|
+
if (!primaryTag || !replicaTag) return true;
|
|
1040
|
+
if (primaryTag === replicaTag) return true; // allow:raw-hash-compare — residency tag string, not a secret hash
|
|
1041
|
+
if (primaryTag === "unrestricted" || replicaTag === "unrestricted") return true;
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function _activePosture() {
|
|
1046
|
+
try {
|
|
1047
|
+
var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
|
|
1048
|
+
return compliance.current();
|
|
1049
|
+
} catch (_e) { return null; }
|
|
1050
|
+
}
|
|
1051
|
+
|
|
689
1052
|
function _buildReplicas(backendName, cfg) {
|
|
690
1053
|
if (!cfg.replicas) return null;
|
|
691
1054
|
if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
|
|
692
1055
|
throw _err("INVALID_CONFIG",
|
|
693
1056
|
"backend '" + backendName + "': replicas must be a non-empty array", true);
|
|
694
1057
|
}
|
|
1058
|
+
var primaryTag = cfg.residencyTag || "unrestricted";
|
|
1059
|
+
var posture = _activePosture();
|
|
695
1060
|
var out = [];
|
|
696
1061
|
for (var i = 0; i < cfg.replicas.length; i++) {
|
|
697
1062
|
var r = cfg.replicas[i];
|
|
@@ -709,11 +1074,33 @@ function _buildReplicas(backendName, cfg) {
|
|
|
709
1074
|
throw _err("INVALID_CONFIG",
|
|
710
1075
|
"backend '" + backendName + "': replicas[" + i + "].weight must be a positive integer", true);
|
|
711
1076
|
}
|
|
1077
|
+
var replicaTag = r.residencyTag || "unrestricted";
|
|
1078
|
+
var allowCrossBorder = r.allowCrossBorder === true;
|
|
1079
|
+
if (!_residencyCompatible(primaryTag, replicaTag) && !allowCrossBorder) {
|
|
1080
|
+
var underPosture = posture && CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
|
|
1081
|
+
throw _err("RESIDENCY_MISMATCH",
|
|
1082
|
+
"backend '" + backendName + "': replica[" + i +
|
|
1083
|
+
"] residencyTag '" + replicaTag +
|
|
1084
|
+
"' is not compatible with primary residencyTag '" + primaryTag +
|
|
1085
|
+
"'" + (underPosture ? " under '" + posture + "' posture" : "") +
|
|
1086
|
+
". This is a cross-border data transfer (GDPR Art 46 / DPDP / PIPL " +
|
|
1087
|
+
"category). Pass allowCrossBorder: true on the replica config with a " +
|
|
1088
|
+
"documented legal basis (SCCs / BCRs / adequacy decision) to suppress.", true);
|
|
1089
|
+
}
|
|
1090
|
+
if (!_residencyCompatible(primaryTag, replicaTag) && allowCrossBorder) {
|
|
1091
|
+
_emit("externalDb.replica.cross_border_allowed", "warning",
|
|
1092
|
+
{ backend: backendName, replicaIndex: i,
|
|
1093
|
+
primaryTag: primaryTag, replicaTag: replicaTag,
|
|
1094
|
+
legalBasis: r.legalBasis || null,
|
|
1095
|
+
posture: posture || null });
|
|
1096
|
+
}
|
|
712
1097
|
out.push({
|
|
713
1098
|
index: i,
|
|
714
1099
|
pool: new Pool(backendName + ":replica:" + i, r),
|
|
715
1100
|
query: r.query,
|
|
716
1101
|
weight: weight,
|
|
1102
|
+
residencyTag: replicaTag,
|
|
1103
|
+
allowCrossBorder: allowCrossBorder,
|
|
717
1104
|
lastFailureAt: 0,
|
|
718
1105
|
consecutiveFailures: 0,
|
|
719
1106
|
});
|
|
@@ -807,6 +1194,8 @@ async function _readQuery(sql, params, opts) {
|
|
|
807
1194
|
_emitMetric("db.role.denied", 1,
|
|
808
1195
|
{ backend: b.name, role: role || "(none)" });
|
|
809
1196
|
}
|
|
1197
|
+
// D-M2 — DB-auth audit visibility for read-replica denials too.
|
|
1198
|
+
_emitAuthFailureAudit(b, role, sql, e);
|
|
810
1199
|
// Fallback to primary on a failed replica read when allowed.
|
|
811
1200
|
if (b.replicaFallbackToPrimary) {
|
|
812
1201
|
return query(sql, params, opts);
|
|
@@ -815,10 +1204,86 @@ async function _readQuery(sql, params, opts) {
|
|
|
815
1204
|
}
|
|
816
1205
|
}
|
|
817
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* @primitive b.externalDb.read.query
|
|
1209
|
+
* @signature b.externalDb.read.query(sql, params, opts)
|
|
1210
|
+
* @since 0.4.0
|
|
1211
|
+
* @related b.externalDb.write.query, b.externalDb.query, b.externalDb.init
|
|
1212
|
+
*
|
|
1213
|
+
* Route a read against the backend's declared replicas using weighted
|
|
1214
|
+
* round-robin. A failed replica is sidelined for 30 seconds and the
|
|
1215
|
+
* call falls back to primary when `replicaFallbackToPrimary` is true
|
|
1216
|
+
* (the default). Backends without replicas transparently route to
|
|
1217
|
+
* primary. Same `opts` selection rules as `b.externalDb.query`
|
|
1218
|
+
* (`backend` / `classification` / ALS-bound role).
|
|
1219
|
+
*
|
|
1220
|
+
* @opts
|
|
1221
|
+
* backend?: string, // explicit backend name
|
|
1222
|
+
* classification?: string, // route by data class
|
|
1223
|
+
*
|
|
1224
|
+
* @example
|
|
1225
|
+
* var res = await b.externalDb.read.query(
|
|
1226
|
+
* "SELECT id, total FROM orders WHERE tenant_id = $1",
|
|
1227
|
+
* ["acme"],
|
|
1228
|
+
* { classification: "operational" }
|
|
1229
|
+
* );
|
|
1230
|
+
* res.rowCount; // → 7
|
|
1231
|
+
* res.rows[0]; // → { id: "o-1", total: 4200 }
|
|
1232
|
+
*/
|
|
818
1233
|
var read = {
|
|
819
1234
|
query: _readQuery,
|
|
820
1235
|
};
|
|
821
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* @primitive b.externalDb.write.query
|
|
1239
|
+
* @signature b.externalDb.write.query(sql, params, opts)
|
|
1240
|
+
* @since 0.4.0
|
|
1241
|
+
* @related b.externalDb.read.query, b.externalDb.query, b.externalDb.write.transaction
|
|
1242
|
+
*
|
|
1243
|
+
* Symmetric alias for `b.externalDb.query` — always routes to primary.
|
|
1244
|
+
* Pair with `b.externalDb.read.query` when an operator wants the call
|
|
1245
|
+
* site to express read/write intent without a magic-comment hint.
|
|
1246
|
+
* Same `opts` selection rules as `b.externalDb.query`.
|
|
1247
|
+
*
|
|
1248
|
+
* @opts
|
|
1249
|
+
* backend?: string, // explicit backend name
|
|
1250
|
+
* classification?: string, // route by data class
|
|
1251
|
+
* includeSqlInAudit?: boolean, // emit SQL text in audit metadata
|
|
1252
|
+
*
|
|
1253
|
+
* @example
|
|
1254
|
+
* var res = await b.externalDb.write.query(
|
|
1255
|
+
* "INSERT INTO orders(id, tenant_id, total) VALUES ($1, $2, $3)",
|
|
1256
|
+
* ["o-2", "acme", 1500],
|
|
1257
|
+
* { classification: "operational" }
|
|
1258
|
+
* );
|
|
1259
|
+
* res.rowCount; // → 1
|
|
1260
|
+
*/
|
|
1261
|
+
/**
|
|
1262
|
+
* @primitive b.externalDb.write.transaction
|
|
1263
|
+
* @signature b.externalDb.write.transaction(fn, opts)
|
|
1264
|
+
* @since 0.4.0
|
|
1265
|
+
* @related b.externalDb.transaction, b.externalDb.write.query
|
|
1266
|
+
*
|
|
1267
|
+
* Symmetric alias for `b.externalDb.transaction` — always runs against
|
|
1268
|
+
* primary. Same `opts` shape (sessionGucs / statementTimeoutMs /
|
|
1269
|
+
* idleInTransactionTimeoutMs / deadlockRetries) as the canonical form.
|
|
1270
|
+
*
|
|
1271
|
+
* @opts
|
|
1272
|
+
* backend?: string,
|
|
1273
|
+
* classification?: string,
|
|
1274
|
+
* sessionGucs?: { [name]: string|number|boolean },
|
|
1275
|
+
* statementTimeoutMs?: number,
|
|
1276
|
+
* idleInTransactionTimeoutMs?: number,
|
|
1277
|
+
* deadlockRetries?: number,
|
|
1278
|
+
*
|
|
1279
|
+
* @example
|
|
1280
|
+
* var n = await b.externalDb.write.transaction(async function (tx) {
|
|
1281
|
+
* await tx.query("UPDATE counters SET n = n + 1 WHERE k = $1", ["hits"]);
|
|
1282
|
+
* var res = await tx.query("SELECT n FROM counters WHERE k = $1", ["hits"]);
|
|
1283
|
+
* return res.rows[0].n;
|
|
1284
|
+
* }, { sessionGucs: { "app.tenant_id": "acme" } });
|
|
1285
|
+
* typeof n; // → "number"
|
|
1286
|
+
*/
|
|
822
1287
|
// write namespace — alias for the primary path. Lets operators express
|
|
823
1288
|
// intent symmetrically with read.query without a magic-comment hint.
|
|
824
1289
|
var write = {
|
|
@@ -852,6 +1317,30 @@ function _resetForTest() {
|
|
|
852
1317
|
// clients are kept; new acquisitions respect the new max. min is honored
|
|
853
1318
|
// the next time the pool refills. idleTimeoutMs takes effect on the next
|
|
854
1319
|
// reaper tick.
|
|
1320
|
+
/**
|
|
1321
|
+
* @primitive b.externalDb.configurePool
|
|
1322
|
+
* @signature b.externalDb.configurePool(backendName, opts)
|
|
1323
|
+
* @since 0.4.0
|
|
1324
|
+
* @related b.externalDb.init, b.externalDb.listBackends
|
|
1325
|
+
*
|
|
1326
|
+
* Resize a registered backend's pool at runtime. New `max` takes effect
|
|
1327
|
+
* on the next acquire; existing idle clients are kept; `min` is honored
|
|
1328
|
+
* when the pool next refills; `idleTimeoutMs` applies on the next
|
|
1329
|
+
* reaper tick. Throws on unknown options or non-positive integers so a
|
|
1330
|
+
* config typo surfaces at the call site.
|
|
1331
|
+
*
|
|
1332
|
+
* @opts
|
|
1333
|
+
* min?: number, // positive integer; floor on idle clients
|
|
1334
|
+
* max?: number, // positive integer; ceiling on total clients (must be >= min)
|
|
1335
|
+
* idleTimeoutMs?: number, // positive integer; reap idle clients after this many ms
|
|
1336
|
+
*
|
|
1337
|
+
* @example
|
|
1338
|
+
* b.externalDb.configurePool("main", {
|
|
1339
|
+
* min: 4,
|
|
1340
|
+
* max: 50,
|
|
1341
|
+
* idleTimeoutMs: 120000,
|
|
1342
|
+
* });
|
|
1343
|
+
*/
|
|
855
1344
|
function configurePool(backendName, opts) {
|
|
856
1345
|
_requireInit();
|
|
857
1346
|
if (typeof backendName !== "string" || backendName.length === 0) {
|
|
@@ -1031,6 +1520,51 @@ function _connectAs(rawConnect, query, opts) {
|
|
|
1031
1520
|
};
|
|
1032
1521
|
}
|
|
1033
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* @primitive b.externalDb.adapters.connectAs
|
|
1525
|
+
* @signature b.externalDb.adapters.connectAs(connect, opts)
|
|
1526
|
+
* @since 0.4.0
|
|
1527
|
+
* @related b.externalDb.init, b.externalDb.runAs
|
|
1528
|
+
*
|
|
1529
|
+
* Wrap a Postgres `connect` so every fresh client runs `SET ROLE`,
|
|
1530
|
+
* `SET search_path`, `SET application_name`, `SET statement_timeout`,
|
|
1531
|
+
* and any operator-supplied `gucs` before being handed to the pool.
|
|
1532
|
+
* Identifier inputs (role, schemas, GUC names) are validated via
|
|
1533
|
+
* `safeSql.validateIdentifier` at call time so a bad name throws once
|
|
1534
|
+
* at boot rather than per acquired client. Returns the wrapped
|
|
1535
|
+
* `connect` function suitable for a backend's `connect` hook.
|
|
1536
|
+
*
|
|
1537
|
+
* @opts
|
|
1538
|
+
* query: function, // required — the backend's query function (used to issue SET statements)
|
|
1539
|
+
* role?: string, // SQL identifier; runs SET ROLE "<role>"
|
|
1540
|
+
* searchPath?: string[], // SQL identifiers; runs SET search_path TO "<a>", "<b>", ...
|
|
1541
|
+
* applicationName?: string, // appears in pg_stat_activity
|
|
1542
|
+
* statementTimeoutMs?: number, // positive integer; SET statement_timeout TO <ms>
|
|
1543
|
+
* gucs?: { [name]: string|number }, // raw GUC bindings; finite numbers required for numeric values
|
|
1544
|
+
*
|
|
1545
|
+
* @example
|
|
1546
|
+
* var pg = require("pg");
|
|
1547
|
+
* var pool = new pg.Pool({ connectionString: "postgres://app:pw@db.example.com/app" });
|
|
1548
|
+
* var rawConnect = function () { return pool.connect(); };
|
|
1549
|
+
* var rawQuery = function (client, sql, params) { return client.query(sql, params); };
|
|
1550
|
+
*
|
|
1551
|
+
* b.externalDb.init({
|
|
1552
|
+
* backends: {
|
|
1553
|
+
* analytics: {
|
|
1554
|
+
* dialect: "postgres",
|
|
1555
|
+
* connect: b.externalDb.adapters.connectAs(rawConnect, {
|
|
1556
|
+
* query: rawQuery,
|
|
1557
|
+
* role: "analytics_user",
|
|
1558
|
+
* searchPath: ["analytics", "public"],
|
|
1559
|
+
* applicationName: "blamejs:analytics",
|
|
1560
|
+
* statementTimeoutMs: 30000,
|
|
1561
|
+
* gucs: { idle_in_transaction_session_timeout: "60s" },
|
|
1562
|
+
* }),
|
|
1563
|
+
* query: rawQuery,
|
|
1564
|
+
* },
|
|
1565
|
+
* },
|
|
1566
|
+
* });
|
|
1567
|
+
*/
|
|
1034
1568
|
// Operators import the helper as `b.externalDb.adapters.connectAs(connect, opts)`
|
|
1035
1569
|
// — declarative wrapping with shared input validation.
|
|
1036
1570
|
function _adaptersConnectAs(connect, opts) {
|
|
@@ -1069,6 +1603,31 @@ function _adaptersConnectAs(connect, opts) {
|
|
|
1069
1603
|
//
|
|
1070
1604
|
// currentRole() returns the active role (or null) — useful for diagnostic
|
|
1071
1605
|
// logs and observability labels.
|
|
1606
|
+
/**
|
|
1607
|
+
* @primitive b.externalDb.runAs
|
|
1608
|
+
* @signature b.externalDb.runAs(role, fn)
|
|
1609
|
+
* @since 0.4.0
|
|
1610
|
+
* @related b.externalDb.currentRole, b.externalDb.adapters.connectAs
|
|
1611
|
+
*
|
|
1612
|
+
* Bind a SQL role on the deep async-local context for the duration of
|
|
1613
|
+
* `fn()`. Every `b.externalDb.query` / `read.query` / `write.query` /
|
|
1614
|
+
* `transaction` call inside the bound region picks the backend mapped
|
|
1615
|
+
* to `role` via the `dbRoleBackends` map declared at `init()`, so
|
|
1616
|
+
* background workers (cron, queue consumers, CLI commands) get the
|
|
1617
|
+
* same role-aware routing as HTTP requests under
|
|
1618
|
+
* `b.middleware.dbRoleFor`. Pass `null` to clear. Audits role
|
|
1619
|
+
* transitions as `db.role.switched`. Identifier-validates the role at
|
|
1620
|
+
* the call site so a typo throws synchronously.
|
|
1621
|
+
*
|
|
1622
|
+
* @example
|
|
1623
|
+
* await b.externalDb.runAs("analytics_user", async function () {
|
|
1624
|
+
* var res = await b.externalDb.read.query(
|
|
1625
|
+
* "SELECT count(*) AS n FROM events WHERE day = $1",
|
|
1626
|
+
* ["2026-05-09"]
|
|
1627
|
+
* );
|
|
1628
|
+
* return res.rows[0].n;
|
|
1629
|
+
* });
|
|
1630
|
+
*/
|
|
1072
1631
|
function runAs(role, fn) {
|
|
1073
1632
|
if (typeof fn !== "function") {
|
|
1074
1633
|
throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
|
|
@@ -1104,10 +1663,195 @@ function runAs(role, fn) {
|
|
|
1104
1663
|
return dbRoleContext.runWithRole(role || null, fn);
|
|
1105
1664
|
}
|
|
1106
1665
|
|
|
1666
|
+
/**
|
|
1667
|
+
* @primitive b.externalDb.currentRole
|
|
1668
|
+
* @signature b.externalDb.currentRole()
|
|
1669
|
+
* @since 0.4.0
|
|
1670
|
+
* @related b.externalDb.runAs
|
|
1671
|
+
*
|
|
1672
|
+
* Read the SQL role bound on the deep async-local context. Returns
|
|
1673
|
+
* `null` when no role is bound. Useful for diagnostic logs, audit
|
|
1674
|
+
* metadata, and observability labels — the value flows through the
|
|
1675
|
+
* same context that `b.externalDb.query` consults for backend pick.
|
|
1676
|
+
*
|
|
1677
|
+
* @example
|
|
1678
|
+
* await b.externalDb.runAs("analytics_user", async function () {
|
|
1679
|
+
* b.externalDb.currentRole(); // → "analytics_user"
|
|
1680
|
+
* });
|
|
1681
|
+
* b.externalDb.currentRole(); // → null
|
|
1682
|
+
*/
|
|
1107
1683
|
function currentRole() {
|
|
1108
1684
|
return dbRoleContext.getRole();
|
|
1109
1685
|
}
|
|
1110
1686
|
|
|
1687
|
+
// OWASP-2 — pg_roles enumeration / unrecognized-role guard.
|
|
1688
|
+
//
|
|
1689
|
+
// Boot-time check that compares pg_roles membership to the operator-
|
|
1690
|
+
// declared role list. Operators declare every role they expect to
|
|
1691
|
+
// exist on the cluster (via opts.declaredRoles); the gate refuses or
|
|
1692
|
+
// audits when pg_roles surfaces names not in that list — typical
|
|
1693
|
+
// signal of a forgotten ALTER ROLE / a leftover migration role / a
|
|
1694
|
+
// privileged role added outside change-management.
|
|
1695
|
+
//
|
|
1696
|
+
// await b.externalDb.assertRoleHardening({
|
|
1697
|
+
// backend: "main",
|
|
1698
|
+
// declaredRoles: ["app_user", "analytics_user", "admin"],
|
|
1699
|
+
// mode: "audit", // "audit" | "throw"
|
|
1700
|
+
// ignoreSystem: true, // skip rds_*, pg_*, postgres
|
|
1701
|
+
// });
|
|
1702
|
+
//
|
|
1703
|
+
// Returns { declared, observed, unrecognized, missing }. mode="throw"
|
|
1704
|
+
// raises ROLE_HARDENING_FAIL when unrecognized rows surface; default
|
|
1705
|
+
// "audit" emits db.role.hardening.unrecognized so dashboards see the
|
|
1706
|
+
// drift without breaking boot.
|
|
1707
|
+
/**
|
|
1708
|
+
* @primitive b.externalDb.assertRoleHardening
|
|
1709
|
+
* @signature b.externalDb.assertRoleHardening(opts)
|
|
1710
|
+
* @since 0.7.0
|
|
1711
|
+
* @related b.externalDb.runAs, b.externalDb.adapters.connectAs
|
|
1712
|
+
*
|
|
1713
|
+
* Compare `pg_roles` membership against an operator-declared role
|
|
1714
|
+
* allowlist on a Postgres backend. Surfaces unrecognized roles
|
|
1715
|
+
* (forgotten ALTER ROLE leftovers, migration roles, privileged grants
|
|
1716
|
+
* added outside change-management) and missing roles (declared but not
|
|
1717
|
+
* present). Default `mode: "audit"` emits
|
|
1718
|
+
* `db.role.hardening.unrecognized` / `.ok` so dashboards see drift
|
|
1719
|
+
* without breaking boot; `mode: "throw"` fails boot when unrecognized
|
|
1720
|
+
* roles surface. Non-Postgres dialects emit `db.role.hardening.skipped`
|
|
1721
|
+
* and return empty observed lists.
|
|
1722
|
+
*
|
|
1723
|
+
* @opts
|
|
1724
|
+
* declaredRoles: string[], // required; allowlist of expected role names
|
|
1725
|
+
* backend?: string, // explicit backend name (defaults to defaultBackend)
|
|
1726
|
+
* mode?: "audit" | "throw", // default "audit"
|
|
1727
|
+
* ignoreSystem?: boolean, // skip postgres / pg_* / rds_* / azure_* / cloudsqlsuperuser (default true)
|
|
1728
|
+
*
|
|
1729
|
+
* @example
|
|
1730
|
+
* var report = await b.externalDb.assertRoleHardening({
|
|
1731
|
+
* backend: "main",
|
|
1732
|
+
* declaredRoles: ["app_user", "analytics_user", "admin"],
|
|
1733
|
+
* mode: "audit",
|
|
1734
|
+
* ignoreSystem: true,
|
|
1735
|
+
* });
|
|
1736
|
+
* report.unrecognized; // → []
|
|
1737
|
+
* report.missing; // → []
|
|
1738
|
+
* report.observed; // → ["admin", "analytics_user", "app_user"]
|
|
1739
|
+
*/
|
|
1740
|
+
async function assertRoleHardening(opts) {
|
|
1741
|
+
_requireInit();
|
|
1742
|
+
if (!opts || typeof opts !== "object") {
|
|
1743
|
+
throw _err("INVALID_CONFIG",
|
|
1744
|
+
"assertRoleHardening: opts is required ({ declaredRoles, backend?, mode? })", true);
|
|
1745
|
+
}
|
|
1746
|
+
if (!Array.isArray(opts.declaredRoles)) {
|
|
1747
|
+
throw _err("INVALID_CONFIG",
|
|
1748
|
+
"assertRoleHardening: opts.declaredRoles must be an array of role names", true);
|
|
1749
|
+
}
|
|
1750
|
+
for (var i = 0; i < opts.declaredRoles.length; i++) {
|
|
1751
|
+
var r = opts.declaredRoles[i];
|
|
1752
|
+
if (typeof r !== "string" || r.length === 0) {
|
|
1753
|
+
throw _err("INVALID_CONFIG",
|
|
1754
|
+
"assertRoleHardening: declaredRoles[" + i + "] must be a non-empty string", true);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
var mode = opts.mode || "audit";
|
|
1758
|
+
if (mode !== "audit" && mode !== "throw") {
|
|
1759
|
+
throw _err("INVALID_CONFIG",
|
|
1760
|
+
"assertRoleHardening: mode must be 'audit' or 'throw' (got '" + mode + "')", true);
|
|
1761
|
+
}
|
|
1762
|
+
var backendName = opts.backend || defaultBackend;
|
|
1763
|
+
var b = backends[backendName];
|
|
1764
|
+
if (!b) {
|
|
1765
|
+
throw _err("UNKNOWN_BACKEND",
|
|
1766
|
+
"assertRoleHardening: no backend named '" + backendName + "'", true);
|
|
1767
|
+
}
|
|
1768
|
+
if (b.dialect !== "postgres") {
|
|
1769
|
+
// Non-Postgres dialects don't have pg_roles. The check is a no-op
|
|
1770
|
+
// with a clear audit row so operators see the skip rather than
|
|
1771
|
+
// assume hardening ran.
|
|
1772
|
+
audit().safeEmit({
|
|
1773
|
+
action: "db.role.hardening.skipped",
|
|
1774
|
+
actor: {},
|
|
1775
|
+
resource: { kind: "db.backend", id: backendName },
|
|
1776
|
+
outcome: "success",
|
|
1777
|
+
metadata: { dialect: b.dialect, reason: "non-postgres" },
|
|
1778
|
+
});
|
|
1779
|
+
return { declared: opts.declaredRoles.slice(), observed: [], unrecognized: [], missing: [] };
|
|
1780
|
+
}
|
|
1781
|
+
var ignoreSystem = opts.ignoreSystem !== false; // default true
|
|
1782
|
+
var rows;
|
|
1783
|
+
try {
|
|
1784
|
+
var res = await query(
|
|
1785
|
+
"SELECT rolname FROM pg_roles ORDER BY rolname",
|
|
1786
|
+
[],
|
|
1787
|
+
{ backend: backendName }
|
|
1788
|
+
);
|
|
1789
|
+
rows = (res && res.rows) || [];
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
audit().safeEmit({
|
|
1792
|
+
action: "db.role.hardening.unreadable",
|
|
1793
|
+
actor: {},
|
|
1794
|
+
resource: { kind: "db.backend", id: backendName },
|
|
1795
|
+
outcome: "failure",
|
|
1796
|
+
reason: (e && e.message) || String(e),
|
|
1797
|
+
metadata: { backend: backendName },
|
|
1798
|
+
});
|
|
1799
|
+
throw _err("ROLE_HARDENING_UNREADABLE",
|
|
1800
|
+
"assertRoleHardening: could not read pg_roles on backend '" + backendName + "': " +
|
|
1801
|
+
((e && e.message) || String(e)), true);
|
|
1802
|
+
}
|
|
1803
|
+
var observed = rows.map(function (r) { return r.rolname; });
|
|
1804
|
+
if (ignoreSystem) {
|
|
1805
|
+
observed = observed.filter(function (n) {
|
|
1806
|
+
return !(n === "postgres" || n.indexOf("pg_") === 0 || n.indexOf("rds_") === 0 ||
|
|
1807
|
+
n.indexOf("rdsadmin") === 0 || n.indexOf("azure_") === 0 ||
|
|
1808
|
+
n.indexOf("cloudsqlsuperuser") === 0);
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
var declaredSet = {};
|
|
1812
|
+
opts.declaredRoles.forEach(function (n) { declaredSet[n] = true; });
|
|
1813
|
+
var observedSet = {};
|
|
1814
|
+
observed.forEach(function (n) { observedSet[n] = true; });
|
|
1815
|
+
var unrecognized = observed.filter(function (n) { return !declaredSet[n]; });
|
|
1816
|
+
var missing = opts.declaredRoles.filter(function (n) { return !observedSet[n]; });
|
|
1817
|
+
if (unrecognized.length > 0 || missing.length > 0) {
|
|
1818
|
+
audit().safeEmit({
|
|
1819
|
+
action: "db.role.hardening.unrecognized",
|
|
1820
|
+
actor: {},
|
|
1821
|
+
resource: { kind: "db.backend", id: backendName },
|
|
1822
|
+
outcome: unrecognized.length > 0 ? "denied" : "failure",
|
|
1823
|
+
metadata: {
|
|
1824
|
+
backend: backendName,
|
|
1825
|
+
unrecognized: unrecognized,
|
|
1826
|
+
missing: missing,
|
|
1827
|
+
observedCount: observed.length,
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
if (mode === "throw" && unrecognized.length > 0) {
|
|
1831
|
+
throw _err("ROLE_HARDENING_FAIL",
|
|
1832
|
+
"assertRoleHardening: pg_roles surfaces " + unrecognized.length +
|
|
1833
|
+
" unrecognized role(s) on backend '" + backendName + "': " +
|
|
1834
|
+
unrecognized.join(", ") + ". Either add them to declaredRoles after " +
|
|
1835
|
+
"review, REVOKE them, or set mode: 'audit' to downgrade to audit-only.",
|
|
1836
|
+
true);
|
|
1837
|
+
}
|
|
1838
|
+
} else {
|
|
1839
|
+
audit().safeEmit({
|
|
1840
|
+
action: "db.role.hardening.ok",
|
|
1841
|
+
actor: {},
|
|
1842
|
+
resource: { kind: "db.backend", id: backendName },
|
|
1843
|
+
outcome: "success",
|
|
1844
|
+
metadata: { backend: backendName, observedCount: observed.length },
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
return {
|
|
1848
|
+
declared: opts.declaredRoles.slice(),
|
|
1849
|
+
observed: observed,
|
|
1850
|
+
unrecognized: unrecognized,
|
|
1851
|
+
missing: missing,
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1111
1855
|
module.exports = {
|
|
1112
1856
|
init: init,
|
|
1113
1857
|
query: query,
|
|
@@ -1115,11 +1859,12 @@ module.exports = {
|
|
|
1115
1859
|
healthCheck: healthCheck,
|
|
1116
1860
|
listBackends: listBackends,
|
|
1117
1861
|
shutdown: shutdown,
|
|
1118
|
-
configurePool:
|
|
1119
|
-
read:
|
|
1120
|
-
write:
|
|
1121
|
-
runAs:
|
|
1122
|
-
currentRole:
|
|
1862
|
+
configurePool: configurePool,
|
|
1863
|
+
read: read,
|
|
1864
|
+
write: write,
|
|
1865
|
+
runAs: runAs,
|
|
1866
|
+
currentRole: currentRole,
|
|
1867
|
+
assertRoleHardening: assertRoleHardening,
|
|
1123
1868
|
adapters: {
|
|
1124
1869
|
connectAs: _adaptersConnectAs,
|
|
1125
1870
|
},
|