@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2
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/components/DESIGN.md +421 -0
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/config/DESIGN.md +306 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/RecordEncoder.js +25 -5
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Table.js +48 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +39 -13
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/databases.js +1 -1
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/recordLockCoordinator.d.ts +5 -5
- package/dist/resources/recordLockCoordinator.js +48 -16
- package/dist/resources/recordLockCoordinator.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +3 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +6 -3
- package/dist/server/REST.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +14 -3
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/utility/logging/harper_logger.js +1 -0
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/index.ts +3 -0
- package/npm-shrinkwrap.json +104 -104
- package/package.json +6 -5
- package/resources/DESIGN.md +568 -3
- package/resources/RecordEncoder.ts +27 -5
- package/resources/Table.ts +49 -3
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +40 -14
- package/resources/crdt.ts +22 -0
- package/resources/databases.ts +1 -1
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLockCoordinator.ts +61 -22
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +3 -0
- package/server/DESIGN.md +264 -0
- package/server/REST.ts +6 -3
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/serverUtilities.ts +14 -3
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/logging/harper_logger.ts +1 -0
|
@@ -239,11 +239,11 @@ export interface DelegationReply {
|
|
|
239
239
|
dependencies?: LockDependencySet | null;
|
|
240
240
|
/**
|
|
241
241
|
* Denied only. `contended` is the one reason that means another node holds the key, and so the only
|
|
242
|
-
* one
|
|
243
|
-
* `unknown-node` (this node is not named in the map)
|
|
244
|
-
* restart interval)
|
|
245
|
-
*
|
|
246
|
-
*
|
|
242
|
+
* one an exhausted wait may report as 423. `generation` (the two sides hold different home maps),
|
|
243
|
+
* `unknown-node` (this node is not named in the map) and `quarantine` (the home is inside its §4.3
|
|
244
|
+
* restart interval) all describe something other than contention, so each ends as a retryable 503
|
|
245
|
+
* rather than telling the caller a key nobody holds is held. `timeout` is not a home answer at all
|
|
246
|
+
* — it is this node's own deadline ending its probe — and never classifies a wait (DESIGN.md).
|
|
247
247
|
*/
|
|
248
248
|
reason?: 'contended' | 'generation' | 'unknown-node' | 'capacity' | 'not-home' | 'quarantine' | 'timeout';
|
|
249
249
|
/** Denied with `generation`, so a stale requester can re-derive the ring without another round trip. */
|
|
@@ -251,6 +251,13 @@ export interface DelegationReply {
|
|
|
251
251
|
retryAfterMs?: number;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/** A completed home reply, bound to the route that produced it so a ring change retires it. */
|
|
255
|
+
interface LastCompletedReply {
|
|
256
|
+
reply: DelegationReply;
|
|
257
|
+
home: string;
|
|
258
|
+
generation: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
254
261
|
export interface DelegationRequest {
|
|
255
262
|
key: any;
|
|
256
263
|
/** The asking node. Established by the transport, never read from an untrusted payload. */
|
|
@@ -1157,13 +1164,17 @@ export class LockCoordinator {
|
|
|
1157
1164
|
* The amortization is the first branch: a live, un-recalled delegation with enough time left costs
|
|
1158
1165
|
* zero cluster messages.
|
|
1159
1166
|
*/
|
|
1160
|
-
|
|
1167
|
+
acquire(key: any, leaseMs: number, waitMs: number): Promise<LockRound> {
|
|
1168
|
+
return this.#acquire(key, leaseMs, waitMs);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async #acquire(key: any, leaseMs: number, waitMs: number, observed?: LastCompletedReply): Promise<LockRound> {
|
|
1161
1172
|
// `Table.lock()` captures a coordinator and only reaches here after the native key lock, which
|
|
1162
1173
|
// can wait the caller's whole timeout — long enough for a transport swap to close what it
|
|
1163
1174
|
// captured. Authority moved to the successor rather than away, so run there instead of
|
|
1164
1175
|
// rejecting a caller that is already holding the key.
|
|
1165
1176
|
const authority = this.#authority();
|
|
1166
|
-
if (authority !== this) return authority
|
|
1177
|
+
if (authority !== this) return authority.#acquire(key, leaseMs, waitMs, observed);
|
|
1167
1178
|
if (this.#closed) throw new LockUnavailableError('Cluster record lock coordination was closed for this table');
|
|
1168
1179
|
if (!this.transport.ownsCoordination()) {
|
|
1169
1180
|
// Off the coordinating thread. A transport that can relay obtains the admission from the
|
|
@@ -1180,6 +1191,7 @@ export class LockCoordinator {
|
|
|
1180
1191
|
}
|
|
1181
1192
|
const keyId = this.#keyIdOf(key);
|
|
1182
1193
|
const deadlineMono = this.#monotonic() + waitMs;
|
|
1194
|
+
let lastCompleted = observed;
|
|
1183
1195
|
|
|
1184
1196
|
acquisition: for (;;) {
|
|
1185
1197
|
const homeMap = this.transport.homeMap(this.database);
|
|
@@ -1360,25 +1372,39 @@ export class LockCoordinator {
|
|
|
1360
1372
|
// which the terminal answer below is what handles.
|
|
1361
1373
|
warnOnce('record lock home disagreed about the ring', { database: this.database, table: this.table });
|
|
1362
1374
|
|
|
1375
|
+
if (reply.reason !== 'timeout') lastCompleted = { reply, home, generation: homeMap.generation };
|
|
1363
1376
|
const remaining = deadlineMono - this.#monotonic();
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
`Could not establish a cluster record lock on ${this.database}.${this.table} within the wait: the key's home answered ${reply.reason ?? (reply.granted ? 'grants that arrived too late to use' : 'nothing usable')}`
|
|
1371
|
-
);
|
|
1372
|
-
}
|
|
1373
|
-
await delay(Math.min(reply.retryAfterMs ?? 25, remaining)).promise;
|
|
1377
|
+
const retryAfterMs = reply.retryAfterMs ?? 25;
|
|
1378
|
+
// A home this node IS costs nothing to ask again — `#grantLocally` is synchronous, so a release
|
|
1379
|
+
// landing in the backoff is still grantable at the deadline. A remote home is not: its request
|
|
1380
|
+
// would go out with the leftover budget and could only return this node's own `timeout`.
|
|
1381
|
+
const exhausted = home === this.nodeId ? remaining <= 0 : remaining <= retryAfterMs;
|
|
1382
|
+
if (!exhausted) await delay(Math.min(retryAfterMs, remaining)).promise;
|
|
1374
1383
|
if (this.#closed) {
|
|
1375
|
-
// Same swap, landing in the backoff instead.
|
|
1376
|
-
//
|
|
1384
|
+
// Same swap, landing in the backoff instead. The successor inherits both the remaining wait
|
|
1385
|
+
// and what this one saw, since a successor exhausted on arrival has nothing of its own.
|
|
1377
1386
|
const successor = this.#authority();
|
|
1378
1387
|
if (successor === this)
|
|
1379
1388
|
throw new LockUnavailableError('Cluster record lock coordination was closed for this table');
|
|
1380
|
-
return successor
|
|
1389
|
+
return successor.#acquire(key, leaseMs, Math.max(0, deadlineMono - this.#monotonic()), lastCompleted);
|
|
1381
1390
|
}
|
|
1391
|
+
if (!exhausted) continue acquisition;
|
|
1392
|
+
// Only an observation made under the generation that is current NOW still describes the key —
|
|
1393
|
+
// this pass's reply included, since a generation can be activated while the probe that ended
|
|
1394
|
+
// the wait is still in flight. Hence a fresh read rather than the copy this pass started from.
|
|
1395
|
+
const currentGeneration = this.transport.homeMap(this.database)?.generation;
|
|
1396
|
+
const carried =
|
|
1397
|
+
lastCompleted?.home === home && lastCompleted.generation === currentGeneration
|
|
1398
|
+
? lastCompleted.reply
|
|
1399
|
+
: undefined;
|
|
1400
|
+
const terminal = carried ?? (homeMap.generation === currentGeneration ? reply : undefined);
|
|
1401
|
+
// Only `contended` may end as 423: it is the one answer that says another node holds the key,
|
|
1402
|
+
// and reporting contention for anything else sends the caller to retry a condition no wait
|
|
1403
|
+
// outlasts. A wait can see `contended` and still end 503 — the rule is what the home said LAST.
|
|
1404
|
+
if (terminal?.reason === 'contended') throw new ClientError('Record is locked and was not released in time', 423);
|
|
1405
|
+
throw new LockUnavailableError(
|
|
1406
|
+
`Could not establish a cluster record lock on ${this.database}.${this.table} within the wait: ${describeExhaustedWait(terminal, currentGeneration !== undefined)}`
|
|
1407
|
+
);
|
|
1382
1408
|
}
|
|
1383
1409
|
}
|
|
1384
1410
|
|
|
@@ -2400,8 +2426,6 @@ export class LockCoordinator {
|
|
|
2400
2426
|
requested,
|
|
2401
2427
|
timeout.promise.then(() => {
|
|
2402
2428
|
raced = true;
|
|
2403
|
-
// `timeout`, not `contended`: the home never answered, so nobody was shown to hold the
|
|
2404
|
-
// key. Retried the same way, but a wait that ends here is 503 rather than 423.
|
|
2405
2429
|
return { granted: false, reason: 'timeout', retryAfterMs: 0 } as DelegationReply;
|
|
2406
2430
|
}),
|
|
2407
2431
|
]);
|
|
@@ -2694,6 +2718,21 @@ export class LockCoordinator {
|
|
|
2694
2718
|
}
|
|
2695
2719
|
}
|
|
2696
2720
|
|
|
2721
|
+
/**
|
|
2722
|
+
* What ended an exhausted `acquire()`, for the 503 it throws. A `timeout` is not phrased as a home
|
|
2723
|
+
* answer: it is this node's own deadline, and an operator told "the home answered timeout" looks for
|
|
2724
|
+
* a fault on a node that was simply not waited for.
|
|
2725
|
+
*/
|
|
2726
|
+
function describeExhaustedWait(terminal: DelegationReply | undefined, hasCurrentMap: boolean): string {
|
|
2727
|
+
if (terminal)
|
|
2728
|
+
return terminal.reason === 'timeout'
|
|
2729
|
+
? "no reply from the key's home within the wait"
|
|
2730
|
+
: `the key's home answered ${terminal.reason ?? (terminal.granted ? 'grants that arrived too late to use' : 'nothing usable')}`;
|
|
2731
|
+
return hasCurrentMap
|
|
2732
|
+
? 'the record lock home map changed generation before the wait ended'
|
|
2733
|
+
: 'this node has no current record lock home map';
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2697
2736
|
/** A sleep whose timer the winner of a race can drop, rather than let it run out its full delay. */
|
|
2698
2737
|
function delay(ms: number): { promise: Promise<void>; cancel: () => void } {
|
|
2699
2738
|
let timer: ReturnType<typeof setTimeout>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# resources/scheduler/ — Design notes
|
|
2
|
+
|
|
3
|
+
The built-in scheduler plugin.
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching job leadership, leases or heartbeats.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Scheduler: cluster-once execution without a consensus primitive (`resources/scheduler/`)
|
|
12
|
+
|
|
13
|
+
The built-in `scheduler` plugin (#951) runs config-declared jobs "exactly once per cluster." The
|
|
14
|
+
non-obvious part is what Harper's substrate does and does not offer for that:
|
|
15
|
+
|
|
16
|
+
- **There is no election, consensus, or cross-node CAS anywhere in harper/harper-pro.** Replication
|
|
17
|
+
converges concurrent writes by record version (LWW). A lease acquired with a plain `put` therefore
|
|
18
|
+
cannot be race-free by construction — two nodes writing the lease both succeed locally and
|
|
19
|
+
converge later. The engine's design accepts this: sticky leadership (a starter defers to a fresh
|
|
20
|
+
heartbeat), a heartbeat takeover check (a leader seeing a fresher foreign heartbeat steps down),
|
|
21
|
+
and documented handler idempotency are the mechanisms that ride out transient dual-leadership.
|
|
22
|
+
Do not "fix" the race with a conditional write — the primitive does not exist.
|
|
23
|
+
- The lease + per-job run state live in the replicating system table `hdb_scheduler_state`
|
|
24
|
+
(`audit: true` because system-table replication requires auditing; name chosen because `hdb_job`
|
|
25
|
+
is taken by the legacy jobs subsystem). On constrained/directional replication topologies the
|
|
26
|
+
lease may not reach every node; that limitation is inherent.
|
|
27
|
+
- The alphabetical node-name tie-break deliberately mirrors replication's deterministic failover
|
|
28
|
+
convention (sorted node names in `subscriptionManager.ts`); the escalation ladder
|
|
29
|
+
(`promotionWaitMs`) exists because a dead alphabetically-first node must not deadlock a
|
|
30
|
+
leaderless cluster (each successive node waits one more `2 × watcher interval` rung).
|
|
31
|
+
- Thread-once vs cluster-once are separate layers: `getWorkerIndex() === 0` gates to one worker per
|
|
32
|
+
node (correct in every threading mode incl. `threads: 0`); the lease gates across nodes.
|
|
33
|
+
`handleApplication` holds a cross-thread load lock with a 30s timeout, so the plugin only
|
|
34
|
+
registers there — election, scheduling, and catch-up run async after.
|
|
35
|
+
- Catch-up fires at most ONE missed occurrence per cron job, and a new job's `firstSeenAt` baseline
|
|
36
|
+
prevents firing immediately on first deploy. Interval jobs are excluded from the sweep — they
|
|
37
|
+
self-correct by anchoring to their persisted last run in `scheduleNextRun`.
|
|
38
|
+
- Timer firing is a deliberately thin `setTimeout` layer so the durable timer service (#754) can
|
|
39
|
+
replace it without touching the config surface, election, or catch-up. Timing thresholds are
|
|
40
|
+
env-overridable (`HARPER_SCHEDULER_*`) so multi-node tests can exercise failover in seconds.
|
|
@@ -192,7 +192,7 @@ function notifyFromTransactionData(subscriptions, auditLogIterable?, allowYield
|
|
|
192
192
|
let ancestorLevel = 0;
|
|
193
193
|
do {
|
|
194
194
|
// we iterate through the key hierarchy, notifying all subscribers for each key,
|
|
195
|
-
// so for an id like resource/foo/bar, we notify subscribers for resource/foo/bar, resource/foo/, resource
|
|
195
|
+
// so for an id like resource/foo/bar, we notify subscribers for resource/foo/bar, resource/foo/, resource/, and the root (null)
|
|
196
196
|
// this allows for efficient subscriptions to children ids/topics
|
|
197
197
|
const keySubscriptions = tableSubscriptions.get(matchingKey);
|
|
198
198
|
if (keySubscriptions) {
|
|
@@ -230,9 +230,9 @@ function notifyFromTransactionData(subscriptions, auditLogIterable?, allowYield
|
|
|
230
230
|
if (lastSlash !== matchingKey.length - 1) {
|
|
231
231
|
ancestorLevel++; // don't increase the ancestor level for this going from resource/ to resource
|
|
232
232
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
233
|
+
// lastIndexOf clamps a negative fromIndex to 0, so '/' would otherwise yield itself forever
|
|
234
|
+
const parentKey = lastSlash > -1 ? matchingKey.slice(0, lastSlash + 1) : null;
|
|
235
|
+
matchingKey = parentKey === matchingKey ? null : parentKey;
|
|
236
236
|
} while (true);
|
|
237
237
|
}
|
|
238
238
|
} else if (auditRecord.type === 'reload') {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# security/ — Design notes
|
|
2
|
+
|
|
3
|
+
Authentication tokens, OIDC exchange and TLS material.
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching `tokenAuthentication.ts`, `impersonation.ts`, `authn/oidc/` or `keys.ts`.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## OIDC trusted publishing (`security/authn/oidc/`)
|
|
12
|
+
|
|
13
|
+
`exchange_oidc_token` lets a workload authenticate with no stored Harper credential (#2171): it presents an identity token minted by its runtime, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [])` registration).
|
|
14
|
+
|
|
15
|
+
**The core is issuer-agnostic; everything issuer-specific lives in `providers/`.** That split is the point of the layout, not an accident of it — a new workload-identity issuer should be a profile, not a change to verification, matching, or storage.
|
|
16
|
+
|
|
17
|
+
- `claims.ts` — matching and constraint _shape_ validation. Knows nothing about any issuer.
|
|
18
|
+
- `jwks.ts` — issuer keys. The rate-limit clock for unknown-`kid` refetches lives _outside_ the cache entry: a successful fetch replaces the entry, and a rate limit that resets whenever it fires is not a rate limit. Keeping it separate also means a genuine key rotation is picked up on first use rather than after the window.
|
|
19
|
+
- `identityToken.ts` — signature, issuer, audience, `exp`, and a bounded lifetime. Owns `rejectToken`, shared with the exchange so both halves refuse identically.
|
|
20
|
+
- `tokenExchange.ts` — policy selection, replay, minting, audit. Verification is memoized per audience, so N policies sharing one cost one signature check.
|
|
21
|
+
- `providers/` — `assertPolicyIsSpecific` / `assertAudienceIsSpecific` / `normalizeClaims` / `describePrincipal` / optional `vetoClaims`, resolved by normalized issuer.
|
|
22
|
+
|
|
23
|
+
**An unregistered issuer gets `providers/generic.ts`, which is strict rather than permissive:** the policy must pin `sub`. That is what makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code — each has a stable canonical subject. GitHub needs its own profile precisely because its `sub` is the one claim you should _not_ pin: it varies by trigger, and its format changed for repositories created after 2026-07-15.
|
|
24
|
+
|
|
25
|
+
Four constraints that look like choices but are not:
|
|
26
|
+
|
|
27
|
+
1. **Every rejection returns the same message.** The endpoint is unauthenticated; a caller told which check failed can enumerate a policy one claim at a time. Reasons go to the `oidc-trust` logger.
|
|
28
|
+
2. **A GitHub policy must gate the ref.** `githubActionsProfile.assertPolicyIsSpecific` rejects a policy pinning only repository + workflow, because anyone who can push a branch could then add that workflow to it and mint a token. Stricter than npm's trusted-publishing model, which mitigates the same hole with environment protection instead — and profile-scoped, so it never constrains another issuer.
|
|
29
|
+
3. **`createOperationToken`, not `createTokens`.** `createTokens` overwrites `hdb_user.refresh_token` as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature removes.
|
|
30
|
+
4. **The role is the boundary; the per-policy `operations` allowlist only narrows it.** Least privilege is primarily the role of the user the policy names. A policy may _optionally_ carry an `operations` scope, which can only subtract from that role — never add to it. It is deliberately not merged into `permission.operations`: gate 2 in `operation_authorization.ts` treats an explicit listing of an SU-only operation as a deliberate grant, so reusing that field would _widen_ where this must only narrow. The scope is carried as a separate `tokenOperations` claim and intersected ahead of every early return, including the super_user bypass.
|
|
31
|
+
|
|
32
|
+
Its enforcement surface is the operations API and SQL (`verifyPerms` / `verifyPermsAST`) — **not** the application REST/GraphQL resource path, which authorizes through table-level `checkPermission` and does not consult the scope. A scoped token therefore still carries its role's full CRUD there, which is why the role has to be least-privilege on its own; the scope is defense in depth, not a substitute. Closing that gap is a follow-up on the same surface as CORE-3061. Because a second authorization mechanism beside roles is one more place for the two to disagree, whether to keep this at all is an open design question on #2173 rather than a settled constraint.
|
|
33
|
+
|
|
34
|
+
Naming `sql` in a scope grants the SQL interface, not unrestricted DML through it: a write statement additionally requires its matching data operation (`insert`/`update`/`delete`) in scope. That is what keeps `read_only` — which expands to include `sql` — from admitting a DELETE, given that `verifyPermsAST` returns early for a super_user before any table check runs.
|
|
35
|
+
|
|
36
|
+
`hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent tokens keyed on a SHA-256 of the token itself, with `expiresAt` past the token's own expiry. Hashed rather than stored, so the table never holds a credential; keyed on the token's **signed input** (`header.payload`) rather than `jti` because not every issuer emits one (Azure uses `uti`). Not on the whole token string: the signature segment is covered by nothing, and base64url decoding ignores the surplus low bits of its final character, so 16 distinct spellings of an RS256 signature decode to the same bytes, all verify, and all hash differently — one leaked token would buy 16 exchanges. ES\* malleability (`s → n−s`) is a second such vector. The signed input is exactly what the issuer asserted, so every variant collapses to one fingerprint. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway.
|
|
37
|
+
|
|
38
|
+
## Scoped tokens and synthetic-role identity (`security/tokenAuthentication.ts`, `security/impersonation.ts`)
|
|
39
|
+
|
|
40
|
+
`create_authentication_tokens` with an inline `role` **object** mints a `sub: 'scoped-operation'`
|
|
41
|
+
JWT that embeds its whole (downgraded, deep-validated) permission set; the bearer needs no
|
|
42
|
+
`hdb_user`/`hdb_role` row and the `username` is attribution only. Minting is super_user-gated
|
|
43
|
+
(or trusted internal dispatch via `isOperationAuthorizationBypassed()`); a string `role` keeps its
|
|
44
|
+
legacy meaning (component-defined token, rejected by `validateOperationToken`). Scoped tokens get
|
|
45
|
+
no refresh token, touch no user record, and are therefore **irrevocable until expiry** — expiry is
|
|
46
|
+
the only control, which is why `auth.ts` evicts cached Bearer identities at exact `authExpiresAt`
|
|
47
|
+
rather than waiting for the auth-cache TTL.
|
|
48
|
+
|
|
49
|
+
The attribution `username` must NOT name an existing `hdb_user` (rejected at mint; the default is
|
|
50
|
+
`scoped:<minter>`): code paths that rehydrate a user by name would otherwise substitute the real
|
|
51
|
+
principal's permissions for the token's — or fail-closed on the non-existent name. The three known
|
|
52
|
+
by-name sites are handled, all by the same `_scopedToken` short-circuit: the MQTT last-will replay
|
|
53
|
+
(`DurableSubscriptionsSession.ts` persists the scoped role/marker/expiry on the will and skips
|
|
54
|
+
rehydration — and both the restart-replay and the live abnormal-disconnect paths refuse to publish
|
|
55
|
+
a scoped will past `authExpiresAt`), the live-subscription stale-auth recheck (`Resource.ts`
|
|
56
|
+
`registerLiveSubscriptionForContext` keeps the embedded role as the identity), and the MCP
|
|
57
|
+
`list_changed` session refresh (`components/mcp/listChanged.ts` `refreshSessionUser`). The scoped
|
|
58
|
+
principal also cannot self-mint standing tokens: the passwordless path of `createTokens` rejects an
|
|
59
|
+
`hdb_user._scopedToken` requester. **Any future by-name rehydration must check `_scopedToken`.** A
|
|
60
|
+
user _created after minting_ with a colliding name is therefore inert at every current site; the
|
|
61
|
+
residual is only some _new_ unguarded by-name site — another reason to prefer short expiries.
|
|
62
|
+
|
|
63
|
+
Scope of the `operations` allowlist: it gates the **operations API** (including the `sql` path,
|
|
64
|
+
which never reaches `verifyPerms` and calls `verifyOperationsAllowlist` directly from
|
|
65
|
+
`chooseOperation`) — it does NOT gate the application/REST/GraphQL/MQTT surfaces, which authorize
|
|
66
|
+
on translated table CRUD permissions only. A scoped token intended to be read-only on app
|
|
67
|
+
endpoints must carry restrictive table permissions; `operations: ['read_only']` alone does not
|
|
68
|
+
constrain REST writes if table perms allow them.
|
|
69
|
+
|
|
70
|
+
The invariant to preserve when touching any synthetic (inline/impersonated/scoped) role:
|
|
71
|
+
`permissionsTranslator.getRolePermissions` memoizes translated permissions **by role name** (keyed
|
|
72
|
+
further by `__updatedtime__` + schema). A synthetic role must therefore never carry a constant
|
|
73
|
+
name or a per-request timestamp — two different permission sets would alias one cache slot (a
|
|
74
|
+
same-millisecond `Date.now()` was enough), leaking one principal's translated permissions to
|
|
75
|
+
another. `syntheticRoleName()` derives the name from a hash of the post-downgrade permission
|
|
76
|
+
content with `__updatedtime__: 0`, so identical sets share a slot and distinct sets can't collide;
|
|
77
|
+
`applyImpersonation` re-keys all three impersonation modes the same way (Mode B/C previously wrote
|
|
78
|
+
downgraded copies under the _persisted_ role's name). Synthetic translations live in a separate
|
|
79
|
+
256-entry LRU (`syntheticRolePermsMap`), not the permanent `rolePermsMap` — so >256 concurrently
|
|
80
|
+
live distinct permission sets degrade to per-request translation (a deliberate cliff; raise the
|
|
81
|
+
constant if a legitimate workload hits it). The `_` name prefix is the discriminator; a persisted
|
|
82
|
+
role named with a leading underscore lands in the LRU too (correct, just evictable). Relatedly,
|
|
83
|
+
the role `operations` allowlist gate in `verifyPerms` must stay **ahead of** the ambient privilege
|
|
84
|
+
early-returns (super_user, structure_user, system-table allowances): persisted roles can't combine
|
|
85
|
+
`super_user` with other permission keys, but inline roles can combine `structure_user` with an
|
|
86
|
+
allowlist, and the gate ordering is what keeps unlisted schema ops unreachable.
|
|
87
|
+
|
|
88
|
+
## TLS hot-reload: cert vs. private key follow two different propagation paths (`security/keys.ts`)
|
|
89
|
+
|
|
90
|
+
A renewed **certificate** and a renewed **private key** reach a worker's live TLS secure context
|
|
91
|
+
by completely separate routes, and the two must reconverge or HTTPS breaks on that worker.
|
|
92
|
+
Certificates propagate through data: only the main thread watches the cert file (`isMainThread`
|
|
93
|
+
guard in `loadCertificates`) and writes the new PEM into the `system.hdb_certificate` table; every
|
|
94
|
+
worker is subscribed to that table and rebuilds its secure contexts (`updateTLS` inside
|
|
95
|
+
`createTLSSelector`) on the notification. Private keys never touch the table — each worker watches
|
|
96
|
+
its own key file (the private-key `loadAndWatch` has no `isMainThread` guard) and loads the PEM
|
|
97
|
+
straight into its in-thread `privateKeys` map. `getPrivateKeyByName` reads that map first, so an
|
|
98
|
+
already-built secure context has the key bytes baked in (`setCert`/`setKey` at build time); a later
|
|
99
|
+
map update does not touch contexts already built.
|
|
100
|
+
|
|
101
|
+
The hazard when a rotation changes **both**: the cert can win the race to a worker (table write +
|
|
102
|
+
subscription) and trigger `updateTLS` before that worker has reloaded the matching key, producing a
|
|
103
|
+
context that pairs the new cert with the old key — every handshake on it then fails, and nothing
|
|
104
|
+
rebuilds it until the _next_ cert-table change. The fix: a private-key reload (`handlePrivateKeyReload`,
|
|
105
|
+
the single sink for both the chokidar watcher and PR #1394's periodic poll) triggers a debounced
|
|
106
|
+
rebuild of every live selector via the module-level `liveTLSRebuilders` set, so the worker reconverges
|
|
107
|
+
on its own. Subtleties to preserve: the rotation guard (`previous !== undefined && previous !== key`)
|
|
108
|
+
must skip both the initial load and identical-content reloads or watchers thrash; transient one-shot
|
|
109
|
+
selectors (`getReplicationCert`) pass `liveReload=false` so they don't accumulate in the never-pruned
|
|
110
|
+
set; and the cert subscription shares the same debounced `scheduleRebuild` (same 1500ms cadence), so
|
|
111
|
+
its coalescing must stay a superset-safe no-op for the single-swap #586 case. Regression coverage:
|
|
112
|
+
`integrationTests/security/cert-key-reload.test.ts` deterministically pins the cert-before-key ordering
|
|
113
|
+
(it fails by design without the rebuild trigger); `cert-reload.test.ts` guards the cert-only #586 path.
|
|
114
|
+
|
|
115
|
+
**Publication is transactional (#2382).** `updateTLS` builds the entire replacement state —
|
|
116
|
+
hostname→context map, CA map, and default candidate — into pass-local candidates and reconciles the
|
|
117
|
+
live maps in place only after the pass completes (their identity is load-bearing:
|
|
118
|
+
`server.secureContexts` and each context's `availableCAs` alias them). A record that is still in the
|
|
119
|
+
table but fails to build (`ERR_OSSL_X509_KEY_VALUES_MISMATCH` when the table's cert outruns the
|
|
120
|
+
on-disk key, a missing key on this thread) keeps every live entry it owns _and_ its default
|
|
121
|
+
candidacy — a record can be serving as the default with no hostname entries at all — so a transient
|
|
122
|
+
mismatch never downgrades serving below last-good (the pre-fix behavior served the self-signed
|
|
123
|
+
default for days). Retention is trust-aware: a context froze its `ca:` list at build time, so when
|
|
124
|
+
the CA set has changed since, the retained pair is rebuilt against the current trust material —
|
|
125
|
+
new handshakes never see revoked client-CA trust; established sessions and outstanding session
|
|
126
|
+
tickets are unaffected, exactly as on a fresh build (ticket keys are process-wide and never rotate
|
|
127
|
+
on trust changes) — and if that rebuild fails the record's entries
|
|
128
|
+
drop for that pass, except when nothing else is servable: the zero-certificate guard then retains
|
|
129
|
+
the old state (availability outranks the drop in that corner) while the failure keeps retrying. Deleting the record remains the way to drop its contexts; a corrupt authority
|
|
130
|
+
row is a pass failure like any other (reported through the signature throttle, armed for retry) and
|
|
131
|
+
its trust drops until it heals. A failed pass arms a
|
|
132
|
+
self-retry on the shared debounce with a per-signature backoff (1.5s doubling to 5min) and
|
|
133
|
+
signature-throttled logging; external triggers (table subscription, key reload) stay at the plain
|
|
134
|
+
debounce. `loadAndWatch` latches its mtime before the callback for chokidar/poll dedupe, but rolls
|
|
135
|
+
the latch back on a synchronous throw or a rejected callback promise (equality-guarded so a stale
|
|
136
|
+
rejection cannot unlatch a newer reload) — the latch means "last successfully applied", so the
|
|
137
|
+
periodic poll can heal a lost `hdb_certificate` write instead of deduplicating it forever.
|
|
138
|
+
|
|
139
|
+
## A component-facing export needs BOTH `index.ts` and `getHarperExports` (`security/jsLoader.ts`, `index.ts`)
|
|
140
|
+
|
|
141
|
+
Adding `export { x } from './…'` to `index.ts` publishes `x` on the `harper` **package** but does not
|
|
142
|
+
make `import { x } from 'harper'` work inside an application. A component loaded into a VM compartment
|
|
143
|
+
resolves `harper` to a synthetic module built from `getHarperExports()` — a hand-maintained object
|
|
144
|
+
literal, not a re-export of `index.ts` — so a value added to only one list fails at component load with
|
|
145
|
+
`The requested module 'harper' does not provide an export named 'x'`. No unit test sees that, because
|
|
146
|
+
unit tests `require('#src/…')` directly; only a fixture that imports from `'harper'` does
|
|
147
|
+
(`integrationTests/security/fixtures/deferred-credential-rejection/resources.js`, harper#2703).
|
|
148
|
+
|
|
149
|
+
Export the value from the module that _defines_ it rather than re-exporting it through an intermediate,
|
|
150
|
+
so a component-created value shares that module's private symbols. `markCredentialRejection` depends on
|
|
151
|
+
this: its tag is a module-private `Symbol` that `isCredentialRejection` checks by identity.
|
|
152
|
+
|
|
153
|
+
## Authentication converts every principal-resolution failure into a decision (`security/auth.ts`)
|
|
154
|
+
|
|
155
|
+
`authentication()` resolves a principal from three sources — an mTLS certificate CN, the `Authorization`
|
|
156
|
+
header, and the `hdb_session` cookie — each through the overridable `server.getUser`. A failure from any
|
|
157
|
+
of them must become a **decision**, never a throw: an internal fault (and anything on the operations API,
|
|
158
|
+
where Harper owns every route) is answered in place, and a tagged credential rejection is deferred so the
|
|
159
|
+
layer owning the route settles it. A throw that escapes instead unwinds the whole middleware chain to
|
|
160
|
+
`server/http.ts`'s terminal handler and renders as a plain-text body, which is what harper#2703 reported.
|
|
161
|
+
`settleAuthFailure()` performs the conversion; `rejectAuthenticationInPlace()` is the only way to answer
|
|
162
|
+
in place, and a new principal-resolving path must route through them.
|
|
163
|
+
|
|
164
|
+
Two consequences that are easy to miss:
|
|
165
|
+
|
|
166
|
+
- **A returned 401 is invisible to a WebSocket or MQTT upgrade.** `server/REST.ts` and `server/mqtt.ts`
|
|
167
|
+
only `await chainCompletion` and discard its resolved value, so an in-place decision has to be recorded
|
|
168
|
+
on the request (`markAuthenticationRejectedInPlace`) for `assertNoDeferredCredentialRejection` — which
|
|
169
|
+
both already call — to fail the upgrade closed. Converting a throw into a returned descriptor without
|
|
170
|
+
that record turns a fail-closed upgrade into one that proceeds with no principal. The certificate
|
|
171
|
+
revocation exit is the case that was missed first.
|
|
172
|
+
- **A deferred rejection outranks a later success.** Route owners call
|
|
173
|
+
`settleDeferredCredentialRejection` _before_ they read `request.user`, so once a credential is deferred,
|
|
174
|
+
resolving a principal from a different credential is a contradiction. Hence a rejected certificate
|
|
175
|
+
identity stops resolution outright instead of falling through to Basic, the session, or the local bypass.
|
package/security/auth.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
deferCredentialRejection,
|
|
19
19
|
getDeferredCredentialRejection,
|
|
20
20
|
isCredentialRejection,
|
|
21
|
+
markAuthenticationRejectedInPlace,
|
|
21
22
|
} from './deferredAuthentication.ts';
|
|
22
23
|
import { serializeMessage } from '../server/serverHelpers/contentTypes.ts';
|
|
23
24
|
import { hdbErrors } from '../utility/errors/hdbError.ts';
|
|
@@ -69,6 +70,27 @@ export function bypassAuth() {
|
|
|
69
70
|
bypassUser = { username: 'bypass', role: { role: 'super_user', permission: { super_user: true } } };
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Turns a failed principal resolution into a decision, so it never escapes the chain as a throw.
|
|
75
|
+
* Returns a response descriptor for the caller to pass through `applyResponseHeaders`, or
|
|
76
|
+
* `undefined` once the rejection has been deferred for the route owner to settle (#2703).
|
|
77
|
+
*/
|
|
78
|
+
function settleAuthFailure(request, error, strategy: string): { status: number; body: any } | undefined {
|
|
79
|
+
const internalFault = !isCredentialRejection(error);
|
|
80
|
+
if (request.isOperationsServer || internalFault) {
|
|
81
|
+
if (internalFault) authLogger.error('Authentication failed internally', errorForLog(error));
|
|
82
|
+
const message = internalFault ? AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL : error.message;
|
|
83
|
+
return rejectAuthenticationInPlace(request, message);
|
|
84
|
+
}
|
|
85
|
+
deferCredentialRejection(request, error, strategy);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rejectAuthenticationInPlace(request, message: string): { status: number; body: any } {
|
|
90
|
+
markAuthenticationRejectedInPlace(request, 401, message);
|
|
91
|
+
return { status: 401, body: serializeMessage({ error: message }, request) };
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
// TODO: Make this not return a promise if it can be fulfilled synchronously (from cache)
|
|
73
95
|
export async function authentication(request, nextHandler) {
|
|
74
96
|
const headers = request.headers.asObject; // we cheat and use the node headers object since it is a little faster
|
|
@@ -156,6 +178,7 @@ export async function authentication(request, nextHandler) {
|
|
|
156
178
|
)
|
|
157
179
|
authEventLog.error?.('Authorization error:', request._nodeRequest.socket.authorizationError);
|
|
158
180
|
|
|
181
|
+
let mtlsCredentialDeferred = false;
|
|
159
182
|
if (request.mtlsConfig && request.authorized && request.peerCertificate.subject) {
|
|
160
183
|
const verificationResult = await verifyCertificate(request.peerCertificate, request.mtlsConfig);
|
|
161
184
|
if (!verificationResult.valid) {
|
|
@@ -165,10 +188,7 @@ export async function authentication(request, nextHandler) {
|
|
|
165
188
|
'for',
|
|
166
189
|
request.peerCertificate.subject.CN
|
|
167
190
|
);
|
|
168
|
-
return applyResponseHeaders(
|
|
169
|
-
status: 401,
|
|
170
|
-
body: serializeMessage({ error: 'Certificate revoked or verification failed' }, request),
|
|
171
|
-
});
|
|
191
|
+
return applyResponseHeaders(rejectAuthenticationInPlace(request, 'Certificate revoked or verification failed'));
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
// Alternative behavior: Instead of returning 401 above, we could just not set the user
|
|
@@ -183,8 +203,19 @@ export async function authentication(request, nextHandler) {
|
|
|
183
203
|
// null means no user is defined from certificate, need regular authentication as well
|
|
184
204
|
if (username === undefined || username === 'Common Name' || username === 'CN')
|
|
185
205
|
username = request.peerCertificate.subject.CN;
|
|
186
|
-
|
|
187
|
-
|
|
206
|
+
let mtlsUser;
|
|
207
|
+
try {
|
|
208
|
+
mtlsUser = await server.getUser(username, null, request);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (LOG_AUTH_FAILED) authAuditLog(username, AUTH_AUDIT_STATUS.FAILURE, 'mTLS');
|
|
211
|
+
const failureResponse = settleAuthFailure(request, error, 'mTLS');
|
|
212
|
+
if (failureResponse) return applyResponseHeaders(failureResponse);
|
|
213
|
+
mtlsCredentialDeferred = true;
|
|
214
|
+
}
|
|
215
|
+
if (!mtlsCredentialDeferred) {
|
|
216
|
+
request.user = mtlsUser;
|
|
217
|
+
authAuditLog(username, AUTH_AUDIT_STATUS.SUCCESS, 'mTLS');
|
|
218
|
+
}
|
|
188
219
|
} else {
|
|
189
220
|
debug('HTTPS/WSS mTLS authorized connection (mTLS did not authorize a user)', 'from', request.ip);
|
|
190
221
|
}
|
|
@@ -193,6 +224,9 @@ export async function authentication(request, nextHandler) {
|
|
|
193
224
|
let newUser;
|
|
194
225
|
if (request.user) {
|
|
195
226
|
// already authenticated
|
|
227
|
+
} else if (mtlsCredentialDeferred) {
|
|
228
|
+
// a principal resolved from another credential here would be contradicted by the deferred
|
|
229
|
+
// certificate rejection downstream, so the certificate's decision stands alone
|
|
196
230
|
} else if (authorization) {
|
|
197
231
|
let cachedUser = authorizationCache.get(authorization);
|
|
198
232
|
// A cached Bearer identity must not outlive its token: expiry is the only revocation
|
|
@@ -214,7 +248,7 @@ export async function authentication(request, nextHandler) {
|
|
|
214
248
|
const strategy = authorization.slice(0, spaceIndex);
|
|
215
249
|
const credentials = authorization.slice(spaceIndex + 1);
|
|
216
250
|
let username, password;
|
|
217
|
-
let
|
|
251
|
+
let credentialDeferred = false;
|
|
218
252
|
try {
|
|
219
253
|
switch (strategy) {
|
|
220
254
|
case 'Basic':
|
|
@@ -269,23 +303,12 @@ export async function authentication(request, nextHandler) {
|
|
|
269
303
|
}
|
|
270
304
|
}
|
|
271
305
|
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
return applyResponseHeaders({
|
|
276
|
-
status: 401,
|
|
277
|
-
body: serializeMessage(
|
|
278
|
-
{ error: internalFault ? AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL : err.message },
|
|
279
|
-
request
|
|
280
|
-
),
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
credentialRejection = err;
|
|
306
|
+
const failureResponse = settleAuthFailure(request, err, strategy);
|
|
307
|
+
if (failureResponse) return applyResponseHeaders(failureResponse);
|
|
308
|
+
credentialDeferred = true;
|
|
284
309
|
}
|
|
285
310
|
|
|
286
|
-
if (
|
|
287
|
-
deferCredentialRejection(request, credentialRejection, strategy);
|
|
288
|
-
} else {
|
|
311
|
+
if (!credentialDeferred) {
|
|
289
312
|
authorizationCache.set(authorization, newUser);
|
|
290
313
|
if (LOG_AUTH_SUCCESSFUL && newUser != null)
|
|
291
314
|
authAuditLog(newUser.username, AUTH_AUDIT_STATUS.SUCCESS, strategy);
|
|
@@ -299,8 +322,14 @@ export async function authentication(request, nextHandler) {
|
|
|
299
322
|
|
|
300
323
|
request.user = newUser;
|
|
301
324
|
} else if (session?.user) {
|
|
302
|
-
|
|
303
|
-
|
|
325
|
+
try {
|
|
326
|
+
// or should this be cached in the session?
|
|
327
|
+
request.user = await server.getUser(session.user, null, request);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (LOG_AUTH_FAILED) authAuditLog(session.user, AUTH_AUDIT_STATUS.FAILURE, 'Session');
|
|
330
|
+
const failureResponse = settleAuthFailure(request, error, 'Session');
|
|
331
|
+
if (failureResponse) return applyResponseHeaders(failureResponse);
|
|
332
|
+
}
|
|
304
333
|
} else if (
|
|
305
334
|
(AUTHORIZE_LOCAL && bypassUser) || // explicit bypass (test mode); also covers ::ffff:127.x addresses
|
|
306
335
|
(AUTHORIZE_LOCAL && (request.ip?.includes('127.0.0.') || request.ip == '::1')) ||
|
|
@@ -65,7 +65,29 @@ export function settleDeferredCredentialRejection(
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const REJECTED_IN_PLACE = Symbol('harper.authenticationRejectedInPlace');
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Records an authentication decision the middleware answered in place, with the client-safe message
|
|
72
|
+
* the HTTP response carries. A WebSocket or MQTT upgrade only awaits the chain and never reads its
|
|
73
|
+
* resolved value, so a returned 401 is invisible to it; without this record such an upgrade would
|
|
74
|
+
* continue with no principal on a fault that failed the equivalent HTTP request closed.
|
|
75
|
+
*/
|
|
76
|
+
export function markAuthenticationRejectedInPlace(request: any, status: number, message: string): void {
|
|
77
|
+
if (request?.[REJECTED_IN_PLACE]) return;
|
|
78
|
+
Object.defineProperty(request, REJECTED_IN_PLACE, {
|
|
79
|
+
value: Object.freeze({ status, message }),
|
|
80
|
+
enumerable: false,
|
|
81
|
+
configurable: false,
|
|
82
|
+
writable: false,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getAuthenticationRejectedInPlace(request: any): { status: number; message: string } | undefined {
|
|
87
|
+
return request?.[REJECTED_IN_PLACE];
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
export function assertNoDeferredCredentialRejection(request: any): void {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
91
|
+
const rejected = getDeferredCredentialRejection(request) ?? getAuthenticationRejectedInPlace(request);
|
|
92
|
+
if (rejected) throw new ClientError(rejected.message, rejected.status);
|
|
71
93
|
}
|
package/security/jsLoader.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as child_process from 'node:child_process';
|
|
|
18
18
|
import { CONFIG_PARAMS, DEFAULT_DATABASE_NAME } from '../utility/hdbTerms.ts';
|
|
19
19
|
|
|
20
20
|
import { contentTypes } from '../server/serverHelpers/contentTypes.ts';
|
|
21
|
+
import { markCredentialRejection, credentialRejectionError } from './credentialRejection.ts';
|
|
21
22
|
import type {} from 'ses';
|
|
22
23
|
import {
|
|
23
24
|
existsSync,
|
|
@@ -946,6 +947,8 @@ function getHarperExports(scope: ApplicationScope) {
|
|
|
946
947
|
authenticateUser: server.authenticateUser,
|
|
947
948
|
operation: server.operation,
|
|
948
949
|
contentTypes,
|
|
950
|
+
markCredentialRejection,
|
|
951
|
+
credentialRejectionError,
|
|
949
952
|
Attribute: undefined,
|
|
950
953
|
Config: undefined,
|
|
951
954
|
ConfigValue: undefined,
|