@forklaunch/core 1.6.7 → 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.
@@ -48,26 +48,29 @@ import type { EntityManager } from '@mikro-orm/core';
48
48
  * }
49
49
  * ```
50
50
  *
51
- * # The empty tenant is a tenant
51
+ * # The empty tenant is not a tenant
52
52
  *
53
- * Global rows (a billing plan, a trial, a template) are encrypted under the
54
- * empty tenant `''`. A caller that passes `''` means "bind the no-tenant
55
- * key", and gets a Proxy that runs every EM call inside
56
- * `withEncryptionContext('', …)`. Only `undefined` means "do not wrap": the
57
- * EM is returned as is and inherits whatever context is already bound,
58
- * which is right for a super-admin lookup that has not resolved a tenant
59
- * yet, and wrong for anything that writes.
53
+ * `''` is rejected: it throws `EmptyTenantError`. It used to mean "bind the
54
+ * no-tenant key", which is how global rows (a billing plan, a trial, a
55
+ * template) were encrypted — and it made a deliberate global row and a tenant
56
+ * nobody resolved the same key. A path that forgot to bind wrote perfectly
57
+ * readable rows under `''`, and the mistake surfaced later, somewhere else, as
58
+ * the owning tenant's "Failed to decrypt encrypted column value".
60
59
  *
61
- * Earlier versions treated `''` like `undefined` and also seeded the ALS
62
- * with `enterWith`, which mutates the *calling* async resource. Together
63
- * those meant: after one org-scoped EM was created on a request, a later
64
- * "no-tenant" EM on the same resource silently read and wrote under that
65
- * org's key. Global rows then came out unreadable ("Failed to decrypt
66
- * encrypted column value") depending on what had run before on the worker.
60
+ * Global rows need an id you can name and search for. Pick a constant no
61
+ * organization can collide with — the platform uses `'_internal'` — and bind
62
+ * it like any other tenant.
63
+ *
64
+ * `undefined` still means "do not wrap": the EM is returned as is and inherits
65
+ * whatever context is already bound. That is right only for a lookup that has
66
+ * not resolved a tenant yet and reads no encrypted column; anything that
67
+ * touches one now fails with `UnboundTenantError` rather than quietly using
68
+ * the empty key.
67
69
  *
68
70
  * @param em a freshly forked `EntityManager` from `orm.em.fork(...)`
69
- * @param tenantId the org/tenant id to bind; `''` binds the no-tenant key;
71
+ * @param tenantId the org/tenant id to bind; `''` throws;
70
72
  * `undefined` skips wrapping entirely
73
+ * @throws EmptyTenantError when `tenantId` is `''`
71
74
  */
72
75
  export declare function wrapEmWithTenantContext(em: EntityManager, tenantId: string | undefined): EntityManager;
73
76
  //# sourceMappingURL=tenantEm.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tenantEm.d.ts","sourceRoot":"","sources":["../../src/persistence/tenantEm.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,aAAa,EACjB,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,aAAa,CA8Bf"}
1
+ {"version":3,"file":"tenantEm.d.ts","sourceRoot":"","sources":["../../src/persistence/tenantEm.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,aAAa,EACjB,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,aAAa,CA4Bf"}
@@ -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