diff --git a/packages/backend-defaults/report-auditor.api.md b/packages/backend-defaults/report-auditor.api.md index c6d12295b3..353b6f241f 100644 --- a/packages/backend-defaults/report-auditor.api.md +++ b/packages/backend-defaults/report-auditor.api.md @@ -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; // @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; } -// @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; } // @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) ``` diff --git a/packages/backend-defaults/src/entrypoints/auditor/DefaultAuditorService.test.ts b/packages/backend-defaults/src/entrypoints/auditor/DefaultAuditorService.test.ts new file mode 100644 index 0000000000..c960a1daa2 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/auditor/DefaultAuditorService.test.ts @@ -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: {}, + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/auditor/Auditor.ts b/packages/backend-defaults/src/entrypoints/auditor/DefaultAuditorService.ts similarity index 54% rename from packages/backend-defaults/src/entrypoints/auditor/Auditor.ts rename to packages/backend-defaults/src/entrypoints/auditor/DefaultAuditorService.ts index c8e9e25fec..c6ba9592f5 100644 --- a/packages/backend-defaults/src/entrypoints/auditor/Auditor.ts +++ b/packages/backend-defaults/src/entrypoints/auditor/DefaultAuditorService.ts @@ -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 = { * * @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; /** - * 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( options: AuditorEventOptions, ): Promise { - 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( - options: AuditorEventOptions, - ): Promise { - 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 { - 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); - } } diff --git a/packages/backend-defaults/src/entrypoints/auditor/Auditor.test.ts b/packages/backend-defaults/src/entrypoints/auditor/WinstonRootAuditorService.test.ts similarity index 87% rename from packages/backend-defaults/src/entrypoints/auditor/Auditor.test.ts rename to packages/backend-defaults/src/entrypoints/auditor/WinstonRootAuditorService.test.ts index 671e4ef102..2d9a959c64 100644 --- a/packages/backend-defaults/src/entrypoints/auditor/Auditor.test.ts +++ b/packages/backend-defaults/src/entrypoints/auditor/WinstonRootAuditorService.test.ts @@ -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: { diff --git a/packages/backend-defaults/src/entrypoints/auditor/WinstonRootAuditorService.ts b/packages/backend-defaults/src/entrypoints/auditor/WinstonRootAuditorService.ts new file mode 100644 index 0000000000..e8b78368d0 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/auditor/WinstonRootAuditorService.ts @@ -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, + ); + } +} diff --git a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts index cba8823c92..520f88b571 100644 --- a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts @@ -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 }, + ); }, }); diff --git a/packages/backend-defaults/src/entrypoints/auditor/index.ts b/packages/backend-defaults/src/entrypoints/auditor/index.ts index 563561dbfb..3231c6a571 100644 --- a/packages/backend-defaults/src/entrypoints/auditor/index.ts +++ b/packages/backend-defaults/src/entrypoints/auditor/index.ts @@ -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';