backend-defaults: refactor DefaultRootAuditorService

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-01-21 13:56:00 +01:00
parent aba0aaf4b3
commit 1874fa3b73
7 changed files with 370 additions and 187 deletions
+20 -30
View File
@@ -13,21 +13,18 @@ import type { HttpAuthService } from '@backstage/backend-plugin-api';
import type { JsonObject } from '@backstage/types';
import type { PluginMetadataService } from '@backstage/backend-plugin-api';
import type { Request as Request_2 } from 'express';
import type { RootLoggerService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import * as winston from 'winston';
// @public
export type AuditorEvent = [
eventId: string,
meta: {
plugin: string;
severityLevel: AuditorServiceEventSeverityLevel;
actor: AuditorEventActorDetails;
meta?: JsonObject;
request?: AuditorEventRequest;
} & AuditorEventStatus,
];
export type AuditorEvent = {
plugin: string;
eventId: string;
severityLevel: AuditorServiceEventSeverityLevel;
actor: AuditorEventActorDetails;
meta?: JsonObject;
request?: AuditorEventRequest;
} & AuditorEventStatus;
// @public (undocumented)
export type AuditorEventActorDetails = {
@@ -65,7 +62,7 @@ export type AuditorEventStatus =
};
// @public
export const auditorFieldFormat: Format;
export type AuditorLogFunction = (event: AuditorEvent) => void | Promise<void>;
// @public
export const auditorServiceFactory: ServiceFactory<
@@ -77,7 +74,7 @@ export const auditorServiceFactory: ServiceFactory<
// @public
export class DefaultAuditorService implements AuditorService {
static create(
impl: DefaultRootAuditorService,
logFn: AuditorLogFunction,
deps: {
auth: AuthService;
httpAuth: HttpAuthService;
@@ -90,32 +87,25 @@ export class DefaultAuditorService implements AuditorService {
): Promise<AuditorServiceEvent>;
}
// @public (undocumented)
export const defaultFormatter: Format;
// @public (undocumented)
export class DefaultRootAuditorService {
static create(options?: RootAuditorOptions): DefaultRootAuditorService;
// @public
export class WinstonRootAuditorService {
static create(
options?: WinstonRootAuditorServiceOptions,
): WinstonRootAuditorService;
// (undocumented)
forPlugin(deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
}): AuditorService;
// (undocumented)
log(auditorEvent: AuditorEvent): Promise<void>;
}
// @public
export type RootAuditorOptions =
| {
meta?: JsonObject;
format?: Format;
transports?: winston.transport[];
}
| {
rootLogger: RootLoggerService;
};
export type WinstonRootAuditorServiceOptions = {
meta?: JsonObject;
format?: Format;
transports?: winston.transport[];
};
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,144 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { DefaultAuditorService } from './DefaultAuditorService';
const mockDeps = {
auth: mockServices.auth.mock(),
httpAuth: mockServices.httpAuth.mock(),
plugin: {
getId: () => 'test',
},
};
describe('DefaultAuditorService', () => {
it('creates a auditor instance with default options', () => {
const auditor = DefaultAuditorService.create(jest.fn(), mockDeps);
expect(auditor).toBeInstanceOf(DefaultAuditorService);
});
it('should log a status "initiated" using createEvent', async () => {
const logFn = jest.fn();
const auditor = DefaultAuditorService.create(logFn, mockDeps);
await auditor.createEvent({
eventId: 'test-event',
});
expect(logFn).toHaveBeenCalledWith({
eventId: 'test-event',
status: 'initiated',
plugin: 'test',
severityLevel: 'low',
actor: {},
});
});
it('should log a status "succeeded" using createEvent', async () => {
const logFn = jest.fn();
const auditor = DefaultAuditorService.create(logFn, mockDeps);
const auditorEvent = await auditor.createEvent({
eventId: 'test-event',
});
await auditorEvent.success();
expect(logFn).toHaveBeenCalledTimes(2);
expect(logFn).toHaveBeenLastCalledWith({
eventId: 'test-event',
status: 'succeeded',
plugin: 'test',
severityLevel: 'low',
actor: {},
});
});
it('should log a status "failed"', async () => {
const logFn = jest.fn();
const auditor = DefaultAuditorService.create(logFn, mockDeps);
const auditorEvent = await auditor.createEvent({
eventId: 'test-event',
});
const error = new Error('error');
await auditorEvent.fail({ error });
expect(logFn).toHaveBeenCalledTimes(2);
expect(logFn).toHaveBeenLastCalledWith({
eventId: 'test-event',
status: 'failed',
error: error.toString(),
plugin: 'test',
severityLevel: 'low',
actor: {},
});
});
it('should use root meta', async () => {
const logFn = jest.fn();
const auditor = DefaultAuditorService.create(logFn, mockDeps);
const auditorEvent = await auditor.createEvent({
eventId: 'test-event',
meta: {
initiated: 'test',
},
});
await auditorEvent.success({ meta: { succeeded: 'test' } });
const error = new Error('error');
await auditorEvent.fail({ error, meta: { failed: 'test' } });
expect(logFn).toHaveBeenCalledTimes(3);
expect(logFn).toHaveBeenNthCalledWith(1, {
eventId: 'test-event',
status: 'initiated',
meta: {
initiated: 'test',
},
plugin: 'test',
severityLevel: 'low',
actor: {},
});
expect(logFn).toHaveBeenNthCalledWith(2, {
eventId: 'test-event',
status: 'succeeded',
meta: {
initiated: 'test',
succeeded: 'test',
},
plugin: 'test',
severityLevel: 'low',
actor: {},
});
expect(logFn).toHaveBeenNthCalledWith(3, {
eventId: 'test-event',
status: 'failed',
meta: {
initiated: 'test',
failed: 'test',
},
error: error.toString(),
plugin: 'test',
severityLevel: 'low',
actor: {},
});
});
});
@@ -23,14 +23,10 @@ import type {
BackstageCredentials,
HttpAuthService,
PluginMetadataService,
RootLoggerService,
} from '@backstage/backend-plugin-api';
import { ForwardedError } from '@backstage/errors';
import type { JsonObject } from '@backstage/types';
import type { Request } from 'express';
import type { Format } from 'logform';
import * as winston from 'winston';
import { WinstonLogger } from '../rootLogger';
/** @public */
export type AuditorEventActorDetails = {
@@ -82,56 +78,61 @@ export type AuditorEventOptions<TMeta extends JsonObject> = {
*
* @public
*/
export type AuditorEvent = [
eventId: string,
meta: {
plugin: string;
severityLevel: AuditorServiceEventSeverityLevel;
actor: AuditorEventActorDetails;
meta?: JsonObject;
request?: AuditorEventRequest;
} & AuditorEventStatus,
];
/** @public */
export const defaultFormatter = winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json(),
);
export type AuditorEvent = {
plugin: string;
eventId: string;
severityLevel: AuditorServiceEventSeverityLevel;
actor: AuditorEventActorDetails;
meta?: JsonObject;
request?: AuditorEventRequest;
} & AuditorEventStatus;
/**
* Adds `isAuditorEvent` field
*
* Logging function used by the auditor.
* @public
*/
export const auditorFieldFormat = winston.format(info => {
return { ...info, isAuditorEvent: true };
})();
export type AuditorLogFunction = (event: AuditorEvent) => void | Promise<void>;
/**
* A {@link @backstage/backend-plugin-api#AuditorService} implementation based on winston.
* A {@link @backstage/backend-plugin-api#AuditorService} implementation that logs events using a provided callback.
*
* @public
*
* @example
* ```ts
* export const auditorServiceFactory = createServiceFactory({
* service: coreServices.auditor,
* deps: {
* logger: coreServices.logger,
* auth: coreServices.auth,
* httpAuth: coreServices.httpAuth,
* plugin: coreServices.pluginMetadata,
* },
* factory({ logger, plugin, auth, httpAuth }) {
* const auditLogger = logger.child({ isAuditorEvent: true });
* return DefaultAuditorService.create(
* event => auditLogger.info(`${event.plugin}.${event.eventId}`, event),
* { plugin, auth, httpAuth },
* );
* },
* });
* ```
*/
export class DefaultAuditorService implements AuditorService {
private readonly impl: DefaultRootAuditorService;
private readonly logFn: AuditorLogFunction;
private readonly auth: AuthService;
private readonly httpAuth: HttpAuthService;
private readonly plugin: PluginMetadataService;
private constructor(
impl: DefaultRootAuditorService,
logFn: AuditorLogFunction,
deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
},
) {
this.impl = impl;
this.logFn = logFn;
this.auth = deps.auth;
this.httpAuth = deps.httpAuth;
this.plugin = deps.plugin;
@@ -141,21 +142,40 @@ export class DefaultAuditorService implements AuditorService {
* Creates a {@link DefaultAuditorService} instance.
*/
static create(
impl: DefaultRootAuditorService,
logFn: AuditorLogFunction,
deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
},
): DefaultAuditorService {
return new DefaultAuditorService(impl, deps);
return new DefaultAuditorService(logFn, deps);
}
private async log<TMeta extends JsonObject>(
options: AuditorEventOptions<TMeta>,
): Promise<void> {
const auditEvent = await this.reshapeAuditorEvent(options);
this.impl.log(auditEvent);
const { eventId, severityLevel = 'low', request, meta, ...rest } = options;
await this.logFn({
plugin: this.plugin.getId(),
eventId,
severityLevel,
actor: {
actorId: await this.getActorId(request),
ip: request?.ip,
hostname: request?.hostname,
userAgent: request?.get('user-agent'),
},
request: request
? {
url: request?.originalUrl,
method: request?.method,
}
: undefined,
meta: Object.keys(meta ?? {}).length === 0 ? undefined : meta,
...rest,
});
}
async createEvent(
@@ -175,7 +195,7 @@ export class DefaultAuditorService implements AuditorService {
await this.log({
...options,
...params,
error: params.error.toString(),
error: String(params.error),
meta: { ...options.meta, ...params?.meta },
status: 'failed',
});
@@ -207,104 +227,4 @@ export class DefaultAuditorService implements AuditorService {
return undefined;
}
private async reshapeAuditorEvent<T extends JsonObject>(
options: AuditorEventOptions<T>,
): Promise<AuditorEvent> {
const { eventId, severityLevel = 'low', request, meta, ...rest } = options;
const auditEvent: AuditorEvent = [
`${this.plugin.getId()}.${eventId}`,
{
plugin: this.plugin.getId(),
severityLevel,
actor: {
actorId: await this.getActorId(request),
ip: request?.ip,
hostname: request?.hostname,
userAgent: request?.get('user-agent'),
},
request: request
? {
url: request?.originalUrl,
method: request?.method,
}
: undefined,
meta: Object.keys(meta ?? {}).length === 0 ? undefined : meta,
...rest,
},
];
return auditEvent;
}
}
/**
* Options for creating a root auditor.
* If `rootLogger` is provided, the root auditor will default to using it.
* Otherwise, a new logger will be created using the provided `meta`, `format`, and `transports`.
*
* @public
*/
export type RootAuditorOptions =
| {
meta?: JsonObject;
format?: Format;
transports?: winston.transport[];
}
| {
rootLogger: RootLoggerService;
};
/** @public */
export class DefaultRootAuditorService {
private readonly impl: WinstonLogger;
private constructor(impl: WinstonLogger) {
this.impl = impl;
}
/**
* Creates a {@link DefaultRootAuditorService} instance.
*/
static create(options?: RootAuditorOptions): DefaultRootAuditorService {
if (options && 'rootLogger' in options) {
return new DefaultRootAuditorService(
options.rootLogger.child({ isAuditorEvent: true }) as WinstonLogger,
);
}
let auditor = WinstonLogger.create({
meta: {
service: 'backstage',
},
level: 'info',
format: winston.format.combine(
auditorFieldFormat,
options?.format ?? defaultFormatter,
),
transports: options?.transports,
});
if (options?.meta) {
auditor = auditor.child(options.meta) as WinstonLogger;
}
return new DefaultRootAuditorService(auditor);
}
async log(auditorEvent: AuditorEvent): Promise<void> {
this.impl.info(...auditorEvent);
}
forPlugin(deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
}): AuditorService {
const impl = new DefaultRootAuditorService(
this.impl.child({}) as WinstonLogger,
);
return DefaultAuditorService.create(impl, deps);
}
}
@@ -15,16 +15,17 @@
*/
import { mockServices } from '@backstage/backend-test-utils';
import { DefaultAuditorService, DefaultRootAuditorService } from './Auditor';
import { WinstonRootAuditorService } from './WinstonRootAuditorService';
import { DefaultAuditorService } from './DefaultAuditorService';
describe('Auditor', () => {
describe('WinstonRootAuditorService', () => {
it('creates a auditor instance with default options', () => {
const auditor = DefaultRootAuditorService.create();
expect(auditor).toBeInstanceOf(DefaultRootAuditorService);
const auditor = WinstonRootAuditorService.create();
expect(auditor).toBeInstanceOf(WinstonRootAuditorService);
});
it('creates a child logger', () => {
const auditor = DefaultRootAuditorService.create();
const auditor = WinstonRootAuditorService.create();
const childLogger = auditor.forPlugin({
auth: mockServices.auth.mock(),
httpAuth: mockServices.httpAuth.mock(),
@@ -38,7 +39,7 @@ describe('Auditor', () => {
it('should log a status "initiated" using createEvent', async () => {
const pluginId = 'test-plugin';
const auditor = DefaultRootAuditorService.create().forPlugin({
const auditor = WinstonRootAuditorService.create().forPlugin({
auth: mockServices.auth.mock(),
httpAuth: mockServices.httpAuth.mock(),
plugin: {
@@ -61,7 +62,7 @@ describe('Auditor', () => {
it('should log a status "succeeded" using createEvent', async () => {
const pluginId = 'test-plugin';
const auditor = DefaultRootAuditorService.create().forPlugin({
const auditor = WinstonRootAuditorService.create().forPlugin({
auth: mockServices.auth.mock(),
httpAuth: mockServices.httpAuth.mock(),
plugin: {
@@ -88,7 +89,7 @@ describe('Auditor', () => {
it('should log a status "failed"', async () => {
const pluginId = 'test-plugin';
const auditor = DefaultRootAuditorService.create().forPlugin({
const auditor = WinstonRootAuditorService.create().forPlugin({
auth: mockServices.auth.mock(),
httpAuth: mockServices.httpAuth.mock(),
plugin: {
@@ -117,7 +118,7 @@ describe('Auditor', () => {
it('should use root meta', async () => {
const pluginId = 'test-plugin';
const auditor = DefaultRootAuditorService.create().forPlugin({
const auditor = WinstonRootAuditorService.create().forPlugin({
auth: mockServices.auth.mock(),
httpAuth: mockServices.httpAuth.mock(),
plugin: {
@@ -0,0 +1,119 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
AuditorService,
AuthService,
HttpAuthService,
PluginMetadataService,
} from '@backstage/backend-plugin-api';
import type { JsonObject } from '@backstage/types';
import type { Format } from 'logform';
import * as winston from 'winston';
import { WinstonLogger } from '../rootLogger';
import { DefaultAuditorService } from './DefaultAuditorService';
/** @public */
export const defaultFormatter = winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json(),
);
/**
* Adds `isAuditorEvent` field
*
* @public
*/
export const auditorFieldFormat = winston.format(info => {
return { ...info, isAuditorEvent: true };
})();
/**
* Options for creating a {@link WinstonRootAuditorService}.
* @public
*/
export type WinstonRootAuditorServiceOptions = {
meta?: JsonObject;
format?: Format;
transports?: winston.transport[];
};
/**
* An implementation of the {@link @backstage/backend-plugin-api#AuditorService} that logs events using a separate winston logger.
*
* @public
*
* @example
* ```ts
* export const auditorServiceFactory = createServiceFactory({
* service: coreServices.auditor,
* deps: {
* auth: coreServices.auth,
* httpAuth: coreServices.httpAuth,
* plugin: coreServices.pluginMetadata,
* },
* createRootContext() {
* return WinstonRootAuditorService.create();
* },
* factory({ plugin, auth, httpAuth }, root) {
* return root.forPlugin({ plugin, auth, httpAuth });
* },
* });
* ```
*/
export class WinstonRootAuditorService {
private constructor(private readonly winstonLogger: WinstonLogger) {}
/**
* Creates a {@link WinstonRootAuditorService} instance.
*/
static create(
options?: WinstonRootAuditorServiceOptions,
): WinstonRootAuditorService {
let winstonLogger = WinstonLogger.create({
meta: {
service: 'backstage',
},
level: 'info',
format: winston.format.combine(
auditorFieldFormat,
options?.format ?? defaultFormatter,
),
transports: options?.transports,
});
if (options?.meta) {
winstonLogger = winstonLogger.child(options.meta) as WinstonLogger;
}
return new WinstonRootAuditorService(winstonLogger);
}
forPlugin(deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
}): AuditorService {
return DefaultAuditorService.create(
e => this.winstonLogger.info(`${e.plugin}.${e.eventId}`, e),
deps,
);
}
}
@@ -18,7 +18,7 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultRootAuditorService } from './Auditor';
import { DefaultAuditorService } from './DefaultAuditorService';
/**
* Plugin-level auditing.
@@ -32,17 +32,16 @@ import { DefaultRootAuditorService } from './Auditor';
export const auditorServiceFactory = createServiceFactory({
service: coreServices.auditor,
deps: {
rootLogger: coreServices.rootLogger,
logger: coreServices.logger,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
plugin: coreServices.pluginMetadata,
},
async createRootContext({ rootLogger }) {
const auditor = DefaultRootAuditorService.create({ rootLogger });
return auditor;
},
factory({ plugin, auth, httpAuth }, rootAuditor) {
return rootAuditor.forPlugin({ auth, httpAuth, plugin });
factory({ logger, plugin, auth, httpAuth }) {
const auditLogger = logger.child({ isAuditorEvent: true });
return DefaultAuditorService.create(
event => auditLogger.info(`${event.plugin}.${event.eventId}`, event),
{ plugin, auth, httpAuth },
);
},
});
@@ -14,5 +14,15 @@
* limitations under the License.
*/
export * from './Auditor';
export type {
AuditorEvent,
AuditorEventActorDetails,
AuditorEventOptions,
AuditorEventRequest,
AuditorEventStatus,
AuditorLogFunction,
} from './DefaultAuditorService';
export { DefaultAuditorService } from './DefaultAuditorService';
export { auditorServiceFactory } from './auditorServiceFactory';
export { WinstonRootAuditorService } from './WinstonRootAuditorService';
export type { WinstonRootAuditorServiceOptions } from './WinstonRootAuditorService';