@forklaunch/core 2.0.0 → 2.1.0

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.
@@ -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
- erase(userId: string): Promise<EraseResult>;
56
- export(userId: string): Promise<ExportResult>;
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;AAM/E,MAAM,WAAW,WAAW;IAC1B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,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;IAwBpB,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CA6DhD;IAEK,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA8DlD;CACF"}
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"}
@@ -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
- async erase(userId) {
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.push(entityName);
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
- this.otel.info("[ComplianceDataService] Erase complete", {
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
- entitiesAffected: entitiesAffected.join(","),
785
- recordsDeleted
895
+ entityCount: Object.keys(entities).length,
896
+ failures: failures.length
786
897
  });
787
- return { entitiesAffected, recordsDeleted };
898
+ return { userId, entities, failures };
788
899
  }
789
- async export(userId) {
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
- entities[entityName] = records.map((record) => {
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