@forklaunch/core 1.5.20 → 1.6.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.
- package/lib/persistence/fieldEncryptor.d.ts +137 -9
- package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
- package/lib/persistence/index.d.ts +2 -1
- package/lib/persistence/index.d.ts.map +1 -1
- package/lib/persistence/index.js +409 -26
- package/lib/persistence/index.js.map +1 -1
- package/lib/persistence/index.mjs +391 -26
- package/lib/persistence/index.mjs.map +1 -1
- package/lib/persistence/keyRotation.d.ts +118 -0
- package/lib/persistence/keyRotation.d.ts.map +1 -0
- package/package.json +1 -1
package/lib/persistence/index.js
CHANGED
|
@@ -32,18 +32,29 @@ var persistence_exports = {};
|
|
|
32
32
|
__export(persistence_exports, {
|
|
33
33
|
ComplianceLevel: () => ComplianceLevel,
|
|
34
34
|
DecryptionError: () => DecryptionError,
|
|
35
|
+
ENCRYPTED_PREFIXES: () => ENCRYPTED_PREFIXES2,
|
|
36
|
+
ENCRYPTION_FORMAT_ENV: () => ENCRYPTION_FORMAT_ENV,
|
|
37
|
+
ENCRYPTION_KEY_ENV: () => ENCRYPTION_KEY_ENV,
|
|
35
38
|
EncryptedType: () => EncryptedType,
|
|
36
39
|
EncryptionRequiredError: () => EncryptionRequiredError,
|
|
37
40
|
FieldEncryptor: () => FieldEncryptor,
|
|
41
|
+
LEGACY_ENCRYPTION_KEYS_ENV: () => LEGACY_ENCRYPTION_KEYS_ENV,
|
|
42
|
+
LEGACY_ENCRYPTION_KEY_ENV: () => LEGACY_ENCRYPTION_KEY_ENV,
|
|
38
43
|
MissingEncryptionKeyError: () => MissingEncryptionKeyError,
|
|
39
44
|
RetentionAction: () => RetentionAction,
|
|
40
45
|
RetentionDuration: () => RetentionDuration,
|
|
41
46
|
RlsEventSubscriber: () => RlsEventSubscriber,
|
|
42
47
|
TENANT_FILTER_NAME: () => TENANT_FILTER_NAME,
|
|
43
48
|
asEncryptionSafe: () => asEncryptionSafe,
|
|
49
|
+
classifyEncryptedValue: () => classifyEncryptedValue,
|
|
50
|
+
collectFallbackTenantIds: () => collectFallbackTenantIds,
|
|
51
|
+
countValuesByKeyId: () => countValuesByKeyId,
|
|
44
52
|
createTenantFilterDef: () => createTenantFilterDef,
|
|
53
|
+
defaultTenantIdsFor: () => defaultTenantIdsFor,
|
|
45
54
|
defineComplianceEntity: () => defineComplianceEntity,
|
|
55
|
+
encryptionKeyId: () => encryptionKeyId,
|
|
46
56
|
entityHasEncryptedFields: () => entityHasEncryptedFields,
|
|
57
|
+
entityMetadataList: () => entityMetadataList,
|
|
47
58
|
fp: () => fp,
|
|
48
59
|
getAllRetentionPolicies: () => getAllRetentionPolicies,
|
|
49
60
|
getAllUserIdFields: () => getAllUserIdFields,
|
|
@@ -53,11 +64,18 @@ __export(persistence_exports, {
|
|
|
53
64
|
getEntityRetention: () => getEntityRetention,
|
|
54
65
|
getEntityUserIdField: () => getEntityUserIdField,
|
|
55
66
|
getSuperAdminContext: () => getSuperAdminContext,
|
|
67
|
+
isEncryptedCiphertext: () => isEncryptedCiphertext,
|
|
68
|
+
isEncryptedValue: () => isEncryptedValue,
|
|
56
69
|
parseDuration: () => parseDuration,
|
|
70
|
+
parseEncryptionKeyList: () => parseEncryptionKeyList,
|
|
71
|
+
reencryptEncryptedColumns: () => reencryptEncryptedColumns,
|
|
57
72
|
registerEncryptor: () => registerEncryptor,
|
|
73
|
+
rotationTotals: () => rotationTotals,
|
|
58
74
|
setEncryptionTenantId: () => setEncryptionTenantId,
|
|
59
75
|
setupRls: () => setupRls,
|
|
60
76
|
setupTenantFilter: () => setupTenantFilter,
|
|
77
|
+
stampedKeyId: () => stampedKeyId,
|
|
78
|
+
stampedPrefix: () => stampedPrefix,
|
|
61
79
|
subtractDuration: () => subtractDuration,
|
|
62
80
|
withEncryptionContext: () => withEncryptionContext,
|
|
63
81
|
wrapEmWithForgivingDecryption: () => wrapEmWithForgivingDecryption,
|
|
@@ -234,7 +252,7 @@ var import_core2 = require("@mikro-orm/core");
|
|
|
234
252
|
// src/persistence/encryptedType.ts
|
|
235
253
|
var import_core = require("@mikro-orm/core");
|
|
236
254
|
var import_node_async_hooks = require("async_hooks");
|
|
237
|
-
var ENCRYPTED_PREFIXES = ["v1:", "v2:"];
|
|
255
|
+
var ENCRYPTED_PREFIXES = ["v1:", "v2:", "v3:"];
|
|
238
256
|
function isEncrypted(value) {
|
|
239
257
|
return ENCRYPTED_PREFIXES.some((p3) => value.startsWith(p3));
|
|
240
258
|
}
|
|
@@ -638,26 +656,122 @@ var IV_BYTES = 12;
|
|
|
638
656
|
var KEY_BYTES = 32;
|
|
639
657
|
var HKDF_HASH = "sha256";
|
|
640
658
|
var HKDF_SALT = Buffer.alloc(0);
|
|
641
|
-
var
|
|
642
|
-
|
|
643
|
-
|
|
659
|
+
var KEY_ID_HEX_CHARS = 12;
|
|
660
|
+
var DERIVED_KEY_CACHE_LIMIT = 4096;
|
|
661
|
+
var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:"];
|
|
662
|
+
function isEncryptedCiphertext(value) {
|
|
663
|
+
return typeof value === "string" && ENCRYPTED_PREFIXES2.some((p3) => value.startsWith(p3));
|
|
664
|
+
}
|
|
665
|
+
var ENCRYPTION_KEY_ENV = "ENCRYPTION_KEY";
|
|
666
|
+
var LEGACY_ENCRYPTION_KEYS_ENV = "LEGACY_ENCRYPTION_KEYS";
|
|
667
|
+
var LEGACY_ENCRYPTION_KEY_ENV = "LEGACY_ENCRYPTION_KEY";
|
|
668
|
+
var ENCRYPTION_FORMAT_ENV = "ENCRYPTION_FORMAT";
|
|
669
|
+
function parseEncryptionKeyList(...sources) {
|
|
670
|
+
const keys = [];
|
|
671
|
+
for (const source of sources) {
|
|
672
|
+
for (const part of (source ?? "").split(/[\s,;]+/)) {
|
|
673
|
+
const key = part.trim().replace(/^["']|["']$/g, "");
|
|
674
|
+
if (key && !keys.includes(key)) keys.push(key);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return keys;
|
|
678
|
+
}
|
|
679
|
+
function encryptionKeyId(masterKey) {
|
|
680
|
+
return import_crypto.default.createHash("sha256").update(masterKey).digest("hex").slice(0, KEY_ID_HEX_CHARS);
|
|
681
|
+
}
|
|
682
|
+
function stampedKeyId(ciphertext) {
|
|
683
|
+
if (!ciphertext.startsWith("v3:")) return null;
|
|
684
|
+
const parts = ciphertext.split(":");
|
|
685
|
+
return parts.length === 5 ? parts[1] : null;
|
|
686
|
+
}
|
|
687
|
+
function stampedPrefix(keyId) {
|
|
688
|
+
return `v3:${keyId}:`;
|
|
689
|
+
}
|
|
690
|
+
var FieldEncryptor = class _FieldEncryptor {
|
|
691
|
+
ring;
|
|
692
|
+
byId;
|
|
693
|
+
derived = /* @__PURE__ */ new Map();
|
|
694
|
+
format;
|
|
695
|
+
constructor(masterKey, options = {}) {
|
|
644
696
|
if (!masterKey) {
|
|
645
697
|
throw new MissingEncryptionKeyError();
|
|
646
698
|
}
|
|
647
|
-
|
|
699
|
+
const ring = [{ id: encryptionKeyId(masterKey), masterKey }];
|
|
700
|
+
for (const key of options.previousKeys ?? []) {
|
|
701
|
+
if (!key || ring.some((k) => k.masterKey === key)) continue;
|
|
702
|
+
ring.push({ id: encryptionKeyId(key), masterKey: key });
|
|
703
|
+
}
|
|
704
|
+
this.ring = ring;
|
|
705
|
+
this.byId = new Map(ring.map((k) => [k.id, k]));
|
|
706
|
+
this.format = options.format ?? "v2";
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Build the ring from the environment: `ENCRYPTION_KEY` plus
|
|
710
|
+
* `LEGACY_ENCRYPTION_KEYS` (comma separated) and the older
|
|
711
|
+
* `LEGACY_ENCRYPTION_KEY`; `ENCRYPTION_FORMAT` selects the envelope.
|
|
712
|
+
*/
|
|
713
|
+
static fromEnv(env = process.env) {
|
|
714
|
+
const masterKey = env[ENCRYPTION_KEY_ENV];
|
|
715
|
+
if (!masterKey) {
|
|
716
|
+
throw new MissingEncryptionKeyError(`${ENCRYPTION_KEY_ENV} must be set`);
|
|
717
|
+
}
|
|
718
|
+
const format = env[ENCRYPTION_FORMAT_ENV]?.trim().toLowerCase();
|
|
719
|
+
if (format && format !== "v2" && format !== "v3") {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`${ENCRYPTION_FORMAT_ENV} must be "v2" or "v3", got "${format}"`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
return new _FieldEncryptor(masterKey, {
|
|
725
|
+
previousKeys: parseEncryptionKeyList(
|
|
726
|
+
env[LEGACY_ENCRYPTION_KEYS_ENV],
|
|
727
|
+
env[LEGACY_ENCRYPTION_KEY_ENV]
|
|
728
|
+
),
|
|
729
|
+
format: format ?? "v2"
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
/** Fingerprints of the ring, current first. Safe to log. */
|
|
733
|
+
get keyIds() {
|
|
734
|
+
return {
|
|
735
|
+
current: this.ring[0].id,
|
|
736
|
+
previous: this.ring.slice(1).map((k) => k.id)
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/** A copy of this encryptor with a different set of previous keys. */
|
|
740
|
+
withPreviousKeys(previousKeys) {
|
|
741
|
+
return new _FieldEncryptor(this.ring[0].masterKey, {
|
|
742
|
+
previousKeys,
|
|
743
|
+
format: this.format
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
/** A copy of this encryptor writing a different envelope. */
|
|
747
|
+
withFormat(format) {
|
|
748
|
+
return new _FieldEncryptor(this.ring[0].masterKey, {
|
|
749
|
+
previousKeys: this.ring.slice(1).map((k) => k.masterKey),
|
|
750
|
+
format
|
|
751
|
+
});
|
|
648
752
|
}
|
|
649
753
|
/**
|
|
650
|
-
* Derive a per-tenant 32-byte key using HKDF-SHA256
|
|
651
|
-
* The master key is
|
|
754
|
+
* Derive a per-tenant 32-byte key using HKDF-SHA256 from the CURRENT
|
|
755
|
+
* master key. The master key is the input key material and the tenantId
|
|
756
|
+
* the info context.
|
|
652
757
|
*/
|
|
653
758
|
deriveKey(tenantId) {
|
|
654
|
-
return
|
|
655
|
-
|
|
759
|
+
return this.deriveKeyFor(this.ring[0], tenantId);
|
|
760
|
+
}
|
|
761
|
+
deriveKeyFor(key, tenantId) {
|
|
762
|
+
const cacheKey = `${key.id} ${tenantId}`;
|
|
763
|
+
const cached = this.derived.get(cacheKey);
|
|
764
|
+
if (cached) return cached;
|
|
765
|
+
const derived = Buffer.from(
|
|
766
|
+
import_crypto.default.hkdfSync(HKDF_HASH, key.masterKey, HKDF_SALT, tenantId, KEY_BYTES)
|
|
656
767
|
);
|
|
768
|
+
if (this.derived.size >= DERIVED_KEY_CACHE_LIMIT) this.derived.clear();
|
|
769
|
+
this.derived.set(cacheKey, derived);
|
|
770
|
+
return derived;
|
|
657
771
|
}
|
|
658
772
|
/**
|
|
659
773
|
* Derive a deterministic IV from the key and plaintext using HMAC-SHA256,
|
|
660
|
-
* truncated to IV_BYTES. Same plaintext + same key
|
|
774
|
+
* truncated to IV_BYTES. Same plaintext + same key -> same IV -> same
|
|
661
775
|
* ciphertext. This enables WHERE clause matching on encrypted columns
|
|
662
776
|
* while maintaining AES-256-GCM authenticated encryption.
|
|
663
777
|
*
|
|
@@ -678,39 +792,95 @@ var FieldEncryptor = class {
|
|
|
678
792
|
cipher.final()
|
|
679
793
|
]);
|
|
680
794
|
const authTag = cipher.getAuthTag();
|
|
681
|
-
|
|
682
|
-
"v2",
|
|
795
|
+
const body = [
|
|
683
796
|
iv.toString("base64"),
|
|
684
797
|
authTag.toString("base64"),
|
|
685
798
|
encrypted.toString("base64")
|
|
686
|
-
]
|
|
799
|
+
];
|
|
800
|
+
return this.format === "v3" ? ["v3", this.ring[0].id, ...body].join(":") : ["v2", ...body].join(":");
|
|
687
801
|
}
|
|
688
802
|
decrypt(ciphertext, tenantId) {
|
|
689
803
|
if (ciphertext === null || ciphertext === void 0) {
|
|
690
804
|
return null;
|
|
691
805
|
}
|
|
806
|
+
return this.open(ciphertext, tenantId ?? "").plaintext;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Decrypt and report which key opened the value and whether a rewrite
|
|
810
|
+
* would change it. Throws {@link DecryptionError} when no key in the
|
|
811
|
+
* ring does.
|
|
812
|
+
*/
|
|
813
|
+
open(ciphertext, tenantId = "") {
|
|
692
814
|
const parts = ciphertext.split(":");
|
|
693
|
-
|
|
815
|
+
const version = parts[0];
|
|
816
|
+
let candidates;
|
|
817
|
+
let body;
|
|
818
|
+
if (version === "v3" && parts.length === 5) {
|
|
819
|
+
const key = this.byId.get(parts[1]);
|
|
820
|
+
if (!key) {
|
|
821
|
+
throw new DecryptionError(
|
|
822
|
+
`Decryption failed: value is stamped with key ${parts[1]}, which is not in the ring (current ${this.ring[0].id}; previous ${this.ring.slice(1).map((k) => k.id).join(", ") || "none"}). Add it to ${LEGACY_ENCRYPTION_KEYS_ENV}.`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
candidates = [key];
|
|
826
|
+
body = parts.slice(2);
|
|
827
|
+
} else if ((version === "v1" || version === "v2") && parts.length === 4) {
|
|
828
|
+
candidates = this.ring;
|
|
829
|
+
body = parts.slice(1);
|
|
830
|
+
} else {
|
|
694
831
|
throw new DecryptionError(
|
|
695
832
|
`Unknown ciphertext version or malformed format`
|
|
696
833
|
);
|
|
697
834
|
}
|
|
698
|
-
const [
|
|
835
|
+
const [ivB64, authTagB64, encryptedB64] = body;
|
|
699
836
|
const iv = Buffer.from(ivB64, "base64");
|
|
700
837
|
const authTag = Buffer.from(authTagB64, "base64");
|
|
701
838
|
const encrypted = Buffer.from(encryptedB64, "base64");
|
|
702
|
-
const key
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
839
|
+
for (const key of candidates) {
|
|
840
|
+
try {
|
|
841
|
+
const decipher = import_crypto.default.createDecipheriv(
|
|
842
|
+
ALGORITHM,
|
|
843
|
+
this.deriveKeyFor(key, tenantId),
|
|
844
|
+
iv
|
|
845
|
+
);
|
|
846
|
+
decipher.setAuthTag(authTag);
|
|
847
|
+
const decrypted = Buffer.concat([
|
|
848
|
+
decipher.update(encrypted),
|
|
849
|
+
decipher.final()
|
|
850
|
+
]);
|
|
851
|
+
const current = key === this.ring[0];
|
|
852
|
+
return {
|
|
853
|
+
plaintext: decrypted.toString("utf8"),
|
|
854
|
+
keyId: key.id,
|
|
855
|
+
current,
|
|
856
|
+
version,
|
|
857
|
+
stale: !current || this.format === "v3" && version !== "v3"
|
|
858
|
+
};
|
|
859
|
+
} catch {
|
|
860
|
+
}
|
|
713
861
|
}
|
|
862
|
+
throw new DecryptionError(
|
|
863
|
+
candidates.length > 1 ? `Decryption failed: none of the ${candidates.length} keys in the ring opens this value (ciphertext corrupted, wrong tenant, or the key it was written with is not in ${LEGACY_ENCRYPTION_KEYS_ENV})` : `Decryption failed: key ${candidates[0].id} does not open this value (ciphertext corrupted or wrong tenant)`
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* True when a rewrite would change the stored bytes: the value is under
|
|
868
|
+
* a previous key, or unstamped while this encryptor writes `v3`. Throws
|
|
869
|
+
* when no key opens it.
|
|
870
|
+
*/
|
|
871
|
+
needsRotation(ciphertext, tenantId = "") {
|
|
872
|
+
return this.open(ciphertext, tenantId).stale;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Rewrite a value under the current key in the current format. Returns
|
|
876
|
+
* the input unchanged when nothing would change, so callers can compare
|
|
877
|
+
* to detect writes.
|
|
878
|
+
*/
|
|
879
|
+
rotate(ciphertext, tenantId = "") {
|
|
880
|
+
if (ciphertext === null || ciphertext === void 0) return null;
|
|
881
|
+
const opened = this.open(ciphertext, tenantId);
|
|
882
|
+
if (!opened.stale) return ciphertext;
|
|
883
|
+
return this.encrypt(opened.plaintext, tenantId);
|
|
714
884
|
}
|
|
715
885
|
};
|
|
716
886
|
|
|
@@ -768,6 +938,201 @@ function resolveEntityName2(entityNameOrEntity) {
|
|
|
768
938
|
return void 0;
|
|
769
939
|
}
|
|
770
940
|
|
|
941
|
+
// src/persistence/keyRotation.ts
|
|
942
|
+
var ENCRYPTED_LEVELS4 = /* @__PURE__ */ new Set(["pii", "phi", "pci"]);
|
|
943
|
+
var isEncryptedValue = isEncryptedCiphertext;
|
|
944
|
+
var tryOpen = (encryptor, value, tenantId) => {
|
|
945
|
+
try {
|
|
946
|
+
return encryptor.open(value, tenantId);
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
function classifyEncryptedValue(value, encryptor, tenantIds, fallbackTenantIds = []) {
|
|
952
|
+
if (!isEncryptedValue(value)) return { kind: "plaintext" };
|
|
953
|
+
const primary = tenantIds.length ? tenantIds : [""];
|
|
954
|
+
const candidates = [
|
|
955
|
+
...primary,
|
|
956
|
+
...fallbackTenantIds.filter((t) => !primary.includes(t))
|
|
957
|
+
];
|
|
958
|
+
for (const tenantId of candidates) {
|
|
959
|
+
const opened = tryOpen(encryptor, value, tenantId);
|
|
960
|
+
if (!opened) continue;
|
|
961
|
+
if (!opened.stale) {
|
|
962
|
+
return { kind: "current", tenantId, keyId: opened.keyId };
|
|
963
|
+
}
|
|
964
|
+
const next = encryptor.encrypt(opened.plaintext, tenantId);
|
|
965
|
+
return next ? { kind: "rewritten", tenantId, keyId: opened.keyId, next } : { kind: "unreadable" };
|
|
966
|
+
}
|
|
967
|
+
return { kind: "unreadable" };
|
|
968
|
+
}
|
|
969
|
+
function defaultTenantIdsFor(_entityName, row) {
|
|
970
|
+
const candidates = [];
|
|
971
|
+
for (const key of ["organization_id", "organizationId"]) {
|
|
972
|
+
const v = row[key];
|
|
973
|
+
if (typeof v === "string" && v) candidates.push(v);
|
|
974
|
+
}
|
|
975
|
+
candidates.push("");
|
|
976
|
+
return [...new Set(candidates)];
|
|
977
|
+
}
|
|
978
|
+
var quote = (id) => `"${id.replace(/"/g, '""')}"`;
|
|
979
|
+
async function collectFallbackTenantIds(execute) {
|
|
980
|
+
const ids = /* @__PURE__ */ new Set();
|
|
981
|
+
const tables = await execute(
|
|
982
|
+
`select table_name from information_schema.columns where table_schema = current_schema() and column_name = 'organization_id'`
|
|
983
|
+
);
|
|
984
|
+
for (const { table_name } of tables) {
|
|
985
|
+
const rows = await execute(
|
|
986
|
+
`select distinct "organization_id" as id from ${quote(table_name)} where "organization_id" is not null`
|
|
987
|
+
);
|
|
988
|
+
for (const { id } of rows) if (typeof id === "string" && id) ids.add(id);
|
|
989
|
+
}
|
|
990
|
+
const org = await execute(`select to_regclass('"organization"') as t`);
|
|
991
|
+
if (org[0]?.t) {
|
|
992
|
+
const rows = await execute(`select "id" from "organization"`);
|
|
993
|
+
for (const { id } of rows) if (typeof id === "string" && id) ids.add(id);
|
|
994
|
+
}
|
|
995
|
+
return [...ids];
|
|
996
|
+
}
|
|
997
|
+
function entityMetadataList(em) {
|
|
998
|
+
const all = em.getMetadata().getAll();
|
|
999
|
+
if (all instanceof Map) return [...all.values()];
|
|
1000
|
+
if (Array.isArray(all)) return all;
|
|
1001
|
+
if (all && typeof all === "object")
|
|
1002
|
+
return Object.values(all);
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
async function reencryptEncryptedColumns(options) {
|
|
1006
|
+
const { em, encryptor, dryRun = false, log = () => void 0 } = options;
|
|
1007
|
+
const tenantIdsFor = options.tenantIdsFor ?? defaultTenantIdsFor;
|
|
1008
|
+
const fallbackTenantIds = options.fallbackTenantIds ?? [];
|
|
1009
|
+
const connection = em.getConnection();
|
|
1010
|
+
const execute = options.execute ?? ((sql, params) => connection.execute(sql, params));
|
|
1011
|
+
const reports = [];
|
|
1012
|
+
const ids = encryptor.keyIds;
|
|
1013
|
+
log(
|
|
1014
|
+
`[key-rotation] current key ${ids.current}; previous: ${ids.previous.join(", ") || "none"}; ${fallbackTenantIds.length} fallback tenant(s)${dryRun ? "; DRY RUN" : ""}`
|
|
1015
|
+
);
|
|
1016
|
+
const metas = entityMetadataList(em);
|
|
1017
|
+
if (metas.length === 0) {
|
|
1018
|
+
throw new Error(
|
|
1019
|
+
"[key-rotation] MikroORM reported no entities; refusing to report a sweep over nothing"
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
for (const meta of metas) {
|
|
1023
|
+
if (meta.abstract || meta.embeddable || !meta.tableName) continue;
|
|
1024
|
+
const fields = getEntityComplianceFields(meta.className);
|
|
1025
|
+
if (!fields) continue;
|
|
1026
|
+
const props = meta.properties;
|
|
1027
|
+
const encryptedProps = [...fields.entries()].filter(([, level]) => ENCRYPTED_LEVELS4.has(level)).map(([prop]) => props[prop]).filter(
|
|
1028
|
+
(p3) => !!p3 && Array.isArray(p3.fieldNames) && p3.fieldNames.length === 1
|
|
1029
|
+
);
|
|
1030
|
+
if (encryptedProps.length === 0) continue;
|
|
1031
|
+
const pkProp = props[meta.primaryKeys[0]];
|
|
1032
|
+
if (!pkProp) continue;
|
|
1033
|
+
const pkColumn = pkProp.fieldNames[0];
|
|
1034
|
+
const columns = encryptedProps.map((p3) => p3.fieldNames[0]);
|
|
1035
|
+
const report = {
|
|
1036
|
+
table: meta.tableName,
|
|
1037
|
+
columns,
|
|
1038
|
+
scanned: 0,
|
|
1039
|
+
plaintext: 0,
|
|
1040
|
+
current: 0,
|
|
1041
|
+
rewritten: 0,
|
|
1042
|
+
unreadable: 0,
|
|
1043
|
+
unreadableIds: [],
|
|
1044
|
+
byKeyId: {},
|
|
1045
|
+
missingKeyIds: {}
|
|
1046
|
+
};
|
|
1047
|
+
const exists = await execute(`select to_regclass(?) as t`, [
|
|
1048
|
+
quote(meta.tableName)
|
|
1049
|
+
]);
|
|
1050
|
+
if (!exists[0]?.t) {
|
|
1051
|
+
log(
|
|
1052
|
+
`[key-rotation] ${meta.tableName}: table does not exist yet, skipped`
|
|
1053
|
+
);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const rows = await execute(
|
|
1057
|
+
`select * from ${quote(meta.tableName)}`
|
|
1058
|
+
);
|
|
1059
|
+
for (const row of rows) {
|
|
1060
|
+
report.scanned += 1;
|
|
1061
|
+
const tenantIds = tenantIdsFor(meta.className, row);
|
|
1062
|
+
const updates = [];
|
|
1063
|
+
let rowUnreadable = false;
|
|
1064
|
+
for (const prop of encryptedProps) {
|
|
1065
|
+
const column = prop.fieldNames[0];
|
|
1066
|
+
const outcome = classifyEncryptedValue(
|
|
1067
|
+
row[column],
|
|
1068
|
+
encryptor,
|
|
1069
|
+
tenantIds,
|
|
1070
|
+
fallbackTenantIds
|
|
1071
|
+
);
|
|
1072
|
+
if (outcome.kind === "plaintext") report.plaintext += 1;
|
|
1073
|
+
else if (outcome.kind === "current") report.current += 1;
|
|
1074
|
+
else if (outcome.kind === "rewritten") {
|
|
1075
|
+
report.rewritten += 1;
|
|
1076
|
+
report.byKeyId[outcome.keyId] = (report.byKeyId[outcome.keyId] ?? 0) + 1;
|
|
1077
|
+
updates.push({
|
|
1078
|
+
column,
|
|
1079
|
+
next: outcome.next,
|
|
1080
|
+
jsonb: (prop.columnTypes?.[0] ?? "").toLowerCase().startsWith("json")
|
|
1081
|
+
});
|
|
1082
|
+
} else {
|
|
1083
|
+
report.unreadable += 1;
|
|
1084
|
+
rowUnreadable = true;
|
|
1085
|
+
const missing2 = stampedKeyId(String(row[column]));
|
|
1086
|
+
if (missing2)
|
|
1087
|
+
report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (rowUnreadable) report.unreadableIds.push(String(row[pkColumn]));
|
|
1091
|
+
if (updates.length && !dryRun) {
|
|
1092
|
+
const sets = updates.map(
|
|
1093
|
+
(u) => `${quote(u.column)} = ${u.jsonb ? "to_jsonb(?::text)" : "?"}`
|
|
1094
|
+
);
|
|
1095
|
+
await execute(
|
|
1096
|
+
`update ${quote(meta.tableName)} set ${sets.join(", ")} where ${quote(pkColumn)} = ?`,
|
|
1097
|
+
[...updates.map((u) => u.next), row[pkColumn]]
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const byKey = Object.entries(report.byKeyId).map(([k, n]) => `${k}: ${n}`).join(", ");
|
|
1102
|
+
const missing = Object.entries(report.missingKeyIds).map(([k, n]) => `${k}: ${n}`).join(", ");
|
|
1103
|
+
log(
|
|
1104
|
+
`[key-rotation] ${meta.tableName}: scanned ${report.scanned} rows, ${report.rewritten} values ${dryRun ? "would be " : ""}rewritten${byKey ? ` (from ${byKey})` : ""}, ${report.current} already current, ${report.plaintext} plaintext, ${report.unreadable} unreadable${missing ? ` (stamped with keys not in the ring: ${missing})` : ""}${report.unreadableIds.length ? ` (rows ${report.unreadableIds.slice(0, 10).join(", ")}${report.unreadableIds.length > 10 ? ", ..." : ""})` : ""}`
|
|
1105
|
+
);
|
|
1106
|
+
reports.push(report);
|
|
1107
|
+
}
|
|
1108
|
+
return reports;
|
|
1109
|
+
}
|
|
1110
|
+
async function countValuesByKeyId(execute, table, column) {
|
|
1111
|
+
const rows = await execute(
|
|
1112
|
+
`select case when ${quote(column)} like 'v3:%' then split_part(${quote(column)}, ':', 2) when ${quote(column)} like 'v1:%' or ${quote(column)} like 'v2:%' then '' else null end as key_id, count(*)::int as n from ${quote(table)} group by 1`
|
|
1113
|
+
);
|
|
1114
|
+
const byKeyId = {};
|
|
1115
|
+
let unstamped = 0;
|
|
1116
|
+
let plaintext = 0;
|
|
1117
|
+
for (const { key_id, n } of rows) {
|
|
1118
|
+
if (key_id === null) plaintext += Number(n);
|
|
1119
|
+
else if (key_id === "") unstamped += Number(n);
|
|
1120
|
+
else byKeyId[key_id] = (byKeyId[key_id] ?? 0) + Number(n);
|
|
1121
|
+
}
|
|
1122
|
+
return { byKeyId, unstamped, plaintext };
|
|
1123
|
+
}
|
|
1124
|
+
function rotationTotals(reports) {
|
|
1125
|
+
const sum = (pick) => reports.reduce((n, r) => n + pick(r), 0);
|
|
1126
|
+
return {
|
|
1127
|
+
tables: reports.length,
|
|
1128
|
+
scanned: sum((r) => r.scanned),
|
|
1129
|
+
rewritten: sum((r) => r.rewritten),
|
|
1130
|
+
current: sum((r) => r.current),
|
|
1131
|
+
plaintext: sum((r) => r.plaintext),
|
|
1132
|
+
unreadable: sum((r) => r.unreadable)
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
771
1136
|
// src/persistence/tenantFilter.ts
|
|
772
1137
|
var TENANT_FILTER_NAME = "tenant";
|
|
773
1138
|
function createTenantFilterDef(column = "organizationId", relationName) {
|
|
@@ -936,18 +1301,29 @@ function wrapEmWithTenantContext(em, tenantId) {
|
|
|
936
1301
|
0 && (module.exports = {
|
|
937
1302
|
ComplianceLevel,
|
|
938
1303
|
DecryptionError,
|
|
1304
|
+
ENCRYPTED_PREFIXES,
|
|
1305
|
+
ENCRYPTION_FORMAT_ENV,
|
|
1306
|
+
ENCRYPTION_KEY_ENV,
|
|
939
1307
|
EncryptedType,
|
|
940
1308
|
EncryptionRequiredError,
|
|
941
1309
|
FieldEncryptor,
|
|
1310
|
+
LEGACY_ENCRYPTION_KEYS_ENV,
|
|
1311
|
+
LEGACY_ENCRYPTION_KEY_ENV,
|
|
942
1312
|
MissingEncryptionKeyError,
|
|
943
1313
|
RetentionAction,
|
|
944
1314
|
RetentionDuration,
|
|
945
1315
|
RlsEventSubscriber,
|
|
946
1316
|
TENANT_FILTER_NAME,
|
|
947
1317
|
asEncryptionSafe,
|
|
1318
|
+
classifyEncryptedValue,
|
|
1319
|
+
collectFallbackTenantIds,
|
|
1320
|
+
countValuesByKeyId,
|
|
948
1321
|
createTenantFilterDef,
|
|
1322
|
+
defaultTenantIdsFor,
|
|
949
1323
|
defineComplianceEntity,
|
|
1324
|
+
encryptionKeyId,
|
|
950
1325
|
entityHasEncryptedFields,
|
|
1326
|
+
entityMetadataList,
|
|
951
1327
|
fp,
|
|
952
1328
|
getAllRetentionPolicies,
|
|
953
1329
|
getAllUserIdFields,
|
|
@@ -957,11 +1333,18 @@ function wrapEmWithTenantContext(em, tenantId) {
|
|
|
957
1333
|
getEntityRetention,
|
|
958
1334
|
getEntityUserIdField,
|
|
959
1335
|
getSuperAdminContext,
|
|
1336
|
+
isEncryptedCiphertext,
|
|
1337
|
+
isEncryptedValue,
|
|
960
1338
|
parseDuration,
|
|
1339
|
+
parseEncryptionKeyList,
|
|
1340
|
+
reencryptEncryptedColumns,
|
|
961
1341
|
registerEncryptor,
|
|
1342
|
+
rotationTotals,
|
|
962
1343
|
setEncryptionTenantId,
|
|
963
1344
|
setupRls,
|
|
964
1345
|
setupTenantFilter,
|
|
1346
|
+
stampedKeyId,
|
|
1347
|
+
stampedPrefix,
|
|
965
1348
|
subtractDuration,
|
|
966
1349
|
withEncryptionContext,
|
|
967
1350
|
wrapEmWithForgivingDecryption,
|