@futdevpro/nts-dynamo 1.15.117 → 1.15.119
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/.dynamo/logs/cicd-pipeline/output.log +1646 -1607
- package/.dynamo/logs/cicd-pipeline/status.json +40 -34
- package/build/_collections/field-encryption.util.d.ts +52 -0
- package/build/_collections/field-encryption.util.d.ts.map +1 -0
- package/build/_collections/field-encryption.util.js +148 -0
- package/build/_collections/field-encryption.util.js.map +1 -0
- package/build/_models/data-models/schema-migration.data-model.d.ts +29 -0
- package/build/_models/data-models/schema-migration.data-model.d.ts.map +1 -0
- package/build/_models/data-models/schema-migration.data-model.js +47 -0
- package/build/_models/data-models/schema-migration.data-model.js.map +1 -0
- package/build/_models/interfaces/migration-entry.interface.d.ts +20 -0
- package/build/_models/interfaces/migration-entry.interface.d.ts.map +1 -0
- package/build/_models/interfaces/migration-entry.interface.js +3 -0
- package/build/_models/interfaces/migration-entry.interface.js.map +1 -0
- package/build/_services/base/db.service.d.ts.map +1 -1
- package/build/_services/base/db.service.js +15 -2
- package/build/_services/base/db.service.js.map +1 -1
- package/build/_services/core/migration-runner.service.d.ts +40 -0
- package/build/_services/core/migration-runner.service.d.ts.map +1 -0
- package/build/_services/core/migration-runner.service.js +75 -0
- package/build/_services/core/migration-runner.service.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/package.json +2 -2
- package/src/_collections/field-encryption.util.spec.ts +109 -0
- package/src/_collections/field-encryption.util.ts +173 -0
- package/src/_models/data-models/schema-migration.data-model.ts +50 -0
- package/src/_models/interfaces/migration-entry.interface.ts +19 -0
- package/src/_services/base/db.service.encryption.spec.ts +76 -0
- package/src/_services/base/db.service.ts +22 -6
- package/src/_services/core/migration-runner.service.spec.ts +91 -0
- package/src/_services/core/migration-runner.service.ts +106 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DyFM_DataProperties,
|
|
3
|
+
DyFM_Error,
|
|
4
|
+
} from '@futdevpro/fsm-dynamo';
|
|
5
|
+
import { DyFM_Crypto } from '@futdevpro/fsm-dynamo/crypto';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AT-REST field-encryption transzform (Option B, bedrock). A DBService a közös írási úton
|
|
9
|
+
* (`createData` / `modifyData`) a persist ELŐTT titkosítja, a read-post-processor
|
|
10
|
+
* (`stringifyDataId`) a load UTÁN visszafejti az `encrypt:true` mezőket.
|
|
11
|
+
*
|
|
12
|
+
* **App-rétegben fut, NEM mongoose-hook** — a `modifyData` `findByIdAndUpdate`-je megkerüli a
|
|
13
|
+
* mongoose settereket/pre-save-hookokat, ezért a transzform explicit itt történik.
|
|
14
|
+
*
|
|
15
|
+
* **Opt-in, default-off:** ha egy modell egyetlen mezője sem `encrypt:true`, a transzform tiszta
|
|
16
|
+
* NO-OP (ugyanaz az objektum-referencia, nincs klónozás, nincs viselkedés-változás, kulcs sem kell).
|
|
17
|
+
*
|
|
18
|
+
* **Legacy-plaintext biztonságos:** a read-transzform CSAK a `DYENC1:` envelope-értékeket fejti vissza
|
|
19
|
+
* (`isEncryptedDbValue`); a régi, még nem migrált plaintext értékek változatlanul jönnek vissza.
|
|
20
|
+
*
|
|
21
|
+
* Kulcs: `FDP_CORE_DBCONTENT_CRYPT_KEY` (Keystore/CI-env). Verzió: `FDP_CORE_DBCONTENT_CRYPT_KEY_VERSION`
|
|
22
|
+
* (default 1) — a kulcs-rotációhoz (a verzió az envelope-ban utazik).
|
|
23
|
+
*/
|
|
24
|
+
export class DyNTS_FieldEncryption_Util {
|
|
25
|
+
static readonly ENV_KEY: string = 'FDP_CORE_DBCONTENT_CRYPT_KEY';
|
|
26
|
+
static readonly ENV_KEY_VERSION: string = 'FDP_CORE_DBCONTENT_CRYPT_KEY_VERSION';
|
|
27
|
+
|
|
28
|
+
/** `hasEncryptedFields` cache per properties-objektum (referencia szerint). */
|
|
29
|
+
private static readonly _hasEncCache: WeakMap<object, boolean> = new WeakMap();
|
|
30
|
+
|
|
31
|
+
/** A titkosító kulcs env-ből (üres string, ha nincs beállítva). */
|
|
32
|
+
static getKey(): string {
|
|
33
|
+
return process.env[this.ENV_KEY] ?? '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Az aktuális kulcs-verzió (default 1). */
|
|
37
|
+
static getKeyVersion(): number {
|
|
38
|
+
const parsed: number = Number(process.env[this.ENV_KEY_VERSION]);
|
|
39
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True, ha a properties-fa (rekurzívan a `subObjectParams`-on át) tartalmaz `encrypt:true` mezőt.
|
|
44
|
+
* Cache-elt (a properties-objektum immutábilis a modell-élettartam alatt).
|
|
45
|
+
*/
|
|
46
|
+
static hasEncryptedFields(properties?: DyFM_DataProperties<any>): boolean {
|
|
47
|
+
if (!properties) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cached: boolean | undefined = this._hasEncCache.get(properties);
|
|
52
|
+
if (cached !== undefined) {
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let has: boolean = false;
|
|
57
|
+
for (const key in properties) {
|
|
58
|
+
const prop: any = properties[key]; // ccap-review-disable-line no-any-type
|
|
59
|
+
if (!prop) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (prop.encrypt === true) {
|
|
63
|
+
has = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (prop.subObjectParams && this.hasEncryptedFields(prop.subObjectParams)) {
|
|
67
|
+
has = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this._hasEncCache.set(properties, has);
|
|
73
|
+
return has;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Titkosítja a doc `encrypt:true` mezőit persist előtt. **Klónt ad vissza** — az eredeti (hívó)
|
|
78
|
+
* objektumot NEM mutálja (különben a `this.data` ciphertextté válna). NO-OP, ha nincs enc-mező.
|
|
79
|
+
* @throws ha van enc-mező, de a kulcs env-var nincs beállítva (fail-loud misconfiguration).
|
|
80
|
+
*/
|
|
81
|
+
static encryptDoc<T>(data: T, properties?: DyFM_DataProperties<any>): T {
|
|
82
|
+
if (data == null || !this.hasEncryptedFields(properties)) {
|
|
83
|
+
return data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const key: string = this.getKey();
|
|
87
|
+
if (!key) {
|
|
88
|
+
throw new DyFM_Error({
|
|
89
|
+
status: 500,
|
|
90
|
+
errorCode: 'DyNTS-ENC-KEY-MISSING',
|
|
91
|
+
message:
|
|
92
|
+
`At-rest encryption: "${this.ENV_KEY}" env-var is not set, but a model field is encrypt:true. ` +
|
|
93
|
+
`Set the key in the Keystore/CI-env and redeploy.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return this._transform(data, properties as DyFM_DataProperties<any>, 'encrypt', key, this.getKeyVersion());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Visszafejti a doc `encrypt:true` mezőit load után. CSAK a `DYENC1:` envelope-értékeket bántja
|
|
102
|
+
* (legacy plaintext változatlan). NO-OP, ha nincs enc-mező. Kulcs hiányában read-en NEM dob
|
|
103
|
+
* (a read ne törjön el) — az envelope-értékek visszafejtetlenül maradnak (a hívó látja + a hiba loggolt).
|
|
104
|
+
*/
|
|
105
|
+
static decryptDoc<T>(data: T, properties?: DyFM_DataProperties<any>): T {
|
|
106
|
+
if (data == null || !this.hasEncryptedFields(properties)) {
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const key: string = this.getKey();
|
|
111
|
+
if (!key) {
|
|
112
|
+
return data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return this._transform(data, properties as DyFM_DataProperties<any>, 'decrypt', key, this.getKeyVersion());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rekurzív per-field walk: az `encrypt:true` mezőket titkosítja/visszafejti, a `subObjectParams`-os
|
|
120
|
+
* mezőkbe (objektum VAGY objektum-tömb) leszáll. Immutábilis: minden érintett konténert sekélyen
|
|
121
|
+
* klónoz (az eredetit nem mutálja); a nem-érintett ágakat referencia szerint megosztja (read-only).
|
|
122
|
+
*/
|
|
123
|
+
private static _transform<T>(
|
|
124
|
+
value: T,
|
|
125
|
+
properties: DyFM_DataProperties<any>,
|
|
126
|
+
mode: 'encrypt' | 'decrypt',
|
|
127
|
+
key: string,
|
|
128
|
+
keyVersion: number,
|
|
129
|
+
): T {
|
|
130
|
+
if (value == null || typeof value !== 'object') {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result: any = Array.isArray(value) ? [ ...(value as any[]) ] : { ...(value as any) }; // ccap-review-disable-line no-any-type
|
|
135
|
+
|
|
136
|
+
for (const propKey in properties) {
|
|
137
|
+
const prop: any = properties[propKey]; // ccap-review-disable-line no-any-type
|
|
138
|
+
if (!prop) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (prop.encrypt === true) {
|
|
143
|
+
const current: any = result[propKey]; // ccap-review-disable-line no-any-type
|
|
144
|
+
|
|
145
|
+
if (mode === 'encrypt') {
|
|
146
|
+
// Üres/undefined/null-t NEM titkosítunk (a DyFM_Crypto ezekre dob); már-envelope-ot sem (idempotens).
|
|
147
|
+
if (current != null && current !== '' && !DyFM_Crypto.isEncryptedDbValue(current)) {
|
|
148
|
+
result[propKey] = DyFM_Crypto.encryptDbValue(current, key, keyVersion);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
// CSAK envelope-ot fejtünk vissza (legacy plaintext változatlan).
|
|
152
|
+
if (DyFM_Crypto.isEncryptedDbValue(current)) {
|
|
153
|
+
result[propKey] = DyFM_Crypto.decryptDbValue(current, key);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (prop.subObjectParams) {
|
|
157
|
+
const current: any = result[propKey]; // ccap-review-disable-line no-any-type
|
|
158
|
+
|
|
159
|
+
if (current != null && typeof current === 'object') {
|
|
160
|
+
if (Array.isArray(current)) {
|
|
161
|
+
result[propKey] = current.map(
|
|
162
|
+
(item: any): any => this._transform(item, prop.subObjectParams, mode, key, keyVersion), // ccap-review-disable-line no-any-type
|
|
163
|
+
);
|
|
164
|
+
} else {
|
|
165
|
+
result[propKey] = this._transform(current, prop.subObjectParams, mode, key, keyVersion);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result as T;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { DyFM_DataModel_Params, DyFM_Metadata } from '@futdevpro/fsm-dynamo';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* **Migráció-nyilvántartó ("server info") LEDGER** — bedrock, újrahasználható az egész flottának.
|
|
5
|
+
* (A token-service app-lokál `SchemaMigration` mintájából emelve bedrock nts-be.)
|
|
6
|
+
*
|
|
7
|
+
* Egy-egy `migrationId` **PONTOSAN EGYSZER** fut le rendszerenként: a `DyNTS_Migration_Runner.runPending()`
|
|
8
|
+
* boot-on (az app `app.server.postProcess()`-éből) lekéri a már-alkalmazott ID-kat, a pending
|
|
9
|
+
* migrációkat lefuttatja, majd egy sort rögzít ide. A `migrationId` **unique index** → race-safe
|
|
10
|
+
* (két párhuzamos instance esetén az egyik `save`-je duplicate-key-re bukik → skip).
|
|
11
|
+
*
|
|
12
|
+
* A ledger a KÖTELEZŐ kanon-adatokat tartja: **mely migrációk futottak** (id + idő) + **mely app-verzió**
|
|
13
|
+
* alkalmazta (a `appliedVersion`-ökből a "mely verziók futottak" levezethető).
|
|
14
|
+
*
|
|
15
|
+
* Az app a saját DB-jében regisztrálja a `dyNTS_schemaMigration_dataParams`-t (`getGlobalServiceCollection
|
|
16
|
+
* ().dbModels`), így per-app saját `dynts_schema_migrations` collection-t kap.
|
|
17
|
+
*/
|
|
18
|
+
export class DyNTS_SchemaMigration extends DyFM_Metadata {
|
|
19
|
+
|
|
20
|
+
/** A migráció egyedi azonosítója (konvenció: `YYYY-MM-DD-NNN-descriptor`). */
|
|
21
|
+
migrationId?: string;
|
|
22
|
+
|
|
23
|
+
/** Alkalmazás időbélyege. */
|
|
24
|
+
appliedAt?: Date;
|
|
25
|
+
|
|
26
|
+
/** Az alkalmazó app-verzió (a `package.json` version-je — audit-trail + "mely verziók futottak"). */
|
|
27
|
+
appliedVersion?: string;
|
|
28
|
+
|
|
29
|
+
/** Diag — pl. `touched=<n>` vagy hiba-részletek. */
|
|
30
|
+
notes?: string;
|
|
31
|
+
|
|
32
|
+
constructor(set?: DyNTS_SchemaMigration) {
|
|
33
|
+
super(set);
|
|
34
|
+
if (set) {
|
|
35
|
+
Object.assign(this, set);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const dyNTS_schemaMigration_dataParams: DyFM_DataModel_Params<DyNTS_SchemaMigration> =
|
|
41
|
+
new DyFM_DataModel_Params<DyNTS_SchemaMigration>({
|
|
42
|
+
dataName: 'dynts_schema_migrations',
|
|
43
|
+
addArchive: false,
|
|
44
|
+
properties: {
|
|
45
|
+
migrationId: { type: 'string', required: true, unique: true, index: true },
|
|
46
|
+
appliedAt: { type: 'date', required: true },
|
|
47
|
+
appliedVersion: { type: 'string' },
|
|
48
|
+
notes: { type: 'string' },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Egy regisztrált deploy-integrált migráció leírója. Az app egy `DyNTS_MigrationEntry[]` registry-t
|
|
3
|
+
* ad át a `DyNTS_Migration_Runner.runPending(...)`-nek; a runner a pending (még nem alkalmazott)
|
|
4
|
+
* entry-ket **egyszer** futtatja le, sorrendben, majd a `DyNTS_SchemaMigration` ledgerbe rögzíti.
|
|
5
|
+
*/
|
|
6
|
+
export interface DyNTS_MigrationEntry {
|
|
7
|
+
/** Egyedi, monoton bővíthető ID (konvenció: `YYYY-MM-DD-NNN-descriptor`). A ledger ez alapján run-once-ol. */
|
|
8
|
+
id: string;
|
|
9
|
+
|
|
10
|
+
/** Rövid leíró (admin/debug/log). */
|
|
11
|
+
description: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A tényleges migráció-logika. Az app már-csatlakozott Mongo-kapcsolatát használja (a runner a
|
|
15
|
+
* `postProcess`-ből fut, ahol a DB már él). Hibára DOBHAT — a runner catch-eli, a `notes`-ba menti,
|
|
16
|
+
* és NEM állítja meg a boot-ot. Visszaad: `{ touched }` (a módosított dokumentumok száma — count/riport).
|
|
17
|
+
*/
|
|
18
|
+
run: (issuer: string) => Promise<{ touched: number }>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DyFM_DataModel_Params,
|
|
3
|
+
DyFM_Metadata,
|
|
4
|
+
DyFM_BasicProperty_Type,
|
|
5
|
+
} from '@futdevpro/fsm-dynamo';
|
|
6
|
+
import { DyFM_Crypto } from '@futdevpro/fsm-dynamo/crypto';
|
|
7
|
+
import * as mongoose from 'mongoose';
|
|
8
|
+
|
|
9
|
+
import { DyNTS_DBService } from './db.service';
|
|
10
|
+
import { DyNTS_FieldEncryption_Util } from '../../_collections/field-encryption.util';
|
|
11
|
+
|
|
12
|
+
class EncMeta extends DyFM_Metadata {
|
|
13
|
+
name: string = '';
|
|
14
|
+
secret: string = '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const encParams: DyFM_DataModel_Params<EncMeta> = new DyFM_DataModel_Params<EncMeta>({
|
|
18
|
+
dataName: 'test_enc_data',
|
|
19
|
+
properties: {
|
|
20
|
+
name: { key: 'name', type: DyFM_BasicProperty_Type.string },
|
|
21
|
+
secret: { key: 'secret', type: DyFM_BasicProperty_Type.string, encrypt: true },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* AT-REST encryption a DB-layer create/update útján. A `createData`/`modifyData` mongoose-hívása
|
|
27
|
+
* DB-kapcsolat nélkül nem futtatható végig (l. db.service.spec: mongoose.model getter-only), de:
|
|
28
|
+
* - a `findByIdAndUpdate` INSTANCE-property-ként spy-olható → az UPDATE-út (a findByIdAndUpdate
|
|
29
|
+
* hook-bypass caveat) igazolható;
|
|
30
|
+
* - a CREATE-út az AZONOS `encryptDoc`-ot hívja `new this.dataModel()` ELŐTT → spy-val igazolható,
|
|
31
|
+
* hogy a persist előtt lefut (a save DB nélkül elhasal, de már az encrypt UTÁN).
|
|
32
|
+
*/
|
|
33
|
+
describe('| DyNTS_DBService at-rest field-encryption (create + update path)', () => {
|
|
34
|
+
let db: DyNTS_DBService<EncMeta>;
|
|
35
|
+
const KEY: string = 'db-content-crypt-key-abcdefgh';
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
process.env.FDP_CORE_DBCONTENT_CRYPT_KEY = KEY;
|
|
39
|
+
db = new DyNTS_DBService<EncMeta>(encParams);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
delete process.env.FDP_CORE_DBCONTENT_CRYPT_KEY;
|
|
44
|
+
delete (mongoose.models as Record<string, unknown>)['test_enc_data'];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('| UPDATE: modifyData → a findByIdAndUpdate CIPHERTEXT-et kap (hook-bypass ellenére), a return plaintext', async () => {
|
|
48
|
+
const spy: jasmine.Spy = spyOn(db['dataModel'], 'findByIdAndUpdate').and.returnValue(Promise.resolve({}) as never);
|
|
49
|
+
|
|
50
|
+
const result: EncMeta = await db.modifyData({ _id: 'id1', name: 'n', secret: 'plain-secret' } as EncMeta, 'issuer');
|
|
51
|
+
|
|
52
|
+
const persisted: any = spy.calls.mostRecent().args[1]; // ccap-review-disable-line no-any-type
|
|
53
|
+
expect(DyFM_Crypto.isEncryptedDbValue(persisted.secret)).toBe(true); // DB-be ciphertext ment
|
|
54
|
+
expect(persisted.name).toBe('n'); // sima mező érintetlen
|
|
55
|
+
expect(result.secret).toBe('plain-secret'); // a hívó plaintextet kap vissza
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('| CREATE: createData a persist (new dataModel/save) ELŐTT titkosít (encryptDoc a create-adattal)', async () => {
|
|
59
|
+
const encSpy: jasmine.Spy = spyOn(DyNTS_FieldEncryption_Util, 'encryptDoc').and.callThrough();
|
|
60
|
+
|
|
61
|
+
// A .save() DB-kapcsolat nélkül buffer-elne (~10s). Kikapcsoljuk a bufferelést → GYORS fail.
|
|
62
|
+
const prevBuffer: boolean | undefined = mongoose.get('bufferCommands') as boolean | undefined;
|
|
63
|
+
mongoose.set('bufferCommands', false);
|
|
64
|
+
try {
|
|
65
|
+
await db.createData({ name: 'n', secret: 'plain-secret' } as EncMeta, 'issuer');
|
|
66
|
+
} catch {
|
|
67
|
+
// A .save() DB nélkül elhasal — a lényeg, hogy az encrypt-transzform ELŐTTE lefutott.
|
|
68
|
+
} finally {
|
|
69
|
+
mongoose.set('bufferCommands', prevBuffer ?? true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(encSpy).toHaveBeenCalled();
|
|
73
|
+
const passed: any = encSpy.calls.mostRecent().args[0]; // ccap-review-disable-line no-any-type
|
|
74
|
+
expect(passed.secret).toBe('plain-secret');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
DyFM_Object
|
|
17
17
|
} from '@futdevpro/fsm-dynamo';
|
|
18
18
|
import { DyNTS_archiveSuffix } from '../../_collections/archive.util';
|
|
19
|
+
import { DyNTS_FieldEncryption_Util } from '../../_collections/field-encryption.util';
|
|
19
20
|
import { DyNTS_DBUpdate } from '../../_models/types/db-update.type';
|
|
20
21
|
import { DyNTS_DBQueryOptions } from '../../_models/interfaces/db-query-options.interface';
|
|
21
22
|
import { DyNTS_global_settings } from '../../_collections/global-settings.const';
|
|
@@ -100,7 +101,11 @@ export class DyNTS_DBService<T extends DyFM_Metadata> {
|
|
|
100
101
|
data.__createdBy = issuer;
|
|
101
102
|
data.__lastModifiedBy = issuer;
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
// AT-REST field-encryption (opt-in): az encrypt:true mezőket a persist ELŐTT titkosítjuk, egy
|
|
105
|
+
// KLÓNON (a hívó `data`-ja plaintext marad). NO-OP, ha nincs encrypt:true mező.
|
|
106
|
+
const encData: T = DyNTS_FieldEncryption_Util.encryptDoc(data, this.dataParams.properties);
|
|
107
|
+
|
|
108
|
+
const dataModel = new this.dataModel(encData);
|
|
104
109
|
const newData: T = await dataModel.save().then((res): T => {
|
|
105
110
|
if (res) {
|
|
106
111
|
return res?.toObject() as T;
|
|
@@ -157,12 +162,18 @@ export class DyNTS_DBService<T extends DyFM_Metadata> {
|
|
|
157
162
|
data.__lastModifiedBy = issuer;
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
// AT-REST field-encryption (opt-in): az encrypt:true mezőket a persist ELŐTT titkosítjuk, egy
|
|
166
|
+
// KLÓNON. KRITIKUS: a `findByIdAndUpdate` MEGKERÜLI a mongoose settereket/pre-save-hookokat, ezért
|
|
167
|
+
// a titkosítás itt, app-rétegben történik (nem mongoose-hookban). A hívó `data`-ja plaintext marad,
|
|
168
|
+
// így a `stringifyDataId(data)` alant no-op decrypttel a plaintextet adja vissza. NO-OP enc-mező nélkül.
|
|
169
|
+
const encData: T = DyNTS_FieldEncryption_Util.encryptDoc(data, this.dataParams.properties);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* EZ A SZAR TELJESEN SZAR, nem friss, nem a db-be mentett adatokat ad vissza,
|
|
173
|
+
* átír random value-kat össze vissza, WTF
|
|
163
174
|
* */
|
|
164
|
-
/* let newData: T = */
|
|
165
|
-
await this.dataModel.findByIdAndUpdate(data._id,
|
|
175
|
+
/* let newData: T = */
|
|
176
|
+
await this.dataModel.findByIdAndUpdate(data._id, encData)/* .then((res) => {
|
|
166
177
|
if (res) {
|
|
167
178
|
//return res?.toObject() as T;
|
|
168
179
|
} else {
|
|
@@ -1277,6 +1288,11 @@ export class DyNTS_DBService<T extends DyFM_Metadata> {
|
|
|
1277
1288
|
// PRIVATE FUNCTIONS
|
|
1278
1289
|
|
|
1279
1290
|
private stringifyDataId(data: T, fnName: string): T {
|
|
1291
|
+
// AT-REST field-decryption (opt-in): ez a KÖZÖS read+write post-processor (minden read + a
|
|
1292
|
+
// create/modify EREDMÉNYE is átmegy rajta), ezért itt fejtjük vissza az encrypt:true envelope-
|
|
1293
|
+
// mezőket load után. NO-OP, ha nincs encrypt:true mező (ugyanaz a referencia — nulla változás).
|
|
1294
|
+
data = DyNTS_FieldEncryption_Util.decryptDoc(data, this.dataParams.properties);
|
|
1295
|
+
|
|
1280
1296
|
if (data?._id && (typeof data._id !== 'string' || typeof data._id === 'object')) {
|
|
1281
1297
|
data._id = `${data._id}`;
|
|
1282
1298
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DyNTS_Migration_Runner } from './migration-runner.service';
|
|
2
|
+
import { DyNTS_SchemaMigration } from '../../_models/data-models/schema-migration.data-model';
|
|
3
|
+
import { DyNTS_MigrationEntry } from '../../_models/interfaces/migration-entry.interface';
|
|
4
|
+
|
|
5
|
+
function entry(id: string, run: () => Promise<{ touched: number }>): DyNTS_MigrationEntry {
|
|
6
|
+
return { id: id, description: id, run: run };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('| DyNTS_Migration_Runner', () => {
|
|
10
|
+
|
|
11
|
+
describe('| selectPending (pure)', () => {
|
|
12
|
+
it('| kiszűri a már-alkalmazottakat, megőrzi a sorrendet', () => {
|
|
13
|
+
const migs: DyNTS_MigrationEntry[] = [
|
|
14
|
+
entry('a', async () => ({ touched: 0 })),
|
|
15
|
+
entry('b', async () => ({ touched: 0 })),
|
|
16
|
+
entry('c', async () => ({ touched: 0 })),
|
|
17
|
+
];
|
|
18
|
+
const pending: DyNTS_MigrationEntry[] = DyNTS_Migration_Runner.selectPending(migs, new Set([ 'b' ]));
|
|
19
|
+
expect(pending.map((m) => m.id)).toEqual([ 'a', 'c' ]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('| mind alkalmazva → üres; üres registry → üres', () => {
|
|
23
|
+
const migs: DyNTS_MigrationEntry[] = [ entry('a', async () => ({ touched: 0 })) ];
|
|
24
|
+
expect(DyNTS_Migration_Runner.selectPending(migs, new Set([ 'a' ])).length).toBe(0);
|
|
25
|
+
expect(DyNTS_Migration_Runner.selectPending([], new Set()).length).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('| runPending (mock ledger)', () => {
|
|
30
|
+
let runner: DyNTS_Migration_Runner;
|
|
31
|
+
let saved: DyNTS_SchemaMigration[];
|
|
32
|
+
let appliedRecords: DyNTS_SchemaMigration[];
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
runner = DyNTS_Migration_Runner.getInstance();
|
|
36
|
+
saved = [];
|
|
37
|
+
appliedRecords = [];
|
|
38
|
+
|
|
39
|
+
const fakeLedger: any = { // ccap-review-disable-line no-any-type
|
|
40
|
+
dataDBService: { find: async (): Promise<DyNTS_SchemaMigration[]> => appliedRecords },
|
|
41
|
+
data: null,
|
|
42
|
+
saveData: async function (): Promise<void> { saved.push(this.data); appliedRecords.push(this.data); },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
spyOn(runner as any, 'createLedgerDataService').and.returnValue(fakeLedger); // ccap-review-disable-line no-any-type
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('| a pending-et futtatja + rögzíti (id+version+touched), a már-alkalmazottat SKIP-eli', async () => {
|
|
49
|
+
appliedRecords.push(new DyNTS_SchemaMigration({ migrationId: 'm1' }));
|
|
50
|
+
const ran: string[] = [];
|
|
51
|
+
|
|
52
|
+
const migs: DyNTS_MigrationEntry[] = [
|
|
53
|
+
entry('m1', async () => { ran.push('m1'); return { touched: 1 }; }),
|
|
54
|
+
entry('m2', async () => { ran.push('m2'); return { touched: 5 }; }),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const res = await runner.runPending(migs, '1.0.0');
|
|
58
|
+
|
|
59
|
+
expect(ran).toEqual([ 'm2' ]); // m1 skip (már applied), m2 fut
|
|
60
|
+
expect(res.applied).toBe(1);
|
|
61
|
+
expect(res.skipped).toBe(1);
|
|
62
|
+
expect(saved[0].migrationId).toBe('m2');
|
|
63
|
+
expect(saved[0].appliedVersion).toBe('1.0.0');
|
|
64
|
+
expect(saved[0].notes).toBe('touched=5');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('| RUN-ONCE: második sweep-en (m2 már a ledgerben) semmi nem fut', async () => {
|
|
68
|
+
appliedRecords.push(new DyNTS_SchemaMigration({ migrationId: 'm2' }));
|
|
69
|
+
const ran: string[] = [];
|
|
70
|
+
const migs: DyNTS_MigrationEntry[] = [ entry('m2', async () => { ran.push('m2'); return { touched: 0 }; }) ];
|
|
71
|
+
|
|
72
|
+
const res = await runner.runPending(migs, '1.0.0');
|
|
73
|
+
expect(ran.length).toBe(0);
|
|
74
|
+
expect(res.applied).toBe(0);
|
|
75
|
+
expect(res.skipped).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('| BOOT-SAFE: egy hibás migráció NEM dob + NEM állítja meg a többit', async () => {
|
|
79
|
+
const ran: string[] = [];
|
|
80
|
+
const migs: DyNTS_MigrationEntry[] = [
|
|
81
|
+
entry('bad', async () => { throw new Error('boom'); }),
|
|
82
|
+
entry('good', async () => { ran.push('good'); return { touched: 2 }; }),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const res = await runner.runPending(migs, '1.0.0');
|
|
86
|
+
expect(res.failed).toBe(1);
|
|
87
|
+
expect(res.applied).toBe(1); // 'good' a hiba UTÁN is lefutott
|
|
88
|
+
expect(ran).toEqual([ 'good' ]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { DyFM_Error, DyFM_Log } from '@futdevpro/fsm-dynamo';
|
|
2
|
+
|
|
3
|
+
import { DyNTS_SingletonService } from '../base/singleton.service';
|
|
4
|
+
import { DyNTS_DataService } from '../base/data.service';
|
|
5
|
+
import {
|
|
6
|
+
DyNTS_SchemaMigration, dyNTS_schemaMigration_dataParams,
|
|
7
|
+
} from '../../_models/data-models/schema-migration.data-model';
|
|
8
|
+
import { DyNTS_MigrationEntry } from '../../_models/interfaces/migration-entry.interface';
|
|
9
|
+
|
|
10
|
+
/** A `runPending` összesítő eredménye. */
|
|
11
|
+
export interface DyNTS_MigrationRunResult {
|
|
12
|
+
applied: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
failed: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* **Deploy-integrált migráció-futtató** — bedrock, újrahasználható. Az app a `app.server.postProcess()`-
|
|
19
|
+
* ből hívja: `DyNTS_Migration_Runner.getInstance().runPending(ALL_MIGRATIONS, appVersion)`.
|
|
20
|
+
*
|
|
21
|
+
* Mechanizmus (kanon `deploy-integrated-migrations.md`):
|
|
22
|
+
* - Boot-on lekéri a `DyNTS_SchemaMigration` ledgerből a MÁR-alkalmazott `migrationId`-kat.
|
|
23
|
+
* - A **pending** (még nem alkalmazott) entry-ket **sorrendben, egyszer** lefuttatja.
|
|
24
|
+
* - Mindegyik után egy ledger-sort rögzít (`migrationId` + `appliedAt` + `appliedVersion` + `notes`).
|
|
25
|
+
* - **RUN-ONCE:** a `migrationId` unique-index → re-boot / párhuzamos instance esetén skip (duplicate-key).
|
|
26
|
+
* - **Boot-safe:** egy hibás migráció NEM állítja meg a boot-ot (catch + notes; a runner tovább megy).
|
|
27
|
+
*/
|
|
28
|
+
export class DyNTS_Migration_Runner extends DyNTS_SingletonService {
|
|
29
|
+
|
|
30
|
+
static getInstance(): DyNTS_Migration_Runner {
|
|
31
|
+
return DyNTS_Migration_Runner.getSingletonInstance();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* TISZTA pending-szelekció (DB nélkül tesztelhető): azok az entry-k, amiknek az `id`-ja NINCS az
|
|
36
|
+
* `appliedIds`-ban. A regisztráció sorrendje megőrződik (a bővítés a tömb VÉGÉRE).
|
|
37
|
+
*/
|
|
38
|
+
static selectPending(
|
|
39
|
+
migrations: DyNTS_MigrationEntry[],
|
|
40
|
+
appliedIds: Set<string>,
|
|
41
|
+
): DyNTS_MigrationEntry[] {
|
|
42
|
+
return (migrations ?? []).filter((migration: DyNTS_MigrationEntry): boolean => !appliedIds.has(migration.id));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A ledger-DataService előállítása. `protected` → a spec felül tudja írni (mock-hoz). */
|
|
46
|
+
protected createLedgerDataService(issuer: string): DyNTS_DataService<DyNTS_SchemaMigration> {
|
|
47
|
+
return new DyNTS_DataService<DyNTS_SchemaMigration>(
|
|
48
|
+
new DyNTS_SchemaMigration(),
|
|
49
|
+
dyNTS_schemaMigration_dataParams,
|
|
50
|
+
issuer,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A pending migrációk lefuttatása + rögzítése.
|
|
56
|
+
*
|
|
57
|
+
* @param migrations - az app registry-je (`DyNTS_MigrationEntry[]`)
|
|
58
|
+
* @param appVersion - az app aktuális verziója (a ledger `appliedVersion`-jébe)
|
|
59
|
+
* @param issuer - opcionális issuer-cimke (default `DyNTS_Migration_Runner`)
|
|
60
|
+
*/
|
|
61
|
+
async runPending(
|
|
62
|
+
migrations: DyNTS_MigrationEntry[],
|
|
63
|
+
appVersion: string,
|
|
64
|
+
issuer: string = 'DyNTS_Migration_Runner',
|
|
65
|
+
): Promise<DyNTS_MigrationRunResult> {
|
|
66
|
+
const result: DyNTS_MigrationRunResult = { applied: 0, skipped: 0, failed: 0 };
|
|
67
|
+
|
|
68
|
+
DyFM_Log.info(`[dynts-migration] sweep start — ${migrations?.length ?? 0} migration(s) registered · appVersion=${appVersion}`);
|
|
69
|
+
|
|
70
|
+
const ledger_DS: DyNTS_DataService<DyNTS_SchemaMigration> = this.createLedgerDataService(issuer);
|
|
71
|
+
|
|
72
|
+
const applied: DyNTS_SchemaMigration[] = await ledger_DS.dataDBService.find({} as never);
|
|
73
|
+
const appliedIds: Set<string> = new Set(
|
|
74
|
+
applied.map((record: DyNTS_SchemaMigration): string => record.migrationId ?? ''),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const pending: DyNTS_MigrationEntry[] = DyNTS_Migration_Runner.selectPending(migrations, appliedIds);
|
|
78
|
+
result.skipped = (migrations?.length ?? 0) - pending.length;
|
|
79
|
+
|
|
80
|
+
for (const migration of pending) {
|
|
81
|
+
try {
|
|
82
|
+
DyFM_Log.info(`[dynts-migration] applying ${migration.id} — ${migration.description}`);
|
|
83
|
+
const outcome: { touched: number } = await migration.run(issuer);
|
|
84
|
+
|
|
85
|
+
// Atomikus claim — a unique-index dob duplicate-ra (race-safe, két párhuzamos instance).
|
|
86
|
+
ledger_DS.data = new DyNTS_SchemaMigration({
|
|
87
|
+
migrationId: migration.id,
|
|
88
|
+
appliedAt: new Date(),
|
|
89
|
+
appliedVersion: appVersion,
|
|
90
|
+
notes: `touched=${outcome?.touched ?? 0}`,
|
|
91
|
+
});
|
|
92
|
+
await ledger_DS.saveData();
|
|
93
|
+
|
|
94
|
+
DyFM_Log.success(`[dynts-migration] applied ${migration.id} (touched=${outcome?.touched ?? 0})`);
|
|
95
|
+
result.applied++;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Egy hibás migráció NEM állíthatja meg a boot-ot (catch + log; a következő entry mehet).
|
|
98
|
+
DyFM_Error.logSimple(`[dynts-migration] FAILED ${migration.id}`, error);
|
|
99
|
+
result.failed++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
DyFM_Log.info(`[dynts-migration] sweep done — applied=${result.applied} skipped=${result.skipped} failed=${result.failed}`);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export * from './_models/interfaces/static-client-settings.interface';
|
|
|
53
53
|
export * from './_models/interfaces/cors-settings.interface';
|
|
54
54
|
export * from './_models/interfaces/security-headers-settings.interface';
|
|
55
55
|
export * from './_models/interfaces/db-query-options.interface';
|
|
56
|
+
export * from './_models/interfaces/migration-entry.interface';
|
|
56
57
|
export * from './_collections/security-headers.util';
|
|
57
58
|
export * from './_collections/client-safe-error.util';
|
|
58
59
|
export * from './_collections/load-shed.util';
|
|
@@ -70,6 +71,9 @@ export * from './_models/control-models/system-control.control-model';
|
|
|
70
71
|
// models/TYPES
|
|
71
72
|
export * from './_models/types/db-update.type';
|
|
72
73
|
|
|
74
|
+
// models/DATA-MODELS
|
|
75
|
+
export * from './_models/data-models/schema-migration.data-model';
|
|
76
|
+
|
|
73
77
|
|
|
74
78
|
// SERVICES
|
|
75
79
|
// services/CORE
|
|
@@ -82,6 +86,7 @@ export * from './_services/core/global.service';
|
|
|
82
86
|
export * from './_services/core/memory-guard.service';
|
|
83
87
|
|
|
84
88
|
export * from './_services/core/service-collection.service';
|
|
89
|
+
export * from './_services/core/migration-runner.service';
|
|
85
90
|
|
|
86
91
|
// services/BASE
|
|
87
92
|
export * from './_services/base/api.service-base';
|