diff --git a/.changeset/olive-boxes-hide.md b/.changeset/olive-boxes-hide.md index beee360eab..878dc8394a 100644 --- a/.changeset/olive-boxes-hide.md +++ b/.changeset/olive-boxes-hide.md @@ -7,7 +7,7 @@ '@backstage/plugin-scaffolder-node': minor --- -feat: add auditor to coreServices +feat: add auditor to `coreServices` This change introduces a new `auditor` service to the `coreServices` in Backstage. The auditor service enables plugins to emit audit events for security-relevant actions. diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 8d36d55ef2..baf0bde2e6 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -635,13 +635,11 @@ export interface Config { auditor?: { /** * Configuration for the auditing to the console - * @visibility frontend */ console: { /** * Enables auditing to console * @default true - * @visibility frontend */ enabled: boolean; }; diff --git a/packages/backend-plugin-api/src/services/definitions/AuditorService.ts b/packages/backend-plugin-api/src/services/definitions/AuditorService.ts index 3bf47e6d61..e678196847 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuditorService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuditorService.ts @@ -18,8 +18,6 @@ import type { JsonObject } from '@backstage/types'; import type { Request } from 'express'; /** - * TODO: Rigorously define each level - * * low (default): normal usage * medium: accessing write endpoints * high: non-root permission changes @@ -31,12 +29,20 @@ export type AuditorEventSeverityLevel = 'low' | 'medium' | 'high' | 'critical'; /** @public */ export type AuditorCreateEvent = (options: { /** - * Use kebab-case to name audit events (e.g., "user-login", "file-download"). + * Use kebab-case to name audit events (e.g., "user-login", "file-download", "fetch"). Represents a logical group of similar events or operations. For example, "fetch" could be used as an eventId encompassing various fetch methods like "by-id" or "by-location". * * The `pluginId` already provides plugin/module context, so avoid redundant prefixes in the `eventId`. */ eventId: string; + /** + * Use kebab-case to name sub-events (e.g., "by-id", "by-user"). + * + * (Optional) The ID for a sub-event or related action within the main event. This allows further categorization of events within a logical group. For example, if the `eventId` is "fetch", the `subEventId` could be "by-id" or "by-location" to specify the method used for fetching. + */ + subEventId?: string; + + /** (Optional) The severity level for the audit event. */ severityLevel?: AuditorEventSeverityLevel; /** (Optional) The associated HTTP request, if applicable. */ diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md index 0ca779f782..6a1972ef17 100644 --- a/plugins/catalog-backend/README.md +++ b/plugins/catalog-backend/README.md @@ -89,22 +89,42 @@ some example entities. ## Audit Events -- **`ancestry-fetch`**: Tracks `GET` requests to the `/entities/by-name/:kind/:namespace/:name/ancestry` endpoint which return the ancestry of an entity. -- **`batch-fetch`**: Tracks `POST` requests to the `/entities/by-refs` endpoint which return a batch of entities. -- **`delete`**: Tracks `DELETE` requests to the `/entities/by-uid/:uid` endpoint which delete an entity. Note: this will not be a permanent deletion and the entity will be restored if the parent location is still present in the catalog. -- **`facet-fetch`**: Tracks `GET` requests to the `/entity-facets` endpoint which return the facets of an entity. -- **`fetch`**: Tracks `GET` requests to the `/entities` endpoint which returns a list of entities. -- **`fetch-by-name`**: Tracks `GET` requests to the `/entities/by-name/:kind/:namespace/:name` endpoint which return an entity matching the specified entity ref. -- **`fetch-by-uid`**: Tracks `GET` requests to the `/entities/by-uid/:uid` endpoint which return an entity matching the specified entity uid. -- **`fetch-by-query`**: Tracks `GET` requests to the `/entities/by-query` endpoint which returns a list of entities matching the specified query. -- **`refresh`**: Tracks `POST` requests to the `/entities/refresh` endpoint which schedules the specified entity to be refreshed. -- **`validate`**: Tracks `POST` requests to the `/entities/validate` endpoint which validates the specified entity. -- **`location-analyze`**: Tracks `POST` requests to the `/locations/analyze` endpoint which analyzes the specified location. -- **`location-create`**: Tracks `POST` requests to the `/locations` endpoint which creates a location. -- **`location-delete`**: Tracks `DELETE` requests to the `/locations/:id` endpoint which deletes a location as well as all child entities associated with it. -- **`location-fetch`**: Tracks `GET` requests to the `/locations` endpoint which returns a list of locations. -- **`location-fetch-by-entity-ref`**: Tracks `GET` requests to the `/locations/by-entity` endpoint which returns a list of locations associated with the specified entity ref. -- **`location-fetch-by-id`**: Tracks `GET` requests to the `/locations/:id` endpoint which returns a location matching the specified location id. +The Catalog backend emits audit events for various operations. Events are grouped logically by `eventId`, with `subEventId` providing further distinction within an operation group. + +**Entity Events:** + +- **`entity-fetch`**: Retrieves entities. + + - **`all`**: Fetching all entities. (GET `/entities`) + - **`by-id`**: Fetching a single entity using its UID. (GET `/entities/by-uid/:uid`) + - **`by-name`**: Fetching a single entity using its kind, namespace, and name. (GET `/entities/by-name/:kind/:namespace/:name`) + - **`by-query`**: Fetching multiple entities using a filter query. (GET `/entities/by-query`) + - **`by-refs`**: Fetching a batch of entities by their entity refs. (POST `/entities/by-refs`) + - **`ancestry`**: Fetching the ancestry of an entity. (GET `/entities/by-name/:kind/:namespace/:name/ancestry`) + +- **`entity-mutate`**: Modifies entities. + + - **`delete`**: Deleting a single entity. Note: this will not be a permanent deletion and the entity will be restored if the parent location is still present in the catalog. (DELETE `/entities/by-uid/:uid`) + - **`refresh`**: Scheduling an entity refresh. (POST `/entities/refresh`) + +- **`entity-validate`**: Validates an entity. (POST `/entities/validate`) + +- **`entity-facets`**: Retrieves entity facets. (GET `/entity-facets`) + +**Location Events:** + +- **`location-fetch`**: Retrieves locations. + + - **`all`**: Fetching all locations. (GET `/locations`) + - **`by-id`**: Fetching a single location by ID. (GET `/locations/:id`) + - **`by-entity`**: Fetching locations associated with an entity ref. (GET `/locations/by-entity`) + +- **`location-mutate`**: Modifies locations. + + - **`create`**: Creating a new location. (POST `/locations`) + - **`delete`**: Deleting a location and its associated entities. (DELETE `/locations/:id`) + +- **`location-analyze`**: Analyzes a location. (POST `/locations/analyze`) ## Links diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 2842a806e2..8c0de3d221 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -128,7 +128,9 @@ export async function createRouter( const { authorizationToken, ...restBody } = req.body; const auditorEvent = await auditor?.createEvent({ - eventId: 'refresh', + eventId: 'entity-mutate', + subEventId: 'refresh', + severityLevel: 'medium', meta: { entityRef: restBody.entityRef, }, @@ -162,7 +164,8 @@ export async function createRouter( router .get('/entities', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'CatalogEntityFetch', + eventId: 'entity-fetch', + subEventId: 'all', request: req, }); @@ -256,7 +259,8 @@ export async function createRouter( }) .get('/entities/by-query', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'fetch-by-query', + eventId: 'entity-fetch', + subEventId: 'by-query', request: req, }); @@ -306,7 +310,8 @@ export async function createRouter( const { uid } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'fetch-by-uid', + eventId: 'entity-fetch', + subEventId: 'by-uid', request: req, meta: { uid: uid, @@ -337,7 +342,8 @@ export async function createRouter( const { uid } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'delete', + eventId: 'entity-mutate', + subEventId: 'delete', severityLevel: 'medium', request: req, meta: { @@ -365,7 +371,8 @@ export async function createRouter( const entityRef = stringifyEntityRef({ kind, namespace, name }); const auditorEvent = await auditor?.createEvent({ - eventId: 'fetch-by-name', + eventId: 'entity-fetch', + subEventId: 'by-name', request: req, meta: { entityRef: entityRef, @@ -399,7 +406,8 @@ export async function createRouter( const entityRef = stringifyEntityRef({ kind, namespace, name }); const auditorEvent = await auditor?.createEvent({ - eventId: 'ancestry-fetch', + eventId: 'entity-fetch', + subEventId: 'ancestry', request: req, meta: { entityRef: entityRef, @@ -434,7 +442,8 @@ export async function createRouter( ) .post('/entities/by-refs', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'batch-fetch', + eventId: 'entity-fetch', + subEventId: 'by-refs', request: req, }); @@ -470,7 +479,7 @@ export async function createRouter( }) .get('/entity-facets', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'facet-fetch', + eventId: 'entity-facets', request: req, }); @@ -500,7 +509,8 @@ export async function createRouter( const dryRun = yn(req.query.dryRun, { default: false }); const auditorEvent = await auditor?.createEvent({ - eventId: 'location-create', + eventId: 'location-mutate', + subEventId: 'create', severityLevel: dryRun ? 'low' : 'medium', request: req, meta: { @@ -545,6 +555,7 @@ export async function createRouter( .get('/locations', async (req, res) => { const auditorEvent = await auditor?.createEvent({ eventId: 'location-fetch', + subEventId: 'all', request: req, }); @@ -568,7 +579,8 @@ export async function createRouter( const { id } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'location-fetch-by-id', + eventId: 'location-fetch', + subEventId: 'by-id', request: req, meta: { id: id, @@ -598,7 +610,8 @@ export async function createRouter( const { id } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'location-delete', + eventId: 'location-mutate', + subEventId: 'delete', severityLevel: 'medium', request: req, meta: { @@ -628,7 +641,8 @@ export async function createRouter( const locationRef = `${kind}:${namespace}/${name}`; const auditorEvent = await auditor?.createEvent({ - eventId: 'location-fetch-by-entity-ref', + eventId: 'location-fetch', + subEventId: 'by-entity', request: req, meta: { locationRef: locationRef, @@ -713,7 +727,7 @@ export async function createRouter( if (orchestrator) { router.post('/validate-entity', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'validate', + eventId: 'entity-validate', request: req, }); diff --git a/plugins/scaffolder-backend/README.md b/plugins/scaffolder-backend/README.md index e38296bd75..4a4281ec9c 100644 --- a/plugins/scaffolder-backend/README.md +++ b/plugins/scaffolder-backend/README.md @@ -66,15 +66,26 @@ catalog locations from the [create-app template](https://github.com/backstage/ba ## Audit Events -- **`parameter-schema-fetch`**: Tracks`GET` requests to the `/v2/templates/:namespace/:kind/:name/parameter-schema` endpoint which return template parameter schemas -- **`installed-actions-fetch`**: Tracks`GET` requests to the `/v2/actions` endpoint which grabs the list of installed actions -- **`task-creation`**: Tracks`POST` requests to the `/v2/tasks` endpoint which creates tasks that the scaffolder executes -- **`task-list-fetch`**: Tracks`GET` requests to the `/v2/tasks` endpoint which fetches details of all tasks in the scaffolder. -- **`task-fetch`**: Tracks`GET` requests to the `/v2/tasks/:taskId` endpoint which fetches details of a specified task `:taskId` -- **`task-cancellation`**: Tracks`POST` requests to the `/v2/tasks/:taskId/cancel` endpoint which cancels a running task -- **`task-retry`**: Tracks`POST` requests to the `/v2/tasks/:taskId/retry` endpoint which retries a failed task -- **`task-stream`**: Tracks`GET` requests to the `/v2/tasks/:taskId/eventstream` endpoint which returns an event stream of the task logs of task `:taskId` -- **`task-event-fetch`**: Tracks`GET` requests to the `/v2/tasks/:taskId/events` endpoint which returns a snapshot of the task logs of task `:taskId` -- **`task-dry-run`**: Tracks`POST` requests to the `/v2/dry-run` endpoint which creates a dry-run task. All audit logs for events associated with dry runs have the `meta.isDryLog` flag set to `true`. -- **`stale-task-cancellation`**: Tracks automated cancellation of stale tasks -- **`task-execution`**: Tracks the`initiation` and `completion` of a real scaffolder task execution (will not occur during dry runs) +The Scaffolder backend emits audit events for various operations. Events are grouped logically by `eventId`, with `subEventId` providing further distinction when needed. + +**Template Events:** + +- **`template-parameter-schema`**: Retrieves template parameter schemas. (GET `/v2/templates/:namespace/:kind/:name/parameter-schema`) + +**Action Events:** + +- **`action-fetch`**: Retrieves installed actions. (GET `/v2/actions`) + +**Task Events:** + +- **`task`**: Operations related to Scaffolder tasks. + - **`create`**: Creates a new task. (POST `/v2/tasks`) + - **`list`**: Fetches details of all tasks. (GET `/v2/tasks`) + - **`get`**: Fetches details of a specific task. (GET `/v2/tasks/:taskId`) + - **`cancel`**: Cancels a running task. (POST `/v2/tasks/:taskId/cancel`) + - **`retry`**: Retries a failed task. (POST `/v2/tasks/:taskId/retry`) + - **`stream`**: Retrieves a stream of task logs. (GET `/v2/tasks/:taskId/eventstream`) + - **`events`**: Retrieves a snapshot of task logs. (GET `/v2/tasks/:taskId/events`) + - **`dry-run`**: Creates a dry-run task. (POST `/v2/dry-run`) All audit logs for events associated with dry runs have the `meta.isDryLog` flag set to `true`. + - **`stale-cancel`**: Automated cancellation of stale tasks. + - **`execute`**: Tracks the initiation and completion of a real scaffolder task execution. This event will not occur during dry runs. diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index a772f6e114..56a395106a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -40,9 +40,9 @@ import { } from '@backstage/types'; import { Logger } from 'winston'; import ObservableImpl from 'zen-observable'; +import { DefaultWorkspaceService, WorkspaceService } from './WorkspaceService'; import { readDuration } from './helper'; import { InternalTaskSecrets, TaskStore } from './types'; -import { DefaultWorkspaceService, WorkspaceService } from './WorkspaceService'; type TaskState = { checkpoints: { @@ -206,7 +206,8 @@ export class TaskManager implements TaskContext { } const auditorEvent = await this.auditor?.createEvent({ - eventId: 'task-execution', + eventId: 'task', + subEventId: 'execution', severityLevel: 'medium', meta: { taskId: this.task.taskId, @@ -472,7 +473,8 @@ export class StorageTaskBroker implements TaskBroker { await Promise.all( tasks.map(async task => { const auditorEvent = await this.auditor?.createEvent({ - eventId: 'stale-task-cancellation', + eventId: 'task', + subEventId: 'stale-cancel', severityLevel: 'medium', meta: { taskId: task.taskId, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 089e58360c..944fa9b6ac 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -175,7 +175,8 @@ export class TaskWorker { async runOneTask(task: TaskContext) { await this.auditor?.createEvent({ - eventId: 'task-execution', + eventId: 'task', + subEventId: 'execution', severityLevel: 'medium', meta: { taskId: task.taskId, diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 270d532090..66ad2a7350 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -462,7 +462,7 @@ export async function createRouter( const requestedTemplateRef = `${req.params.kind}:${req.params.namespace}/${req.params.name}`; const auditorEvent = await auditor?.createEvent({ - eventId: 'parameter-schema-fetch', + eventId: 'template-parameter-schema', request: req, meta: { templateRef: requestedTemplateRef }, }); @@ -512,7 +512,7 @@ export async function createRouter( ) .get('/v2/actions', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'installed-actions-fetch', + eventId: 'action-fetch', request: req, }); @@ -541,7 +541,8 @@ export async function createRouter( }); const auditorEvent = await auditor?.createEvent({ - eventId: 'installed-actions-fetch', + eventId: 'task', + subEventId: 'create', severityLevel: 'medium', request: req, meta: { @@ -646,7 +647,8 @@ export async function createRouter( }) .get('/v2/tasks', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'task-list-fetch', + eventId: 'task', + subEventId: 'list', request: req, }); @@ -708,7 +710,8 @@ export async function createRouter( const { taskId } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'task-fetch', + eventId: 'task', + subEventId: 'get', request: req, meta: { taskId: taskId, @@ -742,7 +745,8 @@ export async function createRouter( const { taskId } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'task-cancellation', + eventId: 'task', + subEventId: 'cancel', severityLevel: 'medium', request: req, meta: { taskId: taskId }, @@ -771,7 +775,8 @@ export async function createRouter( const { taskId } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'task-retry', + eventId: 'task', + subEventId: 'retry', severityLevel: 'medium', request: req, meta: { taskId: taskId }, @@ -799,7 +804,8 @@ export async function createRouter( const { taskId } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'task-stream', + eventId: 'task', + subEventId: 'stream', request: req, meta: { taskId: taskId }, }); @@ -868,7 +874,8 @@ export async function createRouter( const { taskId } = req.params; const auditorEvent = await auditor?.createEvent({ - eventId: 'task-event-fetch', + eventId: 'task', + subEventId: 'events', request: req, meta: { taskId: taskId, @@ -919,7 +926,8 @@ export async function createRouter( }) .post('/v2/dry-run', async (req, res) => { const auditorEvent = await auditor?.createEvent({ - eventId: 'task-dry-run', + eventId: 'task', + subEventId: 'dry-run', request: req, });