@forklaunch/core 2.0.0 → 2.1.1
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/lib/services/complianceDataService.d.ts +42 -2
- package/lib/services/complianceDataService.d.ts.map +1 -1
- package/lib/services/index.js +123 -15
- package/lib/services/index.js.map +1 -1
- package/lib/services/index.mjs +123 -15
- package/lib/services/index.mjs.map +1 -1
- package/lib/services/retentionService.d.ts +13 -0
- package/lib/services/retentionService.d.ts.map +1 -1
- package/package.json +9 -9
|
@@ -12,13 +12,45 @@ export interface ComplianceOrm {
|
|
|
12
12
|
getMetadata(): MetadataStorage;
|
|
13
13
|
}
|
|
14
14
|
import { MetricsDefinition } from '../http/types/openTelemetryCollector.types';
|
|
15
|
+
/**
|
|
16
|
+
* Which tenants a subject's rows live under.
|
|
17
|
+
*
|
|
18
|
+
* A compliance walk cannot be tenant-blind. Every PII column is encrypted
|
|
19
|
+
* under its row's tenant, and a WHERE value on one is encrypted under the
|
|
20
|
+
* CURRENT tenant before it is compared — so an unbound walk searches with a
|
|
21
|
+
* key nothing was written with. It matches nothing, everywhere, and an erase
|
|
22
|
+
* that finds nothing looks exactly like an erase with nothing to do.
|
|
23
|
+
*
|
|
24
|
+
* Pass every tenant the subject may have rows under — their organizations,
|
|
25
|
+
* plus whatever constant holds rows belonging to no organization. The walk
|
|
26
|
+
* runs once per tenant and the results are merged.
|
|
27
|
+
*/
|
|
28
|
+
export interface ComplianceTenantScope {
|
|
29
|
+
tenantIds?: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/** An entity the walk could not read, and why. */
|
|
32
|
+
export interface ComplianceEntityFailure {
|
|
33
|
+
entityName: string;
|
|
34
|
+
tenantId?: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
15
37
|
export interface EraseResult {
|
|
16
38
|
entitiesAffected: string[];
|
|
17
39
|
recordsDeleted: number;
|
|
40
|
+
/**
|
|
41
|
+
* Entities the walk could not read. NON-EMPTY MEANS THE ERASE IS
|
|
42
|
+
* INCOMPLETE: the caller asked for a subject's data to be deleted and some
|
|
43
|
+
* of it may remain. It is reported rather than thrown so the rows that
|
|
44
|
+
* could be erased still are, but it must not be ignored — treat a non-empty
|
|
45
|
+
* list as a failed erasure request.
|
|
46
|
+
*/
|
|
47
|
+
failures: ComplianceEntityFailure[];
|
|
18
48
|
}
|
|
19
49
|
export interface ExportResult {
|
|
20
50
|
userId: string;
|
|
21
51
|
entities: Record<string, unknown[]>;
|
|
52
|
+
/** As `EraseResult.failures`: non-empty means the export is incomplete. */
|
|
53
|
+
failures: ComplianceEntityFailure[];
|
|
22
54
|
}
|
|
23
55
|
/**
|
|
24
56
|
* Per-entity userIdField overrides.
|
|
@@ -52,7 +84,15 @@ export declare class ComplianceDataService {
|
|
|
52
84
|
* Returns undefined if no link can be determined.
|
|
53
85
|
*/
|
|
54
86
|
private resolveUserIdField;
|
|
55
|
-
|
|
56
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Run `fn` once per tenant in scope, each inside its own encryption
|
|
89
|
+
* context. With no tenants named it runs once unbound — which is correct
|
|
90
|
+
* only for entities with no encrypted column, and fails loudly otherwise.
|
|
91
|
+
*/
|
|
92
|
+
private perTenant;
|
|
93
|
+
erase(userId: string, scope?: ComplianceTenantScope): Promise<EraseResult>;
|
|
94
|
+
private eraseUnderCurrentTenant;
|
|
95
|
+
export(userId: string, scope?: ComplianceTenantScope): Promise<ExportResult>;
|
|
96
|
+
private exportUnderCurrentTenant;
|
|
57
97
|
}
|
|
58
98
|
//# sourceMappingURL=complianceDataService.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"complianceDataService.d.ts","sourceRoot":"","sources":["../../src/services/complianceDataService.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAEvF;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;IAChC,WAAW,IAAI,eAAe,CAAC;CAChC;AACD,OAAO,EAAE,iBAAiB,EAAE,MAAM,4CAA4C,CAAC;
|
|
1
|
+
{"version":3,"file":"complianceDataService.d.ts","sourceRoot":"","sources":["../../src/services/complianceDataService.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAEvF;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;IAChC,WAAW,IAAI,eAAe,CAAC;CAChC;AACD,OAAO,EAAE,iBAAiB,EAAE,MAAM,4CAA4C,CAAC;AAO/E;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B;AAED,kDAAkD;AAClD,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,QAAQ,EAAE,uBAAuB,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACpC,2EAA2E;IAC3E,QAAQ,EAAE,uBAAuB,EAAE,CAAC;CACrC;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAiB1D;;;;;;;;;GASG;AACH,qBAAa,qBAAqB;IAI9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAJvB,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAuB;IAE5D,YACmB,GAAG,EAAE,aAAa,EAClB,IAAI,EAAE,sBAAsB,CAAC,iBAAiB,CAAC,EAChE,oBAAoB,CAAC,EAAE,oBAAoB,EAG5C;IAED;;;OAGG;IACH,OAAO,CAAC,kBAAkB;IAwB1B;;;;OAIG;YACW,SAAS;IAajB,KAAK,CACT,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,WAAW,CAAC,CA0BtB;YAEa,uBAAuB;IAkE/B,MAAM,CACV,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,YAAY,CAAC,CAevB;YAEa,wBAAwB;CAoEvC"}
|
package/lib/services/index.js
CHANGED
|
@@ -489,6 +489,28 @@ var ValidConfigInjector = class extends ConfigInjector {
|
|
|
489
489
|
validConfigInjector;
|
|
490
490
|
};
|
|
491
491
|
|
|
492
|
+
// src/persistence/encryptedType.ts
|
|
493
|
+
var import_core = require("@mikro-orm/core");
|
|
494
|
+
var import_node_async_hooks = require("async_hooks");
|
|
495
|
+
var _tenantContext = new import_node_async_hooks.AsyncLocalStorage();
|
|
496
|
+
var EmptyTenantError = class extends Error {
|
|
497
|
+
constructor(api) {
|
|
498
|
+
super(
|
|
499
|
+
`${api}: '' is not a tenant id. It used to mean "global", which made a deliberate global row indistinguishable from a tenant nobody resolved. Bind an explicit constant (for example '_internal') for rows that genuinely belong to no organization.`
|
|
500
|
+
);
|
|
501
|
+
this.name = "EmptyTenantError";
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
function assertBindableTenantId(tenantId, api) {
|
|
505
|
+
if (tenantId === "") {
|
|
506
|
+
throw new EmptyTenantError(api);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function withEncryptionContext(tenantId, fn) {
|
|
510
|
+
assertBindableTenantId(tenantId, "withEncryptionContext");
|
|
511
|
+
return _tenantContext.run({ tenantId }, fn);
|
|
512
|
+
}
|
|
513
|
+
|
|
492
514
|
// src/persistence/complianceTypes.ts
|
|
493
515
|
var complianceRegistry = /* @__PURE__ */ new Map();
|
|
494
516
|
function getEntityComplianceFields(entityName) {
|
|
@@ -540,6 +562,47 @@ var RetentionService = class {
|
|
|
540
562
|
otel;
|
|
541
563
|
DEFAULT_BATCH_SIZE = 1e3;
|
|
542
564
|
async enforce(options) {
|
|
565
|
+
const tenantIds = options?.tenantIds ?? [];
|
|
566
|
+
if (tenantIds.length > 1) {
|
|
567
|
+
const merged = {
|
|
568
|
+
processed: 0,
|
|
569
|
+
deleted: 0,
|
|
570
|
+
anonymized: 0,
|
|
571
|
+
errors: 0,
|
|
572
|
+
byEntity: {},
|
|
573
|
+
durationMs: 0
|
|
574
|
+
};
|
|
575
|
+
const start2 = Date.now();
|
|
576
|
+
for (const tenantId of tenantIds) {
|
|
577
|
+
const one = await withEncryptionContext(
|
|
578
|
+
tenantId,
|
|
579
|
+
() => this.enforce({ ...options, tenantIds: [tenantId] })
|
|
580
|
+
);
|
|
581
|
+
merged.processed += one.processed;
|
|
582
|
+
merged.deleted += one.deleted;
|
|
583
|
+
merged.anonymized += one.anonymized;
|
|
584
|
+
merged.errors += one.errors;
|
|
585
|
+
for (const [entityName, counts] of Object.entries(one.byEntity)) {
|
|
586
|
+
const into = merged.byEntity[entityName] ??= {
|
|
587
|
+
deleted: 0,
|
|
588
|
+
anonymized: 0,
|
|
589
|
+
errors: 0
|
|
590
|
+
};
|
|
591
|
+
into.deleted += counts.deleted;
|
|
592
|
+
into.anonymized += counts.anonymized;
|
|
593
|
+
into.errors += counts.errors;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
merged.durationMs = Date.now() - start2;
|
|
597
|
+
return merged;
|
|
598
|
+
}
|
|
599
|
+
if (tenantIds.length === 1) {
|
|
600
|
+
const only = tenantIds[0];
|
|
601
|
+
return withEncryptionContext(
|
|
602
|
+
only,
|
|
603
|
+
() => this.enforce({ ...options, tenantIds: void 0 })
|
|
604
|
+
);
|
|
605
|
+
}
|
|
543
606
|
const start = Date.now();
|
|
544
607
|
const batchSize = options?.batchSize ?? this.DEFAULT_BATCH_SIZE;
|
|
545
608
|
const dryRun = options?.dryRun ?? false;
|
|
@@ -734,9 +797,46 @@ var ComplianceDataService = class {
|
|
|
734
797
|
}
|
|
735
798
|
return void 0;
|
|
736
799
|
}
|
|
737
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Run `fn` once per tenant in scope, each inside its own encryption
|
|
802
|
+
* context. With no tenants named it runs once unbound — which is correct
|
|
803
|
+
* only for entities with no encrypted column, and fails loudly otherwise.
|
|
804
|
+
*/
|
|
805
|
+
async perTenant(scope, fn) {
|
|
806
|
+
const tenantIds = scope?.tenantIds ?? [];
|
|
807
|
+
if (tenantIds.length === 0) return [await fn(void 0)];
|
|
808
|
+
const results = [];
|
|
809
|
+
for (const tenantId of tenantIds) {
|
|
810
|
+
results.push(await withEncryptionContext(tenantId, () => fn(tenantId)));
|
|
811
|
+
}
|
|
812
|
+
return results;
|
|
813
|
+
}
|
|
814
|
+
async erase(userId, scope) {
|
|
815
|
+
const entitiesAffected = /* @__PURE__ */ new Set();
|
|
816
|
+
const failures = [];
|
|
817
|
+
let recordsDeleted = 0;
|
|
818
|
+
await this.perTenant(scope, async (tenantId) => {
|
|
819
|
+
recordsDeleted += await this.eraseUnderCurrentTenant(
|
|
820
|
+
userId,
|
|
821
|
+
tenantId,
|
|
822
|
+
entitiesAffected,
|
|
823
|
+
failures
|
|
824
|
+
);
|
|
825
|
+
});
|
|
826
|
+
this.otel.info("[ComplianceDataService] Erase complete", {
|
|
827
|
+
userId,
|
|
828
|
+
entitiesAffected: [...entitiesAffected].join(","),
|
|
829
|
+
recordsDeleted,
|
|
830
|
+
failures: failures.length
|
|
831
|
+
});
|
|
832
|
+
return {
|
|
833
|
+
entitiesAffected: [...entitiesAffected],
|
|
834
|
+
recordsDeleted,
|
|
835
|
+
failures
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
async eraseUnderCurrentTenant(userId, tenantId, entitiesAffected, failures) {
|
|
738
839
|
const em = this.orm.em.fork();
|
|
739
|
-
const entitiesAffected = [];
|
|
740
840
|
let recordsDeleted = 0;
|
|
741
841
|
const allMetadata = [...this.orm.getMetadata().getAll().values()];
|
|
742
842
|
for (const metadata of allMetadata) {
|
|
@@ -764,14 +864,16 @@ var ComplianceDataService = class {
|
|
|
764
864
|
[userIdField]: userId
|
|
765
865
|
});
|
|
766
866
|
if (records.length > 0) {
|
|
767
|
-
entitiesAffected.
|
|
867
|
+
entitiesAffected.add(entityName);
|
|
768
868
|
recordsDeleted += records.length;
|
|
769
869
|
records.forEach((r) => em.remove(r));
|
|
770
870
|
}
|
|
771
871
|
} catch (err) {
|
|
872
|
+
failures.push({ entityName, tenantId, reason: String(err) });
|
|
772
873
|
this.otel.error("[ComplianceDataService] Failed to erase entity", {
|
|
773
874
|
entityName,
|
|
774
875
|
userIdField,
|
|
876
|
+
tenantId,
|
|
775
877
|
error: String(err)
|
|
776
878
|
});
|
|
777
879
|
}
|
|
@@ -779,16 +881,24 @@ var ComplianceDataService = class {
|
|
|
779
881
|
if (recordsDeleted > 0) {
|
|
780
882
|
await em.flush();
|
|
781
883
|
}
|
|
782
|
-
|
|
884
|
+
return recordsDeleted;
|
|
885
|
+
}
|
|
886
|
+
async export(userId, scope) {
|
|
887
|
+
const entities = {};
|
|
888
|
+
const failures = [];
|
|
889
|
+
await this.perTenant(
|
|
890
|
+
scope,
|
|
891
|
+
(tenantId) => this.exportUnderCurrentTenant(userId, tenantId, entities, failures)
|
|
892
|
+
);
|
|
893
|
+
this.otel.info("[ComplianceDataService] Export complete", {
|
|
783
894
|
userId,
|
|
784
|
-
|
|
785
|
-
|
|
895
|
+
entityCount: Object.keys(entities).length,
|
|
896
|
+
failures: failures.length
|
|
786
897
|
});
|
|
787
|
-
return {
|
|
898
|
+
return { userId, entities, failures };
|
|
788
899
|
}
|
|
789
|
-
async
|
|
900
|
+
async exportUnderCurrentTenant(userId, tenantId, entities, failures) {
|
|
790
901
|
const em = this.orm.em.fork();
|
|
791
|
-
const entities = {};
|
|
792
902
|
const allMetadata = [...this.orm.getMetadata().getAll().values()];
|
|
793
903
|
for (const metadata of allMetadata) {
|
|
794
904
|
const entityName = metadata.className;
|
|
@@ -812,7 +922,7 @@ var ComplianceDataService = class {
|
|
|
812
922
|
});
|
|
813
923
|
if (records.length > 0) {
|
|
814
924
|
const piiFieldNames = [...fields.entries()].filter(([, level]) => level !== "none").map(([name]) => name);
|
|
815
|
-
|
|
925
|
+
const rows = records.map((record) => {
|
|
816
926
|
const filtered = {};
|
|
817
927
|
filtered["id"] = record["id"];
|
|
818
928
|
for (const fieldName of piiFieldNames) {
|
|
@@ -820,20 +930,18 @@ var ComplianceDataService = class {
|
|
|
820
930
|
}
|
|
821
931
|
return filtered;
|
|
822
932
|
});
|
|
933
|
+
entities[entityName] = [...entities[entityName] ?? [], ...rows];
|
|
823
934
|
}
|
|
824
935
|
} catch (err) {
|
|
936
|
+
failures.push({ entityName, tenantId, reason: String(err) });
|
|
825
937
|
this.otel.error("[ComplianceDataService] Failed to export entity", {
|
|
826
938
|
entityName,
|
|
827
939
|
userIdField,
|
|
940
|
+
tenantId,
|
|
828
941
|
error: String(err)
|
|
829
942
|
});
|
|
830
943
|
}
|
|
831
944
|
}
|
|
832
|
-
this.otel.info("[ComplianceDataService] Export complete", {
|
|
833
|
-
userId,
|
|
834
|
-
entityCount: Object.keys(entities).length
|
|
835
|
-
});
|
|
836
|
-
return { userId, entities };
|
|
837
945
|
}
|
|
838
946
|
};
|
|
839
947
|
|