apply requested changes

Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
Paul Schultz
2025-01-20 16:14:33 -06:00
parent a4aa244fb7
commit ab02538c3d
13 changed files with 17 additions and 397 deletions
@@ -2,6 +2,4 @@
'@backstage/backend-defaults': minor
---
feat: add auditor to `coreServices`
This change introduces the `auditor` service implementation details.
@@ -2,6 +2,4 @@
'@backstage/backend-plugin-api': minor
---
feat: add auditor to `coreServices`
This change introduces the `auditor` service definition.
@@ -2,6 +2,4 @@
'@backstage/backend-test-utils': minor
---
feat: add auditor to `coreServices`
This change introduces mocks for the `auditor` service.
@@ -2,6 +2,4 @@
'@backstage/plugin-catalog-backend': minor
---
feat: add auditor to `coreServices`
This change integrates the `auditor` service into the Catalog plugin.
@@ -2,6 +2,4 @@
'@backstage/plugin-scaffolder-backend': minor
---
feat: add auditor to `coreServices`
This change integrates the `auditor` service into the Scaffolder plugin.
@@ -2,6 +2,4 @@
'@backstage/plugin-scaffolder-node': minor
---
feat: add auditor to `coreServices`
This change introduces an optional `taskId` property to `TaskContext`.
@@ -21,6 +21,7 @@ import * as winston from 'winston';
export type AuditorEvent = [
eventId: string,
meta: {
plugin: string;
severityLevel: AuditorServiceEventSeverityLevel;
actor: AuditorEventActorDetails;
meta?: JsonObject;
@@ -60,7 +61,7 @@ export type AuditorEventStatus =
}
| {
status: 'failed';
error: Error;
error: string;
};
// @public
@@ -80,6 +80,7 @@ describe('Auditor', () => {
expect(auditorSpy).toHaveBeenCalledTimes(2);
expect(auditorSpy).toHaveBeenLastCalledWith({
eventId: 'test-event',
meta: {},
status: 'succeeded',
});
});
@@ -107,8 +108,9 @@ describe('Auditor', () => {
expect(auditorSpy).toHaveBeenCalledTimes(2);
expect(auditorSpy).toHaveBeenLastCalledWith({
eventId: 'test-event',
meta: {},
status: 'failed',
error,
error: error.toString(),
});
});
@@ -160,7 +162,7 @@ describe('Auditor', () => {
initiated: 'test',
failed: 'test',
},
error,
error: error.toString(),
});
});
});
@@ -52,7 +52,7 @@ export type AuditorEventStatus =
| { status: 'succeeded' }
| {
status: 'failed';
error: Error;
error: string;
};
/**
@@ -85,6 +85,7 @@ export type AuditorEventOptions<TMeta extends JsonObject> = {
export type AuditorEvent = [
eventId: string,
meta: {
plugin: string;
severityLevel: AuditorServiceEventSeverityLevel;
actor: AuditorEventActorDetails;
meta?: JsonObject;
@@ -164,31 +165,18 @@ export class DefaultAuditorService implements AuditorService {
return {
success: async params => {
// return undefined if both objects are empty; otherwise, merge the objects
const meta =
Object.keys(options.meta ?? {}).length === 0 &&
Object.keys(params?.meta ?? {}).length === 0
? undefined
: { ...options.meta, ...params?.meta };
await this.log({
...options,
meta,
meta: { ...options.meta, ...params?.meta },
status: 'succeeded',
});
},
fail: async params => {
// return undefined if both objects are empty; otherwise, merge the objects
const meta =
Object.keys(options.meta ?? {}).length === 0 &&
Object.keys(params.meta ?? {}).length === 0
? undefined
: { ...options.meta, ...params.meta };
await this.log({
...options,
...params,
meta,
error: params.error.toString(),
meta: { ...options.meta, ...params?.meta },
status: 'failed',
});
},
@@ -223,11 +211,12 @@ export class DefaultAuditorService implements AuditorService {
private async reshapeAuditorEvent<T extends JsonObject>(
options: AuditorEventOptions<T>,
): Promise<AuditorEvent> {
const { eventId, severityLevel = 'low', request, ...rest } = options;
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),
@@ -241,6 +230,7 @@ export class DefaultAuditorService implements AuditorService {
method: request?.method,
}
: undefined,
meta: Object.keys(meta ?? {}).length === 0 ? undefined : meta,
...rest,
},
];
@@ -304,18 +294,7 @@ export class DefaultRootAuditorService {
}
async log(auditorEvent: AuditorEvent): Promise<void> {
const [eventId, meta] = auditorEvent;
// change `error` type to a string for logging purposes
let fields: Omit<AuditorEvent[1], 'error'> & { error?: string };
if ('error' in meta) {
fields = { ...meta, error: meta.error.toString() };
} else {
fields = meta;
}
this.impl.info(eventId, fields);
this.impl.info(...auditorEvent);
}
forPlugin(deps: {
@@ -154,7 +154,6 @@ export function mockErrorHandler(): ErrorRequestHandler<
// @public
export namespace mockServices {
export function auditor(options?: { pluginId?: string }): AuditorService;
// (undocumented)
export namespace auditor {
const // (undocumented)
@@ -1,100 +0,0 @@
/*
* Copyright 2023 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 { ErrorLike } from '@backstage/errors';
import { MockRootAuditorService } from './MockAuditorService';
import { MockAuthService } from './MockAuthService';
import { MockHttpAuthService } from './MockHttpAuthService';
import { mockCredentials } from './mockCredentials';
describe('MockAuditorService', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should send initiated log with createEvent', async () => {
const spy = jest.spyOn(MockRootAuditorService.prototype, 'log');
const pluginId = 'test-plugin';
const auditor = MockRootAuditorService.create().forPlugin({
plugin: {
getId: () => pluginId,
},
auth: new MockAuthService({
pluginId,
disableDefaultAuthPolicy: false,
}),
httpAuth: new MockHttpAuthService(pluginId, mockCredentials.user()),
});
await auditor.createEvent({
eventId: 'test-event',
});
expect(spy).toHaveBeenCalled();
});
it('should send succeeded log with createEvent', async () => {
const spy = jest.spyOn(MockRootAuditorService.prototype, 'log');
const pluginId = 'test-plugin';
const auditor = MockRootAuditorService.create().forPlugin({
plugin: {
getId: () => pluginId,
},
auth: new MockAuthService({
pluginId,
disableDefaultAuthPolicy: false,
}),
httpAuth: new MockHttpAuthService(pluginId, mockCredentials.user()),
});
const auditorEvent = await auditor.createEvent({
eventId: 'test-event',
});
await auditorEvent.success();
expect(spy).toHaveBeenCalledTimes(2);
});
it('should send failed log with createEvent', async () => {
const spy = jest.spyOn(MockRootAuditorService.prototype, 'log');
const pluginId = 'test-plugin';
const auditor = MockRootAuditorService.create().forPlugin({
plugin: {
getId: () => pluginId,
},
auth: new MockAuthService({
pluginId,
disableDefaultAuthPolicy: false,
}),
httpAuth: new MockHttpAuthService(pluginId, mockCredentials.user()),
});
const auditorEvent = await auditor.createEvent({
eventId: 'test-event',
});
await auditorEvent.fail({ error: new Error('error') as ErrorLike });
expect(spy).toHaveBeenCalledTimes(2);
});
});
@@ -1,200 +0,0 @@
/*
* Copyright 2023 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 {
AuditorEvent,
AuditorEventOptions,
} from '@backstage/backend-defaults/auditor';
import type {
AuditorService,
AuditorServiceCreateEventOptions,
AuditorServiceEvent,
AuthService,
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 { mockServices } from './mockServices';
export class MockAuditorService implements AuditorService {
private readonly impl: MockRootAuditorService;
private readonly auth: AuthService;
private readonly httpAuth: HttpAuthService;
private readonly plugin: PluginMetadataService;
private constructor(
impl: MockRootAuditorService,
deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
},
) {
this.impl = impl;
this.auth = deps.auth;
this.httpAuth = deps.httpAuth;
this.plugin = deps.plugin;
}
static create(
impl: MockRootAuditorService,
deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
},
): AuditorService {
return new MockAuditorService(impl, deps);
}
private async log<TMeta extends JsonObject>(
options: AuditorEventOptions<TMeta>,
): Promise<void> {
const auditEvent = await this.reshapeAuditorEvent(options);
this.impl.log(auditEvent);
}
async createEvent(
options: AuditorServiceCreateEventOptions,
): Promise<AuditorServiceEvent> {
await this.log({ ...options, status: 'initiated' });
return {
success: async params => {
// return undefined if both objects are empty; otherwise, merge the objects
const meta =
Object.keys(options.meta ?? {}).length === 0 &&
Object.keys(params?.meta ?? {}).length === 0
? undefined
: { ...options.meta, ...params?.meta };
await this.log({
...options,
meta,
status: 'succeeded',
});
},
fail: async params => {
// return undefined if both objects are empty; otherwise, merge the objects
const meta =
Object.keys(options.meta ?? {}).length === 0 &&
Object.keys(params.meta ?? {}).length === 0
? undefined
: { ...options.meta, ...params.meta };
await this.log({
...options,
...params,
meta,
status: 'failed',
});
},
};
}
private async getActorId(
request?: Request<any, any, any, any, any>,
): Promise<string | undefined> {
let credentials: BackstageCredentials =
await this.auth.getOwnServiceCredentials();
if (request) {
try {
credentials = await this.httpAuth.credentials(request);
} catch (error) {
throw new ForwardedError('Could not resolve credentials', error);
}
}
if (this.auth.isPrincipal(credentials, 'user')) {
return credentials.principal.userEntityRef;
}
if (this.auth.isPrincipal(credentials, 'service')) {
return credentials.principal.subject;
}
return undefined;
}
private async reshapeAuditorEvent<T extends JsonObject>(
options: AuditorEventOptions<T>,
): Promise<AuditorEvent> {
const { eventId, severityLevel = 'low', request, ...rest } = options;
const auditEvent: AuditorEvent = [
`${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,
...rest,
},
];
return auditEvent;
}
}
export class MockRootAuditorService {
private readonly impl: RootLoggerService;
private constructor() {
this.impl = mockServices.rootLogger();
}
static create(): MockRootAuditorService {
return new MockRootAuditorService();
}
async log(auditorEvent: AuditorEvent): Promise<void> {
const [eventId, meta] = auditorEvent;
// change `error` type to a string for logging purposes
let fields: Omit<AuditorEvent[1], 'error'> & { error?: string };
if ('error' in meta) {
fields = { ...meta, error: meta.error.toString() };
} else {
fields = meta;
}
this.impl.info(eventId, fields);
}
forPlugin(deps: {
auth: AuthService;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
}): AuditorService {
const impl = new MockRootAuditorService();
return MockAuditorService.create(impl, deps);
}
}
@@ -28,7 +28,6 @@ import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLif
import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler';
import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader';
import {
AuditorService,
AuthService,
BackstageCredentials,
BackstageUserInfo,
@@ -50,12 +49,12 @@ import {
} from '@backstage/plugin-events-node';
import { JsonObject } from '@backstage/types';
import { Knex } from 'knex';
import { MockRootAuditorService } from './MockAuditorService';
import { MockAuthService } from './MockAuthService';
import { MockHttpAuthService } from './MockHttpAuthService';
import { MockRootLoggerService } from './MockRootLoggerService';
import { MockUserInfoService } from './MockUserInfoService';
import { mockCredentials } from './mockCredentials';
import { auditorServiceFactory } from '@backstage/backend-defaults/auditor';
/** @internal */
function createLoggerMock() {
@@ -223,56 +222,8 @@ export namespace mockServices {
}));
}
/**
* Creates a mock implementation of the `AuditorService`.
*/
export function auditor(options?: { pluginId?: string }): AuditorService {
const pluginId = options?.pluginId ?? 'test';
const mockAuth = new MockAuthService({
pluginId,
disableDefaultAuthPolicy: false,
});
const mockHttpAuth = new MockHttpAuthService(
pluginId,
mockCredentials.user(),
);
const mockPlugin = {
getId: () => pluginId,
};
const auditorMock = MockRootAuditorService.create();
return auditorMock.forPlugin({
auth: mockAuth,
httpAuth: mockHttpAuth,
plugin: mockPlugin,
});
}
export namespace auditor {
export const factory = () =>
createServiceFactory({
service: coreServices.auditor,
deps: {
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
plugin: coreServices.pluginMetadata,
},
createRootContext() {
return MockRootAuditorService.create();
},
factory(
{ auth: mockAuth, httpAuth: mockHttpAuth, plugin: mockPlugin },
rootAuditor,
) {
return rootAuditor.forPlugin({
auth: mockAuth,
httpAuth: mockHttpAuth,
plugin: mockPlugin,
});
},
});
export const factory = () => auditorServiceFactory;
export const mock = simpleMock(coreServices.auditor, () => ({
createEvent: jest.fn(async _ => {