Skip to content

Commit 794744e

Browse files
author
Volodymyr Malyhin
committed
Merge branch 'master' into feat/add_logging_info
2 parents 7ca915d + c0d5778 commit 794744e

File tree

5 files changed

+487
-8
lines changed

5 files changed

+487
-8
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Knex } from 'knex';
2+
3+
import { EntityTypes } from '../versioning/interfaces';
4+
import { sanitizeSnapshot, Snapshot } from '../versioning/utils/secretSanitizer';
5+
6+
const ENTITY_TYPES = [EntityTypes.settings, EntityTypes.settings_domain_value, EntityTypes.auth_entities];
7+
8+
function sanitizeIfChanged(json: string | null, entityType: string, secretKeys: Set<string>): string | null {
9+
if (!json) {
10+
return null;
11+
}
12+
const sanitized = JSON.stringify(sanitizeSnapshot(entityType, JSON.parse(json) as Snapshot, secretKeys));
13+
return sanitized !== json ? sanitized : null;
14+
}
15+
16+
export async function up(knex: Knex): Promise<void> {
17+
const secretSettings: { key: string }[] = await knex('settings').select('key').where('secret', true);
18+
const secretKeys = new Set(secretSettings.map((s) => s.key));
19+
20+
let count = 0;
21+
const rows = await knex('versioning')
22+
.select('id', 'entity_type', 'data', 'data_after')
23+
.whereIn('entity_type', ENTITY_TYPES);
24+
25+
for (const row of rows) {
26+
const data = sanitizeIfChanged(row.data, row.entity_type, secretKeys);
27+
const dataAfter = sanitizeIfChanged(row.data_after, row.entity_type, secretKeys);
28+
29+
if (data || dataAfter) {
30+
const update: Record<string, string> = {};
31+
if (data) {
32+
update.data = data;
33+
}
34+
if (dataAfter) {
35+
update.data_after = dataAfter;
36+
}
37+
await knex('versioning').where('id', row.id).update(update);
38+
count++;
39+
}
40+
}
41+
42+
console.log(`Sanitized ${count} versioning records`);
43+
}
44+
45+
export async function down(): Promise<void> {
46+
throw new Error('Irreversible migration: secret values cannot be recovered');
47+
}

registry/server/versioning/services/Versioning.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import versioningConfig from '../config';
55
import { extractInsertedId, formatDate } from '../../util/db';
66
import * as errors from '../errors';
77
import { EntityTypes, OperationConfig, VersionRow, VersionRowData, VersionRowParsed } from '../interfaces';
8+
import { sanitizeSnapshot, stripSecretFields } from '../utils/secretSanitizer';
89
import Transaction = Knex.Transaction;
910

1011
export * from '../interfaces';
@@ -66,14 +67,21 @@ export class Versioning {
6667
});
6768
}
6869

69-
const data = currentData === null ? null : JSON.stringify(currentData);
70-
const data_after = newData === null ? null : JSON.stringify(newData);
70+
const rawData = currentData === null ? null : JSON.stringify(currentData);
71+
const rawDataAfter = newData === null ? null : JSON.stringify(newData);
7172

72-
if (data === data_after) {
73+
if (rawData === rawDataAfter) {
7374
await commit();
7475
return null;
7576
}
7677

78+
const secretKeys = await this.getSecretSettingKeys(trx);
79+
const sanitize = (snapshot: typeof currentData) =>
80+
snapshot ? JSON.stringify(sanitizeSnapshot(updatedConfig.type, snapshot, secretKeys)) : null;
81+
82+
const data = sanitize(currentData);
83+
const data_after = sanitize(newData);
84+
7785
const logRecord: VersionRowData = {
7886
entity_type: updatedConfig.type,
7987
entity_id: updatedConfig.id.toString(),
@@ -127,46 +135,55 @@ export class Versioning {
127135
.delete()
128136
.transacting(trx);
129137
} else if (versionRow.data_after === null) {
130-
// Deletion operation, so we need to create everything the was deleted
138+
// Deletion operation, so we need to create everything that was deleted
139+
// Strip secret marker fields to avoid inserting placeholders - secrets remain lost
131140
const dataToRestore = versionRow.data;
141+
const strippedData = stripSecretFields(dataToRestore.data);
132142
await this.db(versionRow.entity_type)
133143
.insert({
134-
...dataToRestore.data,
144+
...strippedData,
135145
[entityConfig.idColumn]: versionRow.entity_id,
136146
})
137147
.transacting(trx);
138148

139149
for (const relation of entityConfig.related) {
140150
const relatedItems = dataToRestore.related[relation.type].map((v: any) => ({
141-
...v,
151+
...stripSecretFields(v),
142152
[relation.key]: versionRow.entity_id,
143153
}));
144154
await this.db.batchInsert(relation.type, relatedItems).transacting(trx);
145155
}
146156
} else {
147157
// We have an update operation
158+
// Strip secret marker fields to preserve current DB values for secrets
148159
const dataToRestore = versionRow.data;
160+
const strippedData = stripSecretFields(dataToRestore.data);
149161
for (const relation of entityConfig.related) {
150162
await this.db(relation.type)
151163
.where(relation.key, versionRow.entity_id)
152164
.delete()
153165
.transacting(trx);
154166

155167
const relatedItems = dataToRestore.related[relation.type].map((v: any) => ({
156-
...v,
168+
...stripSecretFields(v),
157169
[relation.key]: versionRow.entity_id,
158170
}));
159171
await this.db.batchInsert(relation.type, relatedItems).transacting(trx);
160172
}
161173
await this.db(versionRow.entity_type)
162174
.where(entityConfig.idColumn, versionRow.entity_id)
163-
.update(dataToRestore.data)
175+
.update(strippedData)
164176
.transacting(trx);
165177
}
166178
},
167179
);
168180
}
169181

182+
private async getSecretSettingKeys(trx: Transaction): Promise<Set<string>> {
183+
const secretSettings = await this.db('settings').select('key').where('secret', true).transacting(trx);
184+
return new Set(secretSettings.map((s: { key: string }) => s.key));
185+
}
186+
170187
private async getDataSnapshot(trx: Transaction, config: OperationConfig) {
171188
if (!config.id) {
172189
throw new Error(`Attempt to log operation without passing an ID! Passed ID: "${config.id}"`);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { EntityTypes } from '../interfaces';
2+
3+
export const SECRET_MARKER = '[SECRET]';
4+
export const LEGACY_MARKER = '[REDACTED]';
5+
6+
export type Snapshot = { data: Record<string, any>; related: Record<string, any[]> };
7+
8+
export function isSecretMarker(value: unknown): boolean {
9+
return value === SECRET_MARKER || value === LEGACY_MARKER;
10+
}
11+
12+
export function stripSecretFields(data: Record<string, any>): Record<string, any> {
13+
return Object.fromEntries(Object.entries(data).filter(([_, value]) => !isSecretMarker(value)));
14+
}
15+
16+
export function sanitizeSnapshot(entityType: string, snapshot: Snapshot, secretKeys: Set<string>): Snapshot {
17+
const result: Snapshot = {
18+
data: { ...snapshot.data },
19+
related: Object.fromEntries(
20+
Object.entries(snapshot.related).map(([key, arr]) => [key, arr.map((item) => ({ ...item }))]),
21+
),
22+
};
23+
24+
if (entityType === EntityTypes.settings && result.data.secret) {
25+
result.data.value = SECRET_MARKER;
26+
if (result.data.default !== undefined) {
27+
result.data.default = SECRET_MARKER;
28+
}
29+
}
30+
31+
if (entityType === EntityTypes.settings_domain_value && secretKeys.has(result.data.key)) {
32+
result.data.value = SECRET_MARKER;
33+
}
34+
35+
if (entityType === EntityTypes.auth_entities && result.data.secret) {
36+
result.data.secret = SECRET_MARKER;
37+
}
38+
39+
return result;
40+
}

registry/tests/versioning.spec.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
process.env.TZ = 'UTC';
22

33
import supertest, { type Agent } from 'supertest';
4+
import * as bcrypt from 'bcrypt';
45
import db from '../server/db';
56
import { formatDate } from '../server/util/db';
67
import { expect, request, requestWithAuth } from './common';
@@ -320,4 +321,136 @@ describe(`Tests ${basePath}`, () => {
320321
});
321322
});
322323
});
324+
325+
describe('Secret sanitization', () => {
326+
const authEntitiesPath = '/api/v1/auth_entities';
327+
328+
function findVersionRecord(
329+
versions: any[],
330+
entityId: number | undefined,
331+
filter: { hasData?: boolean; hasDataAfter?: boolean } = {},
332+
): any {
333+
return versions.find((v: any) => {
334+
if (v.entity_type !== 'auth_entities') return false;
335+
if (String(v.entity_id) !== String(entityId)) return false;
336+
if (filter.hasData !== undefined && (v.data !== null) !== filter.hasData) return false;
337+
if (filter.hasDataAfter !== undefined && (v.data_after !== null) !== filter.hasDataAfter) return false;
338+
return true;
339+
});
340+
}
341+
342+
it('should mask auth_entities secret in versioning API response', async () => {
343+
let authEntityId: number | undefined;
344+
345+
try {
346+
const createResp = await req
347+
.post(`${authEntitiesPath}/`)
348+
.send({
349+
identifier: 'test-api-auth-entity',
350+
secret: 'super-secret-password-hash',
351+
provider: 'local',
352+
role: 'admin',
353+
})
354+
.expect(200);
355+
356+
authEntityId = createResp.body.id;
357+
358+
const response = await req.get(basePath).expect(200);
359+
const authVersion = findVersionRecord(response.body, authEntityId);
360+
361+
expect(authVersion).to.exist;
362+
const dataAfter = JSON.parse(authVersion.data_after);
363+
364+
expect(dataAfter.data.secret).to.equal('[SECRET]');
365+
expect(dataAfter.data.identifier).to.equal('test-api-auth-entity');
366+
expect(dataAfter.data.provider).to.equal('local');
367+
} finally {
368+
if (authEntityId) {
369+
await req.delete(`${authEntitiesPath}/${authEntityId}`);
370+
}
371+
}
372+
});
373+
374+
it('should detect auth_entities secret changes and log them with masked values', async () => {
375+
let authEntityId: number | undefined;
376+
377+
try {
378+
const createResp = await req
379+
.post(`${authEntitiesPath}/`)
380+
.send({
381+
identifier: 'test-api-auth-change',
382+
secret: 'initial-secret-hash',
383+
provider: 'local',
384+
role: 'admin',
385+
})
386+
.expect(200);
387+
388+
authEntityId = createResp.body.id;
389+
390+
await req.put(`${authEntitiesPath}/${authEntityId}`).send({ secret: 'new-secret-hash' }).expect(200);
391+
392+
const response = await req.get(basePath).expect(200);
393+
const updateVersion = findVersionRecord(response.body, authEntityId, {
394+
hasData: true,
395+
hasDataAfter: true,
396+
});
397+
398+
expect(updateVersion).to.exist;
399+
const data = JSON.parse(updateVersion.data);
400+
const dataAfter = JSON.parse(updateVersion.data_after);
401+
402+
expect(data.data.secret).to.equal('[SECRET]');
403+
expect(dataAfter.data.secret).to.equal('[SECRET]');
404+
expect(data.data.identifier).to.equal('test-api-auth-change');
405+
} finally {
406+
if (authEntityId) {
407+
await req.delete(`${authEntitiesPath}/${authEntityId}`);
408+
}
409+
}
410+
});
411+
412+
it('should revert non-secret fields while preserving current auth secret', async () => {
413+
let authEntityId: number | undefined;
414+
415+
try {
416+
const createResp = await req
417+
.post(`${authEntitiesPath}/`)
418+
.send({
419+
identifier: 'test-api-auth-revert',
420+
secret: 'initial-secret-hash',
421+
provider: 'local',
422+
role: 'admin',
423+
})
424+
.expect(200);
425+
426+
authEntityId = createResp.body.id;
427+
428+
await req
429+
.put(`${authEntitiesPath}/${authEntityId}`)
430+
.send({ secret: 'new-secret-hash', role: 'readonly' })
431+
.expect(200);
432+
433+
const versionsResp = await req.get(basePath).expect(200);
434+
const updateVersion = findVersionRecord(versionsResp.body, authEntityId, {
435+
hasData: true,
436+
hasDataAfter: true,
437+
});
438+
439+
expect(updateVersion).to.exist;
440+
441+
await req.post(`${basePath}/${updateVersion.id}/revert`).expect(200);
442+
443+
const authResp = await req.get(`${authEntitiesPath}/${authEntityId}`).expect(200);
444+
expect(authResp.body.role).to.equal('admin');
445+
446+
const [dbEntity] = await db('auth_entities').where('id', authEntityId);
447+
expect(await bcrypt.compare('new-secret-hash', dbEntity.secret)).to.be.true;
448+
expect(await bcrypt.compare('initial-secret-hash', dbEntity.secret)).to.be.false;
449+
} finally {
450+
if (authEntityId) {
451+
await req.delete(`${authEntitiesPath}/${authEntityId}`);
452+
}
453+
}
454+
});
455+
});
323456
});

0 commit comments

Comments
 (0)