scaffolder-backend: refactor parameter truncation
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -16,10 +16,14 @@
|
||||
|
||||
import os from 'os';
|
||||
import { DatabaseManager } from '@backstage/backend-defaults/database';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DatabaseTaskStore } from './DatabaseTaskStore';
|
||||
import { StorageTaskBroker } from './StorageTaskBroker';
|
||||
import { TaskWorker, TaskWorkerOptions } from './TaskWorker';
|
||||
import {
|
||||
createParameterTruncator,
|
||||
TaskWorker,
|
||||
TaskWorkerOptions,
|
||||
} from './TaskWorker';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
|
||||
@@ -140,6 +144,66 @@ describe('TaskWorker', () => {
|
||||
const event = events.find(e => e.type === 'completion');
|
||||
expect(event?.body.output).toEqual({ testOutput: 'testmockoutput' });
|
||||
});
|
||||
|
||||
it('should log an audit event with task parameters when running a task', async () => {
|
||||
(workflowRunner.execute as jest.Mock).mockResolvedValue({
|
||||
output: {},
|
||||
});
|
||||
|
||||
const auditor = mockServices.auditor.mock();
|
||||
const auditEvent = {
|
||||
success: jest.fn(),
|
||||
fail: jest.fn(),
|
||||
};
|
||||
auditor.createEvent.mockResolvedValue(auditEvent);
|
||||
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const taskWorker = await TaskWorker.create({
|
||||
logger,
|
||||
workingDirectory,
|
||||
integrations,
|
||||
taskBroker: broker,
|
||||
actionRegistry,
|
||||
auditor,
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
scaffolder: {
|
||||
auditor: {
|
||||
taskParameterMaxLength: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await taskWorker.runOneTask({
|
||||
spec: {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
parameters: {
|
||||
test: 'thisisaverylongstring',
|
||||
},
|
||||
steps: [],
|
||||
output: {},
|
||||
},
|
||||
complete: jest.fn(),
|
||||
createdBy: 'test-creator',
|
||||
taskId: 'test-id',
|
||||
} as unknown as TaskContext);
|
||||
|
||||
expect(auditor.createEvent).toHaveBeenCalledWith({
|
||||
eventId: 'task',
|
||||
severityLevel: 'medium',
|
||||
meta: {
|
||||
actionType: 'execution',
|
||||
createdBy: 'test-creator',
|
||||
taskId: 'test-id',
|
||||
taskParameters: {
|
||||
test: 'thisi...<truncated>',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(auditEvent.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent TaskWorker', () => {
|
||||
@@ -344,33 +408,11 @@ describe('TaskWorker internals', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskWorker.truncateParameters', () => {
|
||||
let worker: TaskWorker;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
const logger = { debug: jest.fn() } as any;
|
||||
|
||||
const config = {
|
||||
getOptionalNumber: jest.fn().mockReturnValue(5),
|
||||
} as unknown as Config;
|
||||
|
||||
worker = await TaskWorker.create({
|
||||
logger,
|
||||
workingDirectory: '/tmp',
|
||||
integrations: {} as ScmIntegrations,
|
||||
taskBroker: {} as TaskBroker,
|
||||
actionRegistry: {} as TemplateActionRegistry,
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
describe('createParameterTruncator', () => {
|
||||
it('successfully does nothing', async () => {
|
||||
const testParams = {};
|
||||
|
||||
// @ts-expect-error (truncateParameters is private, but for test we can access)
|
||||
const result = worker.truncateParameters(testParams);
|
||||
const result = createParameterTruncator()(testParams);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
@@ -381,19 +423,28 @@ describe('TaskWorker.truncateParameters', () => {
|
||||
test2: 'thisisaverylongstring',
|
||||
nested: {
|
||||
test3: 'anotherlongstringhere',
|
||||
test4: ['ok', 'toolongstring'],
|
||||
test4: ['ok', 'toolongstring', { prop: 'thisisaverylongstring' }],
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error (truncateParameters is private, but for test we can access)
|
||||
const result = worker.truncateParameters(params);
|
||||
const result = createParameterTruncator(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
scaffolder: {
|
||||
auditor: {
|
||||
taskParameterMaxLength: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
test: 'short',
|
||||
test2: 'thisi...<truncated>',
|
||||
nested: {
|
||||
test3: 'anoth...<truncated>',
|
||||
test4: ['ok', 'toolo...<truncated>'],
|
||||
test4: ['ok', 'toolo...<truncated>', { prop: 'thisi...<truncated>' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { AuditorService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { assertError, stringifyError } from '@backstage/errors';
|
||||
import { assertError, InputError, stringifyError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
@@ -32,6 +32,8 @@ import { setTimeout } from 'timers/promises';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
const DEFAULT_TASK_PARAMETER_MAX_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* TaskWorkerOptions
|
||||
* @deprecated this type is deprecated, and there will be a new way to create Workers in the next major version.
|
||||
@@ -91,17 +93,21 @@ export class TaskWorker {
|
||||
private taskQueue: PQueue;
|
||||
private logger: LoggerService | undefined;
|
||||
private auditor: AuditorService | undefined;
|
||||
private config: Config | undefined;
|
||||
private parameterAuditTransform: ParameterAuditTransform;
|
||||
private stopWorkers: boolean;
|
||||
|
||||
private constructor(private readonly options: TaskWorkerOptions) {
|
||||
private constructor(
|
||||
private readonly options: TaskWorkerOptions & {
|
||||
parameterAuditTransform: ParameterAuditTransform;
|
||||
},
|
||||
) {
|
||||
this.stopWorkers = false;
|
||||
this.logger = options.logger;
|
||||
this.auditor = options.auditor;
|
||||
this.config = options.config;
|
||||
this.taskQueue = new PQueue({
|
||||
concurrency: options.concurrentTasksLimit,
|
||||
});
|
||||
this.parameterAuditTransform = options.parameterAuditTransform;
|
||||
}
|
||||
|
||||
static async create(options: CreateWorkerOptions): Promise<TaskWorker> {
|
||||
@@ -139,6 +145,7 @@ export class TaskWorker {
|
||||
auditor,
|
||||
config,
|
||||
gracefulShutdown,
|
||||
parameterAuditTransform: createParameterTruncator(config),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,46 +197,6 @@ export class TaskWorker {
|
||||
});
|
||||
}
|
||||
|
||||
private truncateParameters(parameters: JsonObject) {
|
||||
const taskParameterMaxLength =
|
||||
this.config?.getOptionalNumber(
|
||||
'scaffolder.auditor.taskParameterMaxLength',
|
||||
) ?? 256;
|
||||
|
||||
if (taskParameterMaxLength === -1) {
|
||||
this.logger?.debug(
|
||||
`scaffolder.auditor.taskParameterMaxLength manually disabled via configuration, no task parameter length limit set.`,
|
||||
);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
function truncate(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > taskParameterMaxLength) {
|
||||
return value
|
||||
.slice(0, taskParameterMaxLength)
|
||||
.concat('...<truncated>');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(truncate);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const k in value as object) {
|
||||
if (Object.hasOwn(value, k)) {
|
||||
result[k] = truncate((value as any)[k]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return truncate(parameters) as JsonObject;
|
||||
}
|
||||
|
||||
async runOneTask(task: TaskContext) {
|
||||
const auditorEvent = await this.auditor?.createEvent({
|
||||
eventId: 'task',
|
||||
@@ -238,7 +205,7 @@ export class TaskWorker {
|
||||
actionType: 'execution',
|
||||
createdBy: task.createdBy,
|
||||
taskId: task.taskId,
|
||||
taskParameters: this.truncateParameters(task.spec.parameters),
|
||||
taskParameters: this.parameterAuditTransform(task.spec.parameters),
|
||||
templateRef: task.spec.templateInfo?.entityRef,
|
||||
},
|
||||
});
|
||||
@@ -267,3 +234,53 @@ export class TaskWorker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ParameterAuditTransform = (parameters: JsonObject) => JsonObject;
|
||||
|
||||
/**
|
||||
* Truncates task parameters for audit logging using the configured max length.
|
||||
* @internal
|
||||
*/
|
||||
export function createParameterTruncator(
|
||||
config?: Config,
|
||||
): ParameterAuditTransform {
|
||||
const maxLength =
|
||||
config?.getOptionalNumber('scaffolder.auditor.taskParameterMaxLength') ??
|
||||
DEFAULT_TASK_PARAMETER_MAX_LENGTH;
|
||||
|
||||
if (!Number.isSafeInteger(maxLength) || maxLength < -1) {
|
||||
throw new InputError(
|
||||
`Invalid configuration for 'scaffolder.auditor.taskParameterMaxLength', got ${maxLength}. Must be a positive integer or -1 to disable truncation.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (maxLength === -1) {
|
||||
return (parameters: JsonObject) => parameters;
|
||||
}
|
||||
|
||||
return (parameters: JsonObject) => {
|
||||
function truncate(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > maxLength) {
|
||||
return value.slice(0, maxLength).concat('...<truncated>');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(truncate);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const k in value as object) {
|
||||
if (Object.hasOwn(value, k)) {
|
||||
result[k] = truncate((value as any)[k]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return truncate(parameters) as JsonObject;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user