@frogfish/k2db 3.0.1 → 3.0.3
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/README.md +351 -13
- package/data.d.ts +28 -5
- package/data.js +45 -15
- package/db.d.ts +133 -22
- package/db.js +652 -59
- package/package.json +1 -1
package/db.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// src/db.ts
|
|
2
2
|
import { K2Error, ServiceError, wrap } from "@frogfish/k2error"; // Keep the existing error structure
|
|
3
3
|
import { MongoClient, } from "mongodb";
|
|
4
|
-
import { randomBytes, createHash } from "crypto";
|
|
5
|
-
// import debugLib from "debug";
|
|
4
|
+
import { randomBytes, createHash, createCipheriv, createDecipheriv } from "crypto";
|
|
6
5
|
import { Topic } from '@frogfish/ratatouille';
|
|
7
6
|
import { z } from "zod";
|
|
8
7
|
// const debug = debugLib("k2:db");
|
|
@@ -157,8 +156,74 @@ export class K2DB {
|
|
|
157
156
|
initialized = false;
|
|
158
157
|
initPromise;
|
|
159
158
|
schemas = new Map();
|
|
159
|
+
ownershipMode;
|
|
160
|
+
aggregationMode;
|
|
161
|
+
secureFieldPrefixes;
|
|
162
|
+
secureFieldEncryptionKey;
|
|
163
|
+
secureFieldEncryptionKeyId;
|
|
160
164
|
constructor(conf) {
|
|
161
165
|
this.conf = conf;
|
|
166
|
+
this.ownershipMode = conf.ownershipMode ?? "lax";
|
|
167
|
+
this.aggregationMode = conf.aggregationMode ?? "loose";
|
|
168
|
+
this.secureFieldPrefixes = (conf.secureFieldPrefixes ?? [])
|
|
169
|
+
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
|
170
|
+
.filter((p) => !!p);
|
|
171
|
+
const keyB64 = (conf.secureFieldEncryptionKey ?? "").trim();
|
|
172
|
+
const kid = (conf.secureFieldEncryptionKeyId ?? "").trim();
|
|
173
|
+
this.secureFieldEncryptionKeyId = kid || undefined;
|
|
174
|
+
if (keyB64) {
|
|
175
|
+
let keyBuf;
|
|
176
|
+
try {
|
|
177
|
+
keyBuf = Buffer.from(keyB64, "base64");
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
throw new K2Error(ServiceError.CONFIGURATION_ERROR, "secureFieldEncryptionKey must be base64-encoded", "sys_mdb_secure_key_invalid");
|
|
181
|
+
}
|
|
182
|
+
if (keyBuf.length !== 32) {
|
|
183
|
+
throw new K2Error(ServiceError.CONFIGURATION_ERROR, "secureFieldEncryptionKey must decode to 32 bytes (AES-256)", "sys_mdb_secure_key_invalid");
|
|
184
|
+
}
|
|
185
|
+
this.secureFieldEncryptionKey = keyBuf;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Normalize a scope value for ownership enforcement.
|
|
190
|
+
*/
|
|
191
|
+
normalizeScope(scope) {
|
|
192
|
+
if (scope === undefined || scope === null)
|
|
193
|
+
return undefined;
|
|
194
|
+
if (typeof scope !== "string")
|
|
195
|
+
return undefined;
|
|
196
|
+
const s = scope.trim();
|
|
197
|
+
if (!s)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (s === "*")
|
|
200
|
+
return "*";
|
|
201
|
+
return s.toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Apply a scope constraint to criteria for ownership enforcement.
|
|
205
|
+
*/
|
|
206
|
+
applyScopeToCriteria(criteria, scope) {
|
|
207
|
+
const normalizedScope = this.normalizeScope(scope);
|
|
208
|
+
// Strict mode requires an explicit scope per call.
|
|
209
|
+
if (this.ownershipMode === "strict" && !normalizedScope) {
|
|
210
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Scope is required in strict ownership mode", "sys_mdb_scope_required");
|
|
211
|
+
}
|
|
212
|
+
// Lax mode with no scope: legacy behavior (no owner constraint injected).
|
|
213
|
+
if (!normalizedScope)
|
|
214
|
+
return criteria;
|
|
215
|
+
// Explicit all-scope request: do not constrain by _owner.
|
|
216
|
+
if (normalizedScope === "*")
|
|
217
|
+
return criteria;
|
|
218
|
+
// If caller already provided _owner in criteria, ensure it matches the scope to avoid ambiguity/bypass.
|
|
219
|
+
if (criteria && typeof criteria === "object" && Object.prototype.hasOwnProperty.call(criteria, "_owner")) {
|
|
220
|
+
const existing = criteria._owner;
|
|
221
|
+
if (typeof existing === "string" && existing.trim().toLowerCase() !== normalizedScope) {
|
|
222
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Conflicting _owner in criteria and provided scope", "sys_mdb_scope_conflict");
|
|
223
|
+
}
|
|
224
|
+
// If it matches (or is non-string), prefer the explicit scope value.
|
|
225
|
+
}
|
|
226
|
+
return { ...(criteria || {}), _owner: normalizedScope };
|
|
162
227
|
}
|
|
163
228
|
/**
|
|
164
229
|
* Initializes the MongoDB connection.
|
|
@@ -277,45 +342,81 @@ export class K2DB {
|
|
|
277
342
|
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_gc", `Error getting collection: ${collectionName}`);
|
|
278
343
|
}
|
|
279
344
|
}
|
|
280
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Retrieves a single document by UUID.
|
|
347
|
+
* @param collectionName - Name of the collection.
|
|
348
|
+
* @param uuid - UUID of the document.
|
|
349
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
350
|
+
*/
|
|
351
|
+
async get(collectionName, uuid, scope) {
|
|
281
352
|
const id = K2DB.normalizeId(uuid);
|
|
353
|
+
// Note: findOne() decrypts secure-prefixed fields for single-record reads when encryption is enabled.
|
|
282
354
|
const res = await this.findOne(collectionName, {
|
|
283
355
|
_uuid: id,
|
|
284
356
|
_deleted: { $ne: true },
|
|
285
|
-
});
|
|
357
|
+
}, undefined, scope);
|
|
286
358
|
if (!res) {
|
|
287
359
|
throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_get_not_found");
|
|
288
360
|
}
|
|
289
361
|
return res;
|
|
290
362
|
}
|
|
291
363
|
/**
|
|
292
|
-
* Retrieves a single document by
|
|
364
|
+
* Retrieves a single document by criteria.
|
|
293
365
|
* @param collectionName - Name of the collection.
|
|
294
|
-
* @param
|
|
295
|
-
* @param objectTypeName - Optional object type name.
|
|
366
|
+
* @param criteria - Criteria to find the document.
|
|
296
367
|
* @param fields - Optional array of fields to include.
|
|
368
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
297
369
|
*/
|
|
298
|
-
async findOne(collectionName, criteria, fields) {
|
|
370
|
+
async findOne(collectionName, criteria, fields, scope) {
|
|
299
371
|
const collection = await this.getCollection(collectionName);
|
|
300
372
|
const projection = {};
|
|
301
373
|
// Exclude soft-deleted documents by default unless caller specifies otherwise
|
|
302
374
|
const normalizedCriteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
303
|
-
|
|
375
|
+
let query = {
|
|
304
376
|
...normalizedCriteria,
|
|
305
377
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
306
378
|
? {}
|
|
307
379
|
: { _deleted: { $ne: true } }),
|
|
308
380
|
};
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
381
|
+
query = this.applyScopeToCriteria(query, scope);
|
|
382
|
+
// If a projection is requested, forbid explicitly projecting secure-prefixed fields.
|
|
383
|
+
// Also ensure _uuid is fetched when secure encryption is enabled so decryption AAD is correct.
|
|
384
|
+
const requestedFields = Array.isArray(fields) ? fields.slice() : undefined;
|
|
385
|
+
if (requestedFields && requestedFields.length > 0) {
|
|
386
|
+
requestedFields.forEach((field) => {
|
|
387
|
+
if (typeof field !== "string")
|
|
388
|
+
return;
|
|
389
|
+
const f = field.trim();
|
|
390
|
+
if (!f)
|
|
391
|
+
return;
|
|
392
|
+
// Deny any projection that includes secure-prefixed fields (including nested dotted paths)
|
|
393
|
+
if (this.pathHasSecureSegment(f)) {
|
|
394
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Projection cannot include secure-prefixed field(s)", "sys_mdb_projection_secure_field");
|
|
395
|
+
}
|
|
396
|
+
projection[f] = 1;
|
|
312
397
|
});
|
|
398
|
+
// Ensure we can decrypt secure fields that are present in the returned doc
|
|
399
|
+
if (this.hasSecureEncryption()) {
|
|
400
|
+
projection["_uuid"] = 1;
|
|
401
|
+
}
|
|
313
402
|
}
|
|
314
403
|
try {
|
|
315
404
|
const item = await this.runTimed("findOne", { collectionName, query, projection }, async () => await collection.findOne(query, { projection }));
|
|
316
405
|
if (item) {
|
|
317
406
|
const { _id, ...rest } = item;
|
|
318
|
-
|
|
407
|
+
const out = rest;
|
|
408
|
+
const aadBase = out._uuid
|
|
409
|
+
? `k2db|${collectionName}|${out._uuid}`
|
|
410
|
+
: `k2db|${collectionName}`;
|
|
411
|
+
const decrypted = this.decryptSecureFieldsDeep(out, aadBase);
|
|
412
|
+
// If caller requested a projection and did NOT request _uuid, hide it again (it may have been added for decrypt AAD).
|
|
413
|
+
if (requestedFields && requestedFields.length > 0) {
|
|
414
|
+
const wantsUuid = requestedFields.some((f) => typeof f === "string" && f.trim() === "_uuid");
|
|
415
|
+
if (!wantsUuid) {
|
|
416
|
+
delete decrypted._uuid;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return decrypted;
|
|
319
420
|
}
|
|
320
421
|
return null;
|
|
321
422
|
}
|
|
@@ -330,12 +431,14 @@ export class K2DB {
|
|
|
330
431
|
* @param params - Optional search parameters (for sorting, including/excluding fields).
|
|
331
432
|
* @param skip - Number of documents to skip (for pagination).
|
|
332
433
|
* @param limit - Maximum number of documents to return.
|
|
434
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
333
435
|
*/
|
|
334
|
-
async find(collectionName, filter, params = {}, skip = 0, limit = 100) {
|
|
436
|
+
async find(collectionName, filter, params = {}, skip = 0, limit = 100, scope) {
|
|
335
437
|
const collection = await this.getCollection(collectionName);
|
|
336
438
|
// Ensure filter is valid, defaulting to an empty object
|
|
337
439
|
let criteria = { ...(filter || {}) };
|
|
338
440
|
criteria = K2DB.normalizeCriteriaIds(criteria);
|
|
441
|
+
criteria = this.applyScopeToCriteria(criteria, scope);
|
|
339
442
|
// Handle the _deleted field if params specify not to include deleted documents
|
|
340
443
|
if (!params?.includeDeleted && !Object.prototype.hasOwnProperty.call(criteria, "_deleted")) {
|
|
341
444
|
if (params?.deleted === true) {
|
|
@@ -353,7 +456,14 @@ export class K2DB {
|
|
|
353
456
|
else if (Array.isArray(params.filter)) {
|
|
354
457
|
projection = {};
|
|
355
458
|
params.filter.forEach((field) => {
|
|
356
|
-
|
|
459
|
+
const f = typeof field === "string" ? field.trim() : "";
|
|
460
|
+
if (!f)
|
|
461
|
+
return;
|
|
462
|
+
// Deny any projection that includes secure-prefixed fields (including nested dotted paths)
|
|
463
|
+
if (this.pathHasSecureSegment(f)) {
|
|
464
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Projection cannot include secure-prefixed field(s)", "sys_mdb_projection_secure_field");
|
|
465
|
+
}
|
|
466
|
+
projection[f] = 1; // Only include the specified fields
|
|
357
467
|
});
|
|
358
468
|
projection._id = 0; // Hide _id when using include list
|
|
359
469
|
}
|
|
@@ -385,7 +495,8 @@ export class K2DB {
|
|
|
385
495
|
// Remove _id safely from each document
|
|
386
496
|
const result = data.map((doc) => {
|
|
387
497
|
const { _id, ...rest } = doc;
|
|
388
|
-
return rest
|
|
498
|
+
// For multi-record reads, never return secure-prefixed fields (even if encrypted-at-rest is enabled)
|
|
499
|
+
return this.stripSecureFieldsDeep(rest);
|
|
389
500
|
});
|
|
390
501
|
return result;
|
|
391
502
|
}
|
|
@@ -394,18 +505,25 @@ export class K2DB {
|
|
|
394
505
|
}
|
|
395
506
|
}
|
|
396
507
|
/**
|
|
397
|
-
* Aggregates documents based on criteria with pagination support.
|
|
508
|
+
* Aggregates documents based on criteria with pagination support (may validate/limit stages in guarded/strict aggregationMode). Secure-prefixed fields may be stripped from results when configured.
|
|
398
509
|
* @param collectionName - Name of the collection.
|
|
399
510
|
* @param criteria - Aggregation pipeline criteria.
|
|
400
511
|
* @param skip - Number of documents to skip (for pagination).
|
|
401
512
|
* @param limit - Maximum number of documents to return.
|
|
513
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
402
514
|
*/
|
|
403
|
-
async aggregate(collectionName, criteria, skip = 0, limit = 100) {
|
|
515
|
+
async aggregate(collectionName, criteria, skip = 0, limit = 100, scope) {
|
|
404
516
|
if (criteria.length === 0) {
|
|
405
517
|
throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
|
|
406
518
|
}
|
|
519
|
+
// Prevent aggregation from reading/deriving values from secure-prefixed fields (when configured)
|
|
520
|
+
this.assertNoSecureFieldRefsInPipeline(criteria);
|
|
521
|
+
// Validate the aggregation pipeline for allowed/disallowed stages and safety caps
|
|
522
|
+
this.validateAggregationPipeline(criteria, skip, limit);
|
|
407
523
|
// Enforce soft-delete behavior: never return documents marked as deleted
|
|
408
524
|
criteria = K2DB.enforceNoDeletedInPipeline(criteria);
|
|
525
|
+
// Enforce ownership scope within the pipeline (and nested pipelines)
|
|
526
|
+
criteria = this.enforceScopeInPipeline(criteria, scope);
|
|
409
527
|
// Add pagination stages to the aggregation pipeline
|
|
410
528
|
if (skip > 0) {
|
|
411
529
|
criteria.push({ $skip: skip });
|
|
@@ -423,14 +541,400 @@ export class K2DB {
|
|
|
423
541
|
return stage;
|
|
424
542
|
});
|
|
425
543
|
try {
|
|
426
|
-
const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () =>
|
|
427
|
-
|
|
428
|
-
|
|
544
|
+
const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => {
|
|
545
|
+
const mode = this.aggregationMode ?? "loose";
|
|
546
|
+
const opts = {};
|
|
547
|
+
if (mode !== "loose") {
|
|
548
|
+
opts.maxTimeMS = 2000;
|
|
549
|
+
}
|
|
550
|
+
return await collection.aggregate(sanitizedCriteria, opts).toArray();
|
|
551
|
+
});
|
|
552
|
+
// Enforce BaseDocument type on each document and strip secure-prefixed fields (if configured)
|
|
553
|
+
return data.map((doc) => this.stripSecureFieldsDeep(doc));
|
|
429
554
|
}
|
|
430
555
|
catch (err) {
|
|
431
556
|
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_ag", "Aggregation failed");
|
|
432
557
|
}
|
|
433
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* Validate an aggregation pipeline for safety based on aggregationMode.
|
|
561
|
+
* - loose: no validation
|
|
562
|
+
* - guarded: deny obvious footguns (writes, server-side code) and enforce basic caps
|
|
563
|
+
* - strict: allow only a small safe subset of stages and enforce basic caps
|
|
564
|
+
*/
|
|
565
|
+
validateAggregationPipeline(pipeline, skip, limit) {
|
|
566
|
+
const mode = this.aggregationMode ?? "loose";
|
|
567
|
+
if (mode === "loose")
|
|
568
|
+
return;
|
|
569
|
+
// Hard caps to reduce accidental/abusive heavy queries
|
|
570
|
+
const maxStages = 50;
|
|
571
|
+
if (pipeline.length > maxStages) {
|
|
572
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation pipeline too long (max ${maxStages} stages)`, "sys_mdb_ag_pipeline_too_long");
|
|
573
|
+
}
|
|
574
|
+
// Require a positive limit in guarded/strict to avoid accidental full scans.
|
|
575
|
+
// Note: aggregate() appends $limit later; enforce here based on the provided arg.
|
|
576
|
+
if (!(typeof limit === "number") || !isFinite(limit) || limit <= 0) {
|
|
577
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Aggregation requires a positive limit in guarded/strict mode", "sys_mdb_ag_limit_required");
|
|
578
|
+
}
|
|
579
|
+
const maxLimit = 1000;
|
|
580
|
+
if (limit > maxLimit) {
|
|
581
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation limit too large (max ${maxLimit})`, "sys_mdb_ag_limit_too_large");
|
|
582
|
+
}
|
|
583
|
+
const ops = this.collectStageOps(pipeline);
|
|
584
|
+
const denyGuarded = new Set(["$out", "$merge", "$function", "$accumulator"]);
|
|
585
|
+
const allowStrict = new Set(["$match", "$project", "$sort", "$skip", "$limit"]);
|
|
586
|
+
if (mode === "guarded") {
|
|
587
|
+
for (const op of ops) {
|
|
588
|
+
if (denyGuarded.has(op)) {
|
|
589
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation stage ${op} is not allowed in guarded mode`, "sys_mdb_ag_stage_denied");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// strict
|
|
595
|
+
for (const op of ops) {
|
|
596
|
+
if (!allowStrict.has(op)) {
|
|
597
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation stage ${op} is not allowed in strict mode`, "sys_mdb_ag_stage_denied");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Collect top-level stage operators for a pipeline (e.g. "$match", "$lookup"). */
|
|
602
|
+
collectStageOps(pipeline) {
|
|
603
|
+
const ops = [];
|
|
604
|
+
for (const stage of pipeline || []) {
|
|
605
|
+
if (!stage || typeof stage !== "object")
|
|
606
|
+
continue;
|
|
607
|
+
const keys = Object.keys(stage);
|
|
608
|
+
if (keys.length === 1 && keys[0].startsWith("$")) {
|
|
609
|
+
ops.push(keys[0]);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
// Unknown/non-canonical stage shape; treat as invalid in strict/guarded
|
|
613
|
+
ops.push("__invalid__");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return ops;
|
|
617
|
+
}
|
|
618
|
+
/** True if a field key is considered secure and must not be returned. */
|
|
619
|
+
isSecureFieldKey(key) {
|
|
620
|
+
if (!this.secureFieldPrefixes.length)
|
|
621
|
+
return false;
|
|
622
|
+
for (const p of this.secureFieldPrefixes) {
|
|
623
|
+
if (key.startsWith(p))
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Recursively strips secure-prefixed fields from objects/arrays (e.g. "#passport_number").
|
|
630
|
+
* This is applied on read results (e.g. aggregate) so pipelines cannot exfiltrate secure fields.
|
|
631
|
+
*/
|
|
632
|
+
stripSecureFieldsDeep(val) {
|
|
633
|
+
if (!this.secureFieldPrefixes.length)
|
|
634
|
+
return val;
|
|
635
|
+
if (val === null || val === undefined)
|
|
636
|
+
return val;
|
|
637
|
+
if (Array.isArray(val)) {
|
|
638
|
+
return val.map((x) => this.stripSecureFieldsDeep(x));
|
|
639
|
+
}
|
|
640
|
+
if (typeof val !== "object") {
|
|
641
|
+
return val;
|
|
642
|
+
}
|
|
643
|
+
const out = {};
|
|
644
|
+
for (const [k, v] of Object.entries(val)) {
|
|
645
|
+
if (this.isSecureFieldKey(k))
|
|
646
|
+
continue;
|
|
647
|
+
out[k] = this.stripSecureFieldsDeep(v);
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
/** True if secure-field encryption is enabled (requires both key and keyId). */
|
|
652
|
+
hasSecureEncryption() {
|
|
653
|
+
return !!this.secureFieldEncryptionKey && !!this.secureFieldEncryptionKeyId;
|
|
654
|
+
}
|
|
655
|
+
/** Encrypt a JS value using AES-256-GCM and return "<kid>:<ivB64>.<tagB64>.<ctB64>". */
|
|
656
|
+
encryptSecureValueToString(value, aad) {
|
|
657
|
+
const kid = this.secureFieldEncryptionKeyId;
|
|
658
|
+
const iv = randomBytes(12);
|
|
659
|
+
const cipher = createCipheriv("aes-256-gcm", this.secureFieldEncryptionKey, iv);
|
|
660
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
661
|
+
const pt = Buffer.from(JSON.stringify(value), "utf8");
|
|
662
|
+
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
663
|
+
const tag = cipher.getAuthTag();
|
|
664
|
+
const payload = `${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`;
|
|
665
|
+
return `${kid}:${payload}`;
|
|
666
|
+
}
|
|
667
|
+
/** Decrypt a "<kid>:<ivB64>.<tagB64>.<ctB64>" string back into a JS value. */
|
|
668
|
+
decryptSecureStringToValue(s, aad) {
|
|
669
|
+
if (!this.hasSecureEncryption())
|
|
670
|
+
return s;
|
|
671
|
+
if (typeof s !== "string")
|
|
672
|
+
return s;
|
|
673
|
+
const idx = s.indexOf(":");
|
|
674
|
+
if (idx <= 0)
|
|
675
|
+
return s;
|
|
676
|
+
const kid = s.slice(0, idx);
|
|
677
|
+
const payload = s.slice(idx + 1);
|
|
678
|
+
// Only decrypt if kid matches the configured key id
|
|
679
|
+
if (!this.secureFieldEncryptionKeyId || kid !== this.secureFieldEncryptionKeyId) {
|
|
680
|
+
return s;
|
|
681
|
+
}
|
|
682
|
+
const parts = payload.split(".");
|
|
683
|
+
if (parts.length !== 3)
|
|
684
|
+
return s;
|
|
685
|
+
try {
|
|
686
|
+
const iv = Buffer.from(parts[0], "base64");
|
|
687
|
+
const tag = Buffer.from(parts[1], "base64");
|
|
688
|
+
const ct = Buffer.from(parts[2], "base64");
|
|
689
|
+
const decipher = createDecipheriv("aes-256-gcm", this.secureFieldEncryptionKey, iv);
|
|
690
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
691
|
+
decipher.setAuthTag(tag);
|
|
692
|
+
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
693
|
+
return JSON.parse(pt.toString("utf8"));
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_secure_decrypt_failed", "Failed to decrypt secure field");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Encrypt secure-prefixed fields in an object/array tree.
|
|
701
|
+
* Only keys with secure prefixes are encrypted; other keys are recursed to allow nested secure keys.
|
|
702
|
+
*/
|
|
703
|
+
encryptSecureFieldsDeep(val, aadPrefix) {
|
|
704
|
+
if (!this.secureFieldPrefixes.length)
|
|
705
|
+
return val;
|
|
706
|
+
if (!this.hasSecureEncryption())
|
|
707
|
+
return val;
|
|
708
|
+
if (val === null || val === undefined)
|
|
709
|
+
return val;
|
|
710
|
+
if (Array.isArray(val)) {
|
|
711
|
+
return val.map((x) => this.encryptSecureFieldsDeep(x, aadPrefix));
|
|
712
|
+
}
|
|
713
|
+
if (typeof val !== "object")
|
|
714
|
+
return val;
|
|
715
|
+
const out = {};
|
|
716
|
+
for (const [k, v] of Object.entries(val)) {
|
|
717
|
+
if (this.isSecureFieldKey(k)) {
|
|
718
|
+
const aad = `${aadPrefix}|${k}`;
|
|
719
|
+
out[k] = this.encryptSecureValueToString(v, aad);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
out[k] = this.encryptSecureFieldsDeep(v, aadPrefix);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Decrypt secure-prefixed fields in an object/array tree.
|
|
729
|
+
* If a secure field value is not an encrypted string, it is returned as-is.
|
|
730
|
+
*/
|
|
731
|
+
decryptSecureFieldsDeep(val, aadPrefix) {
|
|
732
|
+
if (!this.secureFieldPrefixes.length)
|
|
733
|
+
return val;
|
|
734
|
+
if (!this.hasSecureEncryption())
|
|
735
|
+
return val;
|
|
736
|
+
if (val === null || val === undefined)
|
|
737
|
+
return val;
|
|
738
|
+
if (Array.isArray(val)) {
|
|
739
|
+
return val.map((x) => this.decryptSecureFieldsDeep(x, aadPrefix));
|
|
740
|
+
}
|
|
741
|
+
if (typeof val !== "object")
|
|
742
|
+
return val;
|
|
743
|
+
const out = {};
|
|
744
|
+
for (const [k, v] of Object.entries(val)) {
|
|
745
|
+
if (this.isSecureFieldKey(k)) {
|
|
746
|
+
const aad = `${aadPrefix}|${k}`;
|
|
747
|
+
out[k] = typeof v === "string" ? this.decryptSecureStringToValue(v, aad) : v;
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
out[k] = this.decryptSecureFieldsDeep(v, aadPrefix);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Throws if an aggregation pipeline references any secure-prefixed field paths.
|
|
757
|
+
* This prevents deriving output from secure fields (e.g. {$group: {x: {$sum: "$#passport_number"}}}).
|
|
758
|
+
*/
|
|
759
|
+
assertNoSecureFieldRefsInPipeline(pipeline) {
|
|
760
|
+
if (!this.secureFieldPrefixes.length)
|
|
761
|
+
return;
|
|
762
|
+
if (!Array.isArray(pipeline))
|
|
763
|
+
return;
|
|
764
|
+
if (this.containsSecureFieldRefDeep(pipeline)) {
|
|
765
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Aggregation pipeline references secure-prefixed field(s)", "sys_mdb_ag_secure_field_ref");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** Recursively detects secure field references in an aggregation pipeline AST. */
|
|
769
|
+
containsSecureFieldRefDeep(val) {
|
|
770
|
+
if (!this.secureFieldPrefixes.length)
|
|
771
|
+
return false;
|
|
772
|
+
if (val === null || val === undefined)
|
|
773
|
+
return false;
|
|
774
|
+
if (Array.isArray(val)) {
|
|
775
|
+
return val.some((x) => this.containsSecureFieldRefDeep(x));
|
|
776
|
+
}
|
|
777
|
+
if (typeof val === "string") {
|
|
778
|
+
return this.stringHasSecureFieldPath(val);
|
|
779
|
+
}
|
|
780
|
+
if (typeof val !== "object")
|
|
781
|
+
return false;
|
|
782
|
+
for (const [k, v] of Object.entries(val)) {
|
|
783
|
+
// Object keys can be field names in some expressions; treat them as potential paths too.
|
|
784
|
+
if (this.stringHasSecureFieldPath(k))
|
|
785
|
+
return true;
|
|
786
|
+
if (this.containsSecureFieldRefDeep(v))
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Returns true if a string appears to reference a secure field path.
|
|
793
|
+
* We consider:
|
|
794
|
+
* - "$path.to.field"
|
|
795
|
+
* - "$$var.path.to.field"
|
|
796
|
+
* - plain "path.to.field" (e.g. $getField: { field: "#passport_number" })
|
|
797
|
+
*/
|
|
798
|
+
stringHasSecureFieldPath(s) {
|
|
799
|
+
if (!this.secureFieldPrefixes.length)
|
|
800
|
+
return false;
|
|
801
|
+
if (typeof s !== "string")
|
|
802
|
+
return false;
|
|
803
|
+
const raw = s.trim();
|
|
804
|
+
if (!raw)
|
|
805
|
+
return false;
|
|
806
|
+
// Handle "$$var.path" form (aggregation variables)
|
|
807
|
+
if (raw.startsWith("$$")) {
|
|
808
|
+
const after = raw.slice(2);
|
|
809
|
+
const dot = after.indexOf(".");
|
|
810
|
+
if (dot === -1)
|
|
811
|
+
return false; // just a variable name
|
|
812
|
+
const path = after.slice(dot + 1);
|
|
813
|
+
return this.pathHasSecureSegment(path);
|
|
814
|
+
}
|
|
815
|
+
// Handle "$path" form (field paths)
|
|
816
|
+
if (raw.startsWith("$")) {
|
|
817
|
+
const path = raw.slice(1);
|
|
818
|
+
return this.pathHasSecureSegment(path);
|
|
819
|
+
}
|
|
820
|
+
// Handle plain "path" strings (e.g. $getField field names)
|
|
821
|
+
return this.pathHasSecureSegment(raw);
|
|
822
|
+
}
|
|
823
|
+
/** True if any segment in a dotted path starts with a secure prefix. */
|
|
824
|
+
pathHasSecureSegment(path) {
|
|
825
|
+
const p = path.trim();
|
|
826
|
+
if (!p)
|
|
827
|
+
return false;
|
|
828
|
+
const segments = p.split(".");
|
|
829
|
+
for (const seg of segments) {
|
|
830
|
+
if (!seg)
|
|
831
|
+
continue;
|
|
832
|
+
if (this.isSecureFieldKey(seg))
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Ensures an aggregation pipeline respects ownership scope for the root
|
|
839
|
+
* collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
|
|
840
|
+
*/
|
|
841
|
+
enforceScopeInPipeline(pipeline, scope) {
|
|
842
|
+
const normalizedScope = this.normalizeScope(scope);
|
|
843
|
+
// Strict mode requires an explicit scope per call.
|
|
844
|
+
if (this.ownershipMode === "strict" && !normalizedScope) {
|
|
845
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Scope is required in strict ownership mode", "sys_mdb_scope_required");
|
|
846
|
+
}
|
|
847
|
+
// Lax mode with no scope, or explicit all-scope: no owner constraint injected.
|
|
848
|
+
if (!normalizedScope || normalizedScope === "*") {
|
|
849
|
+
return Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
|
|
850
|
+
}
|
|
851
|
+
const cloned = Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
|
|
852
|
+
// Insert a $match to constrain owner near the start, but after any
|
|
853
|
+
// first-stage-only operators like $search, $geoNear, $vectorSearch.
|
|
854
|
+
const reservedFirst = ["$search", "$geoNear", "$vectorSearch"];
|
|
855
|
+
let insertIdx = 0;
|
|
856
|
+
while (insertIdx < cloned.length &&
|
|
857
|
+
typeof cloned[insertIdx] === "object" &&
|
|
858
|
+
cloned[insertIdx] !== null &&
|
|
859
|
+
Object.keys(cloned[insertIdx]).length === 1 &&
|
|
860
|
+
reservedFirst.includes(Object.keys(cloned[insertIdx])[0])) {
|
|
861
|
+
insertIdx++;
|
|
862
|
+
}
|
|
863
|
+
const ownerMatch = { $match: { _owner: normalizedScope } };
|
|
864
|
+
cloned.splice(insertIdx, 0, ownerMatch);
|
|
865
|
+
const mapStage = (stage) => {
|
|
866
|
+
if (!stage || typeof stage !== "object")
|
|
867
|
+
return stage;
|
|
868
|
+
if (stage.$lookup) {
|
|
869
|
+
const lu = { ...stage.$lookup };
|
|
870
|
+
if (Array.isArray(lu.pipeline)) {
|
|
871
|
+
lu.pipeline = this.enforceScopeInPipeline(lu.pipeline, normalizedScope);
|
|
872
|
+
}
|
|
873
|
+
else if (lu.localField && lu.foreignField) {
|
|
874
|
+
// Convert simple lookup to pipeline lookup so we can enforce owner scope (and deleted) in foreign coll.
|
|
875
|
+
const localVar = "__lk";
|
|
876
|
+
const ownerVar = "__own";
|
|
877
|
+
lu.let = { [localVar]: `$${lu.localField}`, [ownerVar]: normalizedScope };
|
|
878
|
+
lu.pipeline = [
|
|
879
|
+
{
|
|
880
|
+
$match: {
|
|
881
|
+
$expr: {
|
|
882
|
+
$and: [
|
|
883
|
+
{
|
|
884
|
+
$cond: [
|
|
885
|
+
{ $isArray: "$$" + localVar },
|
|
886
|
+
{ $in: ["$" + lu.foreignField, "$$" + localVar] },
|
|
887
|
+
{ $eq: ["$" + lu.foreignField, "$$" + localVar] },
|
|
888
|
+
],
|
|
889
|
+
},
|
|
890
|
+
{ $eq: ["$_owner", "$$" + ownerVar] },
|
|
891
|
+
{ $ne: ["$_deleted", true] },
|
|
892
|
+
],
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
];
|
|
897
|
+
delete lu.localField;
|
|
898
|
+
delete lu.foreignField;
|
|
899
|
+
}
|
|
900
|
+
return { $lookup: lu };
|
|
901
|
+
}
|
|
902
|
+
if (stage.$unionWith) {
|
|
903
|
+
const uw = stage.$unionWith;
|
|
904
|
+
if (typeof uw === "string") {
|
|
905
|
+
return {
|
|
906
|
+
$unionWith: {
|
|
907
|
+
coll: uw,
|
|
908
|
+
pipeline: [
|
|
909
|
+
{ $match: { _owner: normalizedScope } },
|
|
910
|
+
{ $match: { _deleted: { $ne: true } } },
|
|
911
|
+
],
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
else if (uw && typeof uw === "object") {
|
|
916
|
+
const uwc = { ...uw };
|
|
917
|
+
uwc.pipeline = this.enforceScopeInPipeline(uwc.pipeline || [], normalizedScope);
|
|
918
|
+
return { $unionWith: uwc };
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (stage.$graphLookup) {
|
|
922
|
+
const gl = { ...stage.$graphLookup };
|
|
923
|
+
const existing = gl.restrictSearchWithMatch || {};
|
|
924
|
+
gl.restrictSearchWithMatch = { ...existing, _owner: normalizedScope };
|
|
925
|
+
return { $graphLookup: gl };
|
|
926
|
+
}
|
|
927
|
+
if (stage.$facet) {
|
|
928
|
+
const facets = { ...stage.$facet };
|
|
929
|
+
for (const key of Object.keys(facets)) {
|
|
930
|
+
facets[key] = this.enforceScopeInPipeline(facets[key] || [], normalizedScope);
|
|
931
|
+
}
|
|
932
|
+
return { $facet: facets };
|
|
933
|
+
}
|
|
934
|
+
return stage;
|
|
935
|
+
};
|
|
936
|
+
return cloned.map(mapStage);
|
|
937
|
+
}
|
|
434
938
|
/**
|
|
435
939
|
* Ensures an aggregation pipeline excludes soft-deleted documents for the root
|
|
436
940
|
* collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
|
|
@@ -469,7 +973,13 @@ export class K2DB {
|
|
|
469
973
|
$match: {
|
|
470
974
|
$expr: {
|
|
471
975
|
$and: [
|
|
472
|
-
{
|
|
976
|
+
{
|
|
977
|
+
$cond: [
|
|
978
|
+
{ $isArray: "$$" + localVar },
|
|
979
|
+
{ $in: ["$" + lu.foreignField, "$$" + localVar] },
|
|
980
|
+
{ $eq: ["$" + lu.foreignField, "$$" + localVar] },
|
|
981
|
+
],
|
|
982
|
+
},
|
|
473
983
|
{ $ne: ["$_deleted", true] },
|
|
474
984
|
],
|
|
475
985
|
},
|
|
@@ -527,6 +1037,13 @@ export class K2DB {
|
|
|
527
1037
|
if (typeof owner !== "string") {
|
|
528
1038
|
throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be of a string type", "sys_mdb_crv2");
|
|
529
1039
|
}
|
|
1040
|
+
const normalizedOwner = owner.trim().toLowerCase();
|
|
1041
|
+
if (!normalizedOwner) {
|
|
1042
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be a non-empty string", "sys_mdb_owner_empty");
|
|
1043
|
+
}
|
|
1044
|
+
if (normalizedOwner === "*") {
|
|
1045
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Owner cannot be '*'", "sys_mdb_owner_star");
|
|
1046
|
+
}
|
|
530
1047
|
const collection = await this.getCollection(collectionName);
|
|
531
1048
|
const timestamp = Date.now();
|
|
532
1049
|
// Generate a new UUIDv7 encoded as Crockford Base32 with hyphens
|
|
@@ -534,12 +1051,13 @@ export class K2DB {
|
|
|
534
1051
|
// Remove reserved fields from user data, then validate/transform via schema if present
|
|
535
1052
|
const safeData = K2DB.stripReservedFields(data);
|
|
536
1053
|
const validated = this.applySchema(collectionName, safeData, /*partial*/ false);
|
|
1054
|
+
const storedData = this.encryptSecureFieldsDeep(validated, `k2db|${collectionName}|${newUuid}`);
|
|
537
1055
|
// Spread validated data first, then set internal fields to prevent overwriting
|
|
538
1056
|
const document = {
|
|
539
|
-
...
|
|
1057
|
+
...storedData,
|
|
540
1058
|
_created: timestamp,
|
|
541
1059
|
_updated: timestamp,
|
|
542
|
-
_owner:
|
|
1060
|
+
_owner: normalizedOwner,
|
|
543
1061
|
_uuid: newUuid,
|
|
544
1062
|
};
|
|
545
1063
|
try {
|
|
@@ -564,8 +1082,9 @@ export class K2DB {
|
|
|
564
1082
|
* @param collectionName - Name of the collection.
|
|
565
1083
|
* @param criteria - Update criteria.
|
|
566
1084
|
* @param values - Values to update or replace with.
|
|
1085
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
567
1086
|
*/
|
|
568
|
-
async updateAll(collectionName, criteria, values) {
|
|
1087
|
+
async updateAll(collectionName, criteria, values, scope) {
|
|
569
1088
|
this.validateCollectionName(collectionName);
|
|
570
1089
|
const collection = await this.getCollection(collectionName);
|
|
571
1090
|
debug(`Updating ${collectionName} with criteria: ${JSON.stringify(criteria)}`);
|
|
@@ -582,7 +1101,10 @@ export class K2DB {
|
|
|
582
1101
|
if (deletedFlag !== undefined) {
|
|
583
1102
|
values._deleted = deletedFlag;
|
|
584
1103
|
}
|
|
1104
|
+
// Encrypt secure-prefixed fields at rest (no-op unless encryption is configured)
|
|
1105
|
+
values = this.encryptSecureFieldsDeep(values, `k2db|${collectionName}`);
|
|
585
1106
|
criteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1107
|
+
criteria = this.applyScopeToCriteria(criteria, scope);
|
|
586
1108
|
criteria = {
|
|
587
1109
|
...criteria,
|
|
588
1110
|
_deleted: { $ne: true },
|
|
@@ -604,20 +1126,26 @@ export class K2DB {
|
|
|
604
1126
|
* @param id - UUID string to identify the document.
|
|
605
1127
|
* @param data - Data to update or replace with.
|
|
606
1128
|
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
1129
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
607
1130
|
*/
|
|
608
|
-
async update(collectionName, id, data, replace = false) {
|
|
1131
|
+
async update(collectionName, id, data, replace = false, scope) {
|
|
609
1132
|
id = K2DB.normalizeId(id);
|
|
610
1133
|
this.validateCollectionName(collectionName);
|
|
611
1134
|
const collection = await this.getCollection(collectionName);
|
|
612
1135
|
data = K2DB.stripReservedFields(data);
|
|
613
1136
|
data = this.applySchema(collectionName, data, /*partial*/ !replace);
|
|
614
1137
|
data._updated = Date.now(); // Set the _updated timestamp
|
|
1138
|
+
// PATCH path: encrypt secure-prefixed fields at rest (no-op unless encryption is configured).
|
|
1139
|
+
// REPLACE path: encrypt after underscore-field merge to avoid double-encrypt.
|
|
1140
|
+
if (!replace) {
|
|
1141
|
+
data = this.encryptSecureFieldsDeep(data, `k2db|${collectionName}|${id}`);
|
|
1142
|
+
}
|
|
615
1143
|
try {
|
|
616
1144
|
let res;
|
|
617
1145
|
// If replacing the document, first get the original document
|
|
618
1146
|
if (replace) {
|
|
619
1147
|
// Get the original document to preserve fields starting with underscore
|
|
620
|
-
const originalDoc = await this.get(collectionName, id);
|
|
1148
|
+
const originalDoc = await this.get(collectionName, id, scope);
|
|
621
1149
|
// Override all fields starting with underscore from the original document
|
|
622
1150
|
const fieldsToPreserve = Object.keys(originalDoc).reduce((acc, key) => {
|
|
623
1151
|
if (key.startsWith("_")) {
|
|
@@ -628,12 +1156,14 @@ export class K2DB {
|
|
|
628
1156
|
// Merge the preserved fields into the data
|
|
629
1157
|
data = { ...data, ...fieldsToPreserve };
|
|
630
1158
|
data._updated = Date.now();
|
|
1159
|
+
// Encrypt secure-prefixed fields at rest (no-op unless encryption is configured)
|
|
1160
|
+
data = this.encryptSecureFieldsDeep(data, `k2db|${collectionName}|${id}`);
|
|
631
1161
|
// Now replace the document with the merged data
|
|
632
|
-
res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne({ _uuid: id, _deleted: { $ne: true } }, data));
|
|
1162
|
+
res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne(this.applyScopeToCriteria({ _uuid: id, _deleted: { $ne: true } }, scope), data));
|
|
633
1163
|
}
|
|
634
1164
|
else {
|
|
635
1165
|
// If patching, just update specific fields using $set
|
|
636
|
-
res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne({ _uuid: id, _deleted: { $ne: true } }, { $set: data }));
|
|
1166
|
+
res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne(this.applyScopeToCriteria({ _uuid: id, _deleted: { $ne: true } }, scope), { $set: data }));
|
|
637
1167
|
}
|
|
638
1168
|
// Use matchedCount to determine existence; modifiedCount indicates actual change
|
|
639
1169
|
if (res.matchedCount === 0) {
|
|
@@ -649,13 +1179,14 @@ export class K2DB {
|
|
|
649
1179
|
* Removes (soft deletes) multiple documents based on criteria.
|
|
650
1180
|
* @param collectionName - Name of the collection.
|
|
651
1181
|
* @param criteria - Removal criteria.
|
|
1182
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
652
1183
|
*/
|
|
653
|
-
async deleteAll(collectionName, criteria) {
|
|
1184
|
+
async deleteAll(collectionName, criteria, scope) {
|
|
654
1185
|
this.validateCollectionName(collectionName);
|
|
655
1186
|
try {
|
|
656
1187
|
const result = await this.updateAll(collectionName, criteria, {
|
|
657
1188
|
_deleted: true,
|
|
658
|
-
});
|
|
1189
|
+
}, scope);
|
|
659
1190
|
return { deleted: result.updated };
|
|
660
1191
|
}
|
|
661
1192
|
catch (err) {
|
|
@@ -666,12 +1197,13 @@ export class K2DB {
|
|
|
666
1197
|
* Removes (soft deletes) a single document by UUID.
|
|
667
1198
|
* @param collectionName - Name of the collection.
|
|
668
1199
|
* @param id - UUID of the document.
|
|
1200
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
669
1201
|
*/
|
|
670
|
-
async delete(collectionName, id) {
|
|
1202
|
+
async delete(collectionName, id, scope) {
|
|
671
1203
|
id = K2DB.normalizeId(id);
|
|
672
1204
|
try {
|
|
673
1205
|
// Call deleteAll to soft delete the document by UUID
|
|
674
|
-
const result = await this.deleteAll(collectionName, { _uuid: id });
|
|
1206
|
+
const result = await this.deleteAll(collectionName, { _uuid: id }, scope);
|
|
675
1207
|
// Check the result of the deleteAll operation
|
|
676
1208
|
if (result.deleted === 1) {
|
|
677
1209
|
// Successfully deleted one document
|
|
@@ -694,19 +1226,19 @@ export class K2DB {
|
|
|
694
1226
|
* Permanently deletes a document that has been soft-deleted.
|
|
695
1227
|
* @param collectionName - Name of the collection.
|
|
696
1228
|
* @param id - UUID of the document.
|
|
1229
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
697
1230
|
*/
|
|
698
|
-
async purge(collectionName, id) {
|
|
1231
|
+
async purge(collectionName, id, scope) {
|
|
699
1232
|
id = K2DB.normalizeId(id);
|
|
700
1233
|
const collection = await this.getCollection(collectionName);
|
|
701
1234
|
try {
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
_deleted: true,
|
|
705
|
-
}));
|
|
1235
|
+
const findFilter = this.applyScopeToCriteria({ _uuid: id, _deleted: true }, scope);
|
|
1236
|
+
const item = await this.runTimed("findOne", { collectionName, ...findFilter }, async () => await collection.findOne(findFilter));
|
|
706
1237
|
if (!item) {
|
|
707
1238
|
throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
|
|
708
1239
|
}
|
|
709
|
-
|
|
1240
|
+
const delFilter = this.applyScopeToCriteria({ _uuid: id }, scope);
|
|
1241
|
+
await this.runTimed("deleteOne", { collectionName, ...delFilter }, async () => await collection.deleteOne(delFilter));
|
|
710
1242
|
return { id };
|
|
711
1243
|
}
|
|
712
1244
|
catch (err) {
|
|
@@ -719,8 +1251,9 @@ export class K2DB {
|
|
|
719
1251
|
* @param collectionName - Name of the collection.
|
|
720
1252
|
* @param olderThanMs - Age threshold in milliseconds; documents with
|
|
721
1253
|
* `_updated <= (Date.now() - olderThanMs)` will be purged.
|
|
1254
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
722
1255
|
*/
|
|
723
|
-
async purgeDeletedOlderThan(collectionName, olderThanMs) {
|
|
1256
|
+
async purgeDeletedOlderThan(collectionName, olderThanMs, scope) {
|
|
724
1257
|
this.validateCollectionName(collectionName);
|
|
725
1258
|
if (typeof olderThanMs !== 'number' || !isFinite(olderThanMs) || olderThanMs < 0) {
|
|
726
1259
|
throw new K2Error(ServiceError.BAD_REQUEST, 'olderThanMs must be a non-negative number', 'sys_mdb_purge_older_invalid');
|
|
@@ -728,10 +1261,11 @@ export class K2DB {
|
|
|
728
1261
|
const collection = await this.getCollection(collectionName);
|
|
729
1262
|
const cutoff = Date.now() - olderThanMs;
|
|
730
1263
|
try {
|
|
731
|
-
const
|
|
1264
|
+
const delFilter = this.applyScopeToCriteria({
|
|
732
1265
|
_deleted: true,
|
|
733
1266
|
_updated: { $lte: cutoff },
|
|
734
|
-
})
|
|
1267
|
+
}, scope);
|
|
1268
|
+
const res = await this.runTimed('deleteMany', { collectionName, olderThanMs, cutoff, ...delFilter }, async () => await collection.deleteMany(delFilter));
|
|
735
1269
|
return { purged: res.deletedCount ?? 0 };
|
|
736
1270
|
}
|
|
737
1271
|
catch (err) {
|
|
@@ -742,10 +1276,12 @@ export class K2DB {
|
|
|
742
1276
|
* Restores a soft-deleted document.
|
|
743
1277
|
* @param collectionName - Name of the collection.
|
|
744
1278
|
* @param criteria - Criteria to identify the document.
|
|
1279
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
745
1280
|
*/
|
|
746
|
-
async restore(collectionName, criteria) {
|
|
1281
|
+
async restore(collectionName, criteria, scope) {
|
|
747
1282
|
const collection = await this.getCollection(collectionName);
|
|
748
|
-
|
|
1283
|
+
let crit = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1284
|
+
crit = this.applyScopeToCriteria(crit, scope);
|
|
749
1285
|
const query = { ...crit, _deleted: true };
|
|
750
1286
|
try {
|
|
751
1287
|
const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
|
|
@@ -762,17 +1298,19 @@ export class K2DB {
|
|
|
762
1298
|
* Counts documents based on criteria.
|
|
763
1299
|
* @param collectionName - Name of the collection.
|
|
764
1300
|
* @param criteria - Counting criteria.
|
|
1301
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
765
1302
|
*/
|
|
766
|
-
async count(collectionName, criteria) {
|
|
1303
|
+
async count(collectionName, criteria, scope) {
|
|
767
1304
|
const collection = await this.getCollection(collectionName);
|
|
768
1305
|
try {
|
|
769
1306
|
const norm = K2DB.normalizeCriteriaIds(criteria || {});
|
|
770
|
-
|
|
1307
|
+
let query = {
|
|
771
1308
|
...norm,
|
|
772
1309
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
773
1310
|
? {}
|
|
774
1311
|
: { _deleted: { $ne: true } }),
|
|
775
1312
|
};
|
|
1313
|
+
query = this.applyScopeToCriteria(query, scope);
|
|
776
1314
|
const cnt = await this.runTimed("countDocuments", { collectionName, query }, async () => await collection.countDocuments(query));
|
|
777
1315
|
return { count: cnt };
|
|
778
1316
|
}
|
|
@@ -781,11 +1319,21 @@ export class K2DB {
|
|
|
781
1319
|
}
|
|
782
1320
|
}
|
|
783
1321
|
/**
|
|
784
|
-
* Drops an entire collection.
|
|
1322
|
+
* Drops an entire collection (global destructive operation).
|
|
785
1323
|
* @param collectionName - Name of the collection.
|
|
1324
|
+
* @param scope - (optional) Must be "*" in strict ownership mode.
|
|
786
1325
|
*/
|
|
787
|
-
async drop(collectionName) {
|
|
1326
|
+
async drop(collectionName, scope) {
|
|
788
1327
|
const collection = await this.getCollection(collectionName);
|
|
1328
|
+
const normalizedScope = this.normalizeScope(scope);
|
|
1329
|
+
// Global destructive operation: require explicit all-scope gate in strict mode.
|
|
1330
|
+
if (this.ownershipMode === "strict" && normalizedScope !== "*") {
|
|
1331
|
+
throw new K2Error(ServiceError.BAD_REQUEST, 'Dropping a collection requires scope="*" in strict ownership mode', "sys_mdb_drop_scope_required");
|
|
1332
|
+
}
|
|
1333
|
+
// In lax mode, allow legacy behavior when scope is omitted; if provided, it must be "*".
|
|
1334
|
+
if (this.ownershipMode === "lax" && normalizedScope !== undefined && normalizedScope !== "*") {
|
|
1335
|
+
throw new K2Error(ServiceError.BAD_REQUEST, 'Dropping a collection only supports scope="*"', "sys_mdb_drop_scope_invalid");
|
|
1336
|
+
}
|
|
789
1337
|
try {
|
|
790
1338
|
await collection.drop();
|
|
791
1339
|
return { status: "ok" };
|
|
@@ -817,7 +1365,7 @@ export class K2DB {
|
|
|
817
1365
|
}
|
|
818
1366
|
return criteria;
|
|
819
1367
|
}
|
|
820
|
-
/** Recursively
|
|
1368
|
+
/** Recursively normalizes query fields: `_uuid` uppercased, `_owner` lowercased. */
|
|
821
1369
|
static normalizeCriteriaIds(obj) {
|
|
822
1370
|
if (!obj || typeof obj !== "object")
|
|
823
1371
|
return obj;
|
|
@@ -828,6 +1376,9 @@ export class K2DB {
|
|
|
828
1376
|
if (k === "_uuid") {
|
|
829
1377
|
out[k] = K2DB.normalizeUuidField(v);
|
|
830
1378
|
}
|
|
1379
|
+
else if (k === "_owner") {
|
|
1380
|
+
out[k] = K2DB.normalizeOwnerField(v);
|
|
1381
|
+
}
|
|
831
1382
|
else if (v && typeof v === "object") {
|
|
832
1383
|
out[k] = K2DB.normalizeCriteriaIds(v);
|
|
833
1384
|
}
|
|
@@ -856,6 +1407,23 @@ export class K2DB {
|
|
|
856
1407
|
}
|
|
857
1408
|
return val;
|
|
858
1409
|
}
|
|
1410
|
+
/** Lowercase helper for `_owner` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
1411
|
+
static normalizeOwnerField(val) {
|
|
1412
|
+
if (typeof val === "string")
|
|
1413
|
+
return val.trim().toLowerCase();
|
|
1414
|
+
if (Array.isArray(val)) {
|
|
1415
|
+
return val.map((x) => (typeof x === "string" ? x.trim().toLowerCase() : x));
|
|
1416
|
+
}
|
|
1417
|
+
if (val && typeof val === "object") {
|
|
1418
|
+
const out = { ...val };
|
|
1419
|
+
for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
|
|
1420
|
+
if (op in out)
|
|
1421
|
+
out[op] = K2DB.normalizeOwnerField(out[op]);
|
|
1422
|
+
}
|
|
1423
|
+
return out;
|
|
1424
|
+
}
|
|
1425
|
+
return val;
|
|
1426
|
+
}
|
|
859
1427
|
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
860
1428
|
static stripReservedFields(obj) {
|
|
861
1429
|
const out = {};
|
|
@@ -1095,26 +1663,33 @@ export class K2DB {
|
|
|
1095
1663
|
async snapshotCurrent(collectionName, current) {
|
|
1096
1664
|
const hc = await this.getHistoryCollection(collectionName);
|
|
1097
1665
|
const version = await this.nextVersion(collectionName, current._uuid);
|
|
1666
|
+
const storedSnapshot = this.encryptSecureFieldsDeep(current, `k2db|${collectionName}|${current._uuid}`);
|
|
1098
1667
|
await hc.insertOne({
|
|
1099
1668
|
_uuid: current._uuid,
|
|
1100
1669
|
_v: version,
|
|
1101
1670
|
_at: Date.now(),
|
|
1102
|
-
snapshot:
|
|
1671
|
+
snapshot: storedSnapshot,
|
|
1103
1672
|
});
|
|
1104
1673
|
return { version };
|
|
1105
1674
|
}
|
|
1106
1675
|
/**
|
|
1107
1676
|
* Update a document and keep the previous version in a history collection.
|
|
1108
1677
|
* If maxVersions is provided, prunes oldest snapshots beyond that number.
|
|
1678
|
+
* @param collectionName - Name of the collection.
|
|
1679
|
+
* @param id - UUID string to identify the document.
|
|
1680
|
+
* @param data - Data to update or replace with.
|
|
1681
|
+
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
1682
|
+
* @param maxVersions - Maximum number of versions to keep (optional).
|
|
1683
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1109
1684
|
*/
|
|
1110
|
-
async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
|
|
1685
|
+
async updateVersioned(collectionName, id, data, replace = false, maxVersions, scope) {
|
|
1111
1686
|
id = K2DB.normalizeId(id);
|
|
1112
1687
|
// Get current doc (excludes deleted) and snapshot it
|
|
1113
|
-
const current = await this.get(collectionName, id);
|
|
1688
|
+
const current = await this.get(collectionName, id, scope);
|
|
1114
1689
|
await this.ensureHistoryIndexes(collectionName);
|
|
1115
1690
|
const { version } = await this.snapshotCurrent(collectionName, current);
|
|
1116
1691
|
// Perform update
|
|
1117
|
-
const res = await this.update(collectionName, id, data, replace);
|
|
1692
|
+
const res = await this.update(collectionName, id, data, replace, scope);
|
|
1118
1693
|
// Optionally prune old versions
|
|
1119
1694
|
if (typeof maxVersions === "number" && maxVersions >= 0) {
|
|
1120
1695
|
const hc = await this.getHistoryCollection(collectionName);
|
|
@@ -1135,9 +1710,18 @@ export class K2DB {
|
|
|
1135
1710
|
}
|
|
1136
1711
|
return [{ updated: res.updated, versionSaved: version }];
|
|
1137
1712
|
}
|
|
1138
|
-
/**
|
|
1139
|
-
|
|
1713
|
+
/**
|
|
1714
|
+
* List versions (latest first).
|
|
1715
|
+
* @param collectionName - Name of the collection.
|
|
1716
|
+
* @param id - UUID string to identify the document.
|
|
1717
|
+
* @param skip - Number of versions to skip (for pagination).
|
|
1718
|
+
* @param limit - Maximum number of versions to return.
|
|
1719
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1720
|
+
*/
|
|
1721
|
+
async listVersions(collectionName, id, skip = 0, limit = 20, scope) {
|
|
1140
1722
|
id = K2DB.normalizeId(id);
|
|
1723
|
+
// Gate history access by ownership of the current document
|
|
1724
|
+
await this.get(collectionName, id, scope);
|
|
1141
1725
|
const hc = await this.getHistoryCollection(collectionName);
|
|
1142
1726
|
const rows = await hc
|
|
1143
1727
|
.find({ _uuid: id })
|
|
@@ -1148,21 +1732,30 @@ export class K2DB {
|
|
|
1148
1732
|
.toArray();
|
|
1149
1733
|
return rows;
|
|
1150
1734
|
}
|
|
1151
|
-
/**
|
|
1152
|
-
|
|
1735
|
+
/**
|
|
1736
|
+
* Revert the current document to a specific historical version (preserves metadata).
|
|
1737
|
+
* @param collectionName - Name of the collection.
|
|
1738
|
+
* @param id - UUID string to identify the document.
|
|
1739
|
+
* @param version - Version number to revert to.
|
|
1740
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1741
|
+
*/
|
|
1742
|
+
async revertToVersion(collectionName, id, version, scope) {
|
|
1153
1743
|
id = K2DB.normalizeId(id);
|
|
1744
|
+
// Gate history access by ownership of the current document
|
|
1745
|
+
await this.get(collectionName, id, scope);
|
|
1154
1746
|
const hc = await this.getHistoryCollection(collectionName);
|
|
1155
1747
|
const row = await hc.findOne({ _uuid: id, _v: version });
|
|
1156
1748
|
if (!row) {
|
|
1157
1749
|
throw new K2Error(ServiceError.NOT_FOUND, `Version ${version} for ${id} not found`, "sys_mdb_version_not_found");
|
|
1158
1750
|
}
|
|
1159
1751
|
const snapshot = row.snapshot;
|
|
1752
|
+
const snapshotPlain = this.decryptSecureFieldsDeep(snapshot, `k2db|${collectionName}|${id}`);
|
|
1160
1753
|
// Only apply non-underscore fields; metadata is preserved by replace=true path
|
|
1161
1754
|
const apply = {};
|
|
1162
|
-
for (const [k, v] of Object.entries(
|
|
1755
|
+
for (const [k, v] of Object.entries(snapshotPlain)) {
|
|
1163
1756
|
if (!k.startsWith("_"))
|
|
1164
1757
|
apply[k] = v;
|
|
1165
1758
|
}
|
|
1166
|
-
return this.update(collectionName, id, apply, true);
|
|
1759
|
+
return this.update(collectionName, id, apply, true, scope);
|
|
1167
1760
|
}
|
|
1168
1761
|
}
|