Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved values redacting #22332

Merged
merged 11 commits into from
May 2, 2024
5 changes: 5 additions & 0 deletions .changeset/purple-shirts-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

Check warning on line 1 in .changeset/purple-shirts-care.md

View workflow job for this annotation

GitHub Actions / Lint

File ignored by default.
"@directus/api": patch
---

Improved redacting of sensitive values
2 changes: 1 addition & 1 deletion api/src/database/run-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default async function runAST(

// Run the items through the special transforms
const payloadService = new PayloadService(collection, { knex, schema });
let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
let items: null | Item | Item[] = await payloadService.processValues('read', rawItems, query.alias ?? {});

if (!items || (Array.isArray(items) && items.length === 0)) return items;

Expand Down
99 changes: 89 additions & 10 deletions api/src/services/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Knex } from 'knex';
import knex from 'knex';
import { MockClient, Tracker, createTracker } from 'knex-mock-client';
import type { MockedFunction } from 'vitest';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import type { Helpers } from '../database/helpers/index.js';
import { getHelpers } from '../database/helpers/index.js';
import { PayloadService } from './index.js';
Expand Down Expand Up @@ -39,7 +39,7 @@ describe('Integration Tests', () => {
});

describe('csv', () => {
it('Returns undefined for illegal values', async () => {
test('Returns undefined for illegal values', async () => {
const result = await service.transformers['cast-csv']!({
value: 123,
action: 'read',
Expand All @@ -52,7 +52,7 @@ describe('Integration Tests', () => {
expect(result).toBe(undefined);
});

it('Returns [] for empty strings', async () => {
test('Returns [] for empty strings', async () => {
const result = await service.transformers['cast-csv']!({
value: '',
action: 'read',
Expand All @@ -65,7 +65,7 @@ describe('Integration Tests', () => {
expect(result).toMatchObject([]);
});

it('Returns array values as is', async () => {
test('Returns array values as is', async () => {
const result = await service.transformers['cast-csv']!({
value: ['test', 'directus'],
action: 'read',
Expand All @@ -78,7 +78,7 @@ describe('Integration Tests', () => {
expect(result).toEqual(['test', 'directus']);
});

it('Splits the CSV string', async () => {
test('Splits the CSV string', async () => {
const result = await service.transformers['cast-csv']!({
value: 'test,directus',
action: 'read',
Expand All @@ -91,7 +91,7 @@ describe('Integration Tests', () => {
expect(result).toMatchObject(['test', 'directus']);
});

it('Saves array values as joined string', async () => {
test('Saves array values as joined string', async () => {
const result = await service.transformers['cast-csv']!({
value: ['test', 'directus'],
action: 'create',
Expand All @@ -104,7 +104,7 @@ describe('Integration Tests', () => {
expect(result).toBe('test,directus');
});

it('Saves string values as is', async () => {
test('Saves string values as is', async () => {
const result = await service.transformers['cast-csv']!({
value: 'test,directus',
action: 'create',
Expand Down Expand Up @@ -190,7 +190,7 @@ describe('Integration Tests', () => {
});

describe('processes dates', () => {
it('with zero values', () => {
test('with zero values', () => {
const result = service.processDates(
[
{
Expand All @@ -211,7 +211,7 @@ describe('Integration Tests', () => {
]);
});

it('with typical values', () => {
test('with typical values', () => {
const result = service.processDates(
[
{
Expand All @@ -232,7 +232,7 @@ describe('Integration Tests', () => {
]);
});

it('with date object values', () => {
test('with date object values', () => {
const result = service.processDates(
[
{
Expand All @@ -254,6 +254,85 @@ describe('Integration Tests', () => {
});
});
});

describe('processValues', () => {
let service: PayloadService;

const concealedField = 'hidden';
const stringField = 'string';
const REDACT_STR = '**********';

beforeEach(() => {
service = new PayloadService('test', {
knex: db,
schema: {
collections: {
test: {
collection: 'test',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
[concealedField]: {
field: concealedField,
defaultValue: null,
nullable: true,
generated: false,
type: 'hash',
dbType: 'nvarchar',
precision: null,
scale: null,
special: ['hash', 'conceal'],
note: null,
validation: null,
alias: false,
},
[stringField]: {
field: stringField,
defaultValue: null,
nullable: true,
generated: false,
type: 'string',
dbType: 'nvarchar',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
});

test('processing special fields', async () => {
const result = await service.processValues('read', {
string: 'not-redacted',
hidden: 'secret',
});

expect(result).toMatchObject({ string: 'not-redacted', hidden: REDACT_STR });
});

test('processing aliassed special fields', async () => {
const result = await service.processValues(
'read',
{
other_string: 'not-redacted',
other_hidden: 'secret',
},
{ other_string: 'string', other_hidden: 'hidden' },
);

expect(result).toMatchObject({ other_string: 'not-redacted', other_hidden: REDACT_STR });
});
});
});
});

Expand Down
35 changes: 29 additions & 6 deletions api/src/services/payload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
import type { Accountability, Alterations, Item, PrimaryKey, Query, SchemaOverview } from '@directus/types';
import type {
Accountability,
Alterations,
Item,
PrimaryKey,
FieldOverview,
Query,
SchemaOverview,
} from '@directus/types';
import { parseJSON, toArray } from '@directus/utils';
import { format, isValid, parseISO } from 'date-fns';
import { unflatten } from 'flat';
Expand Down Expand Up @@ -141,30 +149,45 @@ export class PayloadService {

processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
processValues(action: Action, payload: Partial<Item>): Promise<Partial<Item>>;
processValues(action: Action, payloads: Partial<Item>[], aliasMap: Record<string, string>): Promise<Partial<Item>[]>;
processValues(action: Action, payload: Partial<Item>, aliasMap: Record<string, string>): Promise<Partial<Item>>;
async processValues(
action: Action,
payload: Partial<Item> | Partial<Item>[],
aliasMap: Record<string, string> = {},
): Promise<Partial<Item> | Partial<Item>[]> {
const processedPayload = toArray(payload);

if (processedPayload.length === 0) return [];

const fieldsInPayload = Object.keys(processedPayload[0]!);
const fieldEntries = Object.entries(this.schema.collections[this.collection]!.fields);
const aliasEntries = Object.entries(aliasMap);

let specialFieldsInCollection = Object.entries(this.schema.collections[this.collection]!.fields).filter(
([_name, field]) => field.special && field.special.length > 0,
);
let specialFields: [string, FieldOverview][] = [];

for (const [name, field] of fieldEntries) {
if (field.special && field.special.length > 0) {
specialFields.push([name, field]);

for (const [aliasName, fieldName] of aliasEntries) {
if (fieldName === name) {
specialFields.push([aliasName, { ...field, field: aliasName }]);
}
}
}
}

if (action === 'read') {
specialFieldsInCollection = specialFieldsInCollection.filter(([name]) => {
specialFields = specialFields.filter(([name]) => {
return fieldsInPayload.includes(name);
});
}

await Promise.all(
processedPayload.map(async (record: any) => {
await Promise.all(
specialFieldsInCollection.map(async ([name, field]) => {
specialFields.map(async ([name, field]) => {
const newValue = await this.processField(field, record, action, this.accountability);
if (newValue !== undefined) record[name] = newValue;
}),
Expand Down