apply requested changes
Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
@@ -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 _ => {
|
||||
|
||||
Reference in New Issue
Block a user