From e8736ea2e8bca547c4c7ca92c331a2d877e9007b Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 10 Mar 2026 11:47:40 +0100 Subject: [PATCH] feat(scaffolder): implementing secrets schema for scaffolder templates (#32320) * feat: implementing secrets schema for scaffolder templates Signed-off-by: benjdlambert * chore: fix and regenerate openapi Signed-off-by: benjdlambert Signed-off-by: benjdlambert * chore: fix review feedback Signed-off-by: benjdlambert * fix: address code review feedback for secrets validation - Extract validateSecrets helper to deduplicate validation logic - Add auditorEvent.fail() call on secrets validation failure - Sanitize instance field in error responses to prevent secret leakage - Add retry endpoint test coverage for secrets validation - Split changeset into per-package entries Signed-off-by: benjdlambert * refactor: nest secrets schema under secrets.schema Move the JSON Schema definition from spec.secrets to spec.secrets.schema to leave room for future extensions like secret sources. Signed-off-by: benjdlambert * chore: update API reports Signed-off-by: benjdlambert * chore: use InputError for secrets validation audit event Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .changeset/template-secrets-schema.md | 5 + .changeset/template-secrets-validation.md | 5 + .../software-templates/writing-templates.md | 51 +++ .../src/schema/openapi.yaml | 13 + .../openapi/generated/apis/Api.server.ts | 2 +- .../src/schema/openapi/generated/router.ts | 19 ++ .../src/service/router.test.ts | 298 ++++++++++++++++-- .../scaffolder-backend/src/service/router.ts | 88 +++++- plugins/scaffolder-common/report.api.md | 3 + .../src/Template.v1beta3.schema.json | 10 + .../src/TemplateEntityV1beta3.ts | 7 + 11 files changed, 473 insertions(+), 28 deletions(-) create mode 100644 .changeset/template-secrets-schema.md create mode 100644 .changeset/template-secrets-validation.md diff --git a/.changeset/template-secrets-schema.md b/.changeset/template-secrets-schema.md new file mode 100644 index 0000000000..5f6b0c4c9e --- /dev/null +++ b/.changeset/template-secrets-schema.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': minor +--- + +Added an optional `secrets` field to `TemplateEntityV1beta3` for configuring secrets validation. The schema for validating secrets is defined under `secrets.schema` as a JSON Schema object. diff --git a/.changeset/template-secrets-validation.md b/.changeset/template-secrets-validation.md new file mode 100644 index 0000000000..2b484ea758 --- /dev/null +++ b/.changeset/template-secrets-validation.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added secrets schema validation for task creation, retry, and dry-run endpoints. When a template defines `spec.secrets.schema`, the API validates provided secrets against the schema and returns a `400` error if validation fails. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 5d9fcba603..64c1acc67d 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -325,6 +325,57 @@ spec: token: ${{ each.value.token }} ``` +### Defining a Secrets Schema + +You can define a JSON Schema for secrets that will be validated when a task is created. This is useful when secrets are passed programmatically (e.g., via CI/CD pipelines or API calls) rather than through the UI form. The schema ensures that required secrets are provided before task execution begins. + +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: publish-to-npm + title: Publish to NPM +spec: + owner: backstage/techdocs-core + type: service + + # Define required secrets with a JSON Schema + secrets: + schema: + required: + - NPM_TOKEN + properties: + NPM_TOKEN: + type: string + description: NPM authentication token for publishing + + parameters: + - title: Package Details + properties: + packageName: + type: string + title: Package Name + + steps: + - id: publish + action: npm:publish + input: + packageName: ${{ parameters.packageName }} + token: ${{ secrets.NPM_TOKEN }} +``` + +When a task is created without the required secrets, the API returns a `400` error with a descriptive message: + +```json +{ + "errors": [ + { + "message": "secrets.NPM_TOKEN is required" + } + ] +} +``` + ### Custom step layouts If you find that the default layout of the form used in a particular step does not meet your needs then you can supply your own [custom step layout](./writing-custom-step-layouts.md). diff --git a/plugins/scaffolder-backend/src/schema/openapi.yaml b/plugins/scaffolder-backend/src/schema/openapi.yaml index 72fa89532c..2baf9fbba3 100644 --- a/plugins/scaffolder-backend/src/schema/openapi.yaml +++ b/plugins/scaffolder-backend/src/schema/openapi.yaml @@ -686,6 +686,19 @@ paths: type: string required: - id + '400': + description: Validation errors. + content: + application/json: + schema: + type: object + properties: + errors: + type: array + items: + $ref: '#/components/schemas/ValidationError' + required: + - errors security: - {} - JWT: [] diff --git a/plugins/scaffolder-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/scaffolder-backend/src/schema/openapi/generated/apis/Api.server.ts index 6263921887..af824321b2 100644 --- a/plugins/scaffolder-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/scaffolder-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -116,7 +116,7 @@ export type Retry = { taskId: string; }; body: RetryRequest; - response: Scaffold201Response; + response: Scaffold201Response | Scaffold400Response; }; /** * @public diff --git a/plugins/scaffolder-backend/src/schema/openapi/generated/router.ts b/plugins/scaffolder-backend/src/schema/openapi/generated/router.ts index 1cb3ba755f..1f12056a69 100644 --- a/plugins/scaffolder-backend/src/schema/openapi/generated/router.ts +++ b/plugins/scaffolder-backend/src/schema/openapi/generated/router.ts @@ -1023,6 +1023,25 @@ export const spec = { }, }, }, + '400': { + description: 'Validation errors.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + errors: { + type: 'array', + items: { + $ref: '#/components/schemas/ValidationError', + }, + }, + }, + required: ['errors'], + }, + }, + }, + }, }, security: [ {}, diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index ab1f5cd583..9da276c67b 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -20,11 +20,7 @@ import express from 'express'; import request from 'supertest'; import ObservableImpl from 'zen-observable'; -import { - parseEntityRef, - stringifyEntityRef, - UserEntity, -} from '@backstage/catalog-model'; +import { stringifyEntityRef, UserEntity } from '@backstage/catalog-model'; import { createTemplateAction, TaskBroker, @@ -83,9 +79,9 @@ function createDatabase(): DatabaseService { const config = new ConfigReader({}); -// todo: this needs to return a new object every time as there seems to -// be some mutation in the tests. -const generateMockTemplate = () => ({ +// Returns a new mock template object each time to avoid mutation issues. +// Accepts optional spec overrides that are merged with the base spec. +const generateMockTemplate = (specOverrides?: Record) => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', kind: 'Template', metadata: { @@ -146,6 +142,7 @@ const generateMockTemplate = () => ({ }, }, ], + ...specOverrides, }, }); @@ -153,7 +150,7 @@ const mockUser: UserEntity = { apiVersion: 'backstage.io/v1alpha1', kind: 'User', metadata: { - name: 'guest', + name: 'mock', annotations: { 'google.com/email': 'bobby@tables.com', }, @@ -175,6 +172,7 @@ const createTestRouter = async ( | CreatedTemplateGlobal[]; autocompleteHandlers?: Record; actionsRegistry?: ActionsService; + entities?: any[]; } = {}, ) => { const logger = mockServices.logger.mock({ @@ -196,26 +194,13 @@ const createTestRouter = async ( jest.spyOn(taskBroker, 'vacuumTasks'); jest.spyOn(taskBroker, 'event$'); - const catalog = catalogServiceMock.mock(); + const entities = overrides.entities ?? [generateMockTemplate(), mockUser]; + const catalog = catalogServiceMock({ entities }); const permissions = mockServices.permissions(); const auth = mockServices.auth(); const httpAuth = mockServices.httpAuth(); const events = mockServices.events(); - catalog.getEntityByRef.mockImplementation(async ref => { - const { kind } = parseEntityRef(ref); - - if (kind.toLocaleLowerCase() === 'template') { - return generateMockTemplate(); - } - - if (kind.toLocaleLowerCase() === 'user') { - return mockUser; - } - - throw new Error(`no mock found for kind: ${kind}`); - }); - const router = await createRouter({ logger, config: new ConfigReader({}), @@ -651,6 +636,132 @@ describe('scaffolder router', () => { expect(response.status).toEqual(400); }); + it('rejects when required secrets are missing', async () => { + const templateWithSecrets = generateMockTemplate({ + secrets: { + schema: { + type: 'object', + required: ['NPM_TOKEN'], + properties: { + NPM_TOKEN: { type: 'string' }, + }, + }, + }, + }); + + const { router } = await createTestRouter({ + entities: [templateWithSecrets, mockUser], + }); + + const response = await request(router) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + // No secrets provided + }); + + expect(response.status).toEqual(400); + expect(response.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: 'secrets', + message: 'secrets.NPM_TOKEN is required', + }), + ]), + ); + }); + + it('rejects when required secrets are missing without explicit type', async () => { + const templateWithSecrets = generateMockTemplate({ + secrets: { + schema: { + // No explicit type: 'object' - should still work + required: ['NPM_TOKEN'], + properties: { + NPM_TOKEN: { type: 'string' }, + }, + }, + }, + }); + + const { router } = await createTestRouter({ + entities: [templateWithSecrets, mockUser], + }); + + const response = await request(router) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + // No secrets provided + }); + + expect(response.status).toEqual(400); + expect(response.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: 'secrets', + message: 'secrets.NPM_TOKEN is required', + }), + ]), + ); + }); + + it('accepts valid secrets matching the schema', async () => { + const templateWithSecrets = generateMockTemplate({ + secrets: { + schema: { + type: 'object', + required: ['NPM_TOKEN'], + properties: { + NPM_TOKEN: { type: 'string' }, + }, + }, + }, + }); + + const { router, taskBroker } = await createTestRouter({ + entities: [templateWithSecrets, mockUser], + }); + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + + broker.mockResolvedValue({ + taskId: 'a-random-id', + }); + + const response = await request(router) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + secrets: { + NPM_TOKEN: 'my-secret-token', + }, + }); + + expect(response.status).toEqual(201); + expect(response.body.id).toBe('a-random-id'); + }); + it('return the template id', async () => { const { router, taskBroker } = await createTestRouter(); const broker = taskBroker.dispatch as jest.Mocked['dispatch']; @@ -1087,6 +1198,97 @@ describe('scaffolder router', () => { }); }); + describe('POST /v2/tasks/:taskId/retry', () => { + it('rejects when required secrets are missing', async () => { + const templateWithSecrets = generateMockTemplate({ + secrets: { + schema: { + type: 'object', + required: ['NPM_TOKEN'], + properties: { + NPM_TOKEN: { type: 'string' }, + }, + }, + }, + }); + + const { router, taskBroker } = await createTestRouter({ + entities: [templateWithSecrets, mockUser], + }); + + (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ + id: 'a-random-id', + spec: { + templateInfo: { + entityRef: 'template:default/create-react-app-template', + baseUrl: 'https://example.com', + entity: { metadata: templateWithSecrets.metadata }, + }, + } as any, + status: 'failed', + createdAt: '', + createdBy: 'user:default/mock', + }); + + const response = await request(router) + .post('/v2/tasks/a-random-id/retry') + .send({}); + + expect(response.status).toEqual(400); + expect(response.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: 'secrets', + message: 'secrets.NPM_TOKEN is required', + }), + ]), + ); + }); + + it('accepts valid secrets on retry', async () => { + const templateWithSecrets = generateMockTemplate({ + secrets: { + schema: { + type: 'object', + required: ['NPM_TOKEN'], + properties: { + NPM_TOKEN: { type: 'string' }, + }, + }, + }, + }); + + const { router, taskBroker } = await createTestRouter({ + entities: [templateWithSecrets, mockUser], + }); + + (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ + id: 'a-random-id', + spec: { + templateInfo: { + entityRef: 'template:default/create-react-app-template', + baseUrl: 'https://example.com', + entity: { metadata: templateWithSecrets.metadata }, + }, + } as any, + status: 'failed', + createdAt: '', + createdBy: 'user:default/mock', + }); + + const response = await request(router) + .post('/v2/tasks/a-random-id/retry') + .send({ + secrets: { + NPM_TOKEN: 'my-secret-token', + }, + }); + + expect(response.status).toEqual(201); + expect(taskBroker.retry).toHaveBeenCalled(); + }); + }); + describe('GET /v2/tasks/:taskId/eventstream', () => { it('should return log messages', async () => { const { unwrappedRouter: router, taskBroker } = await createTestRouter(); @@ -1407,6 +1609,9 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ const mockToken = mockCredentials.user.token(); const mockTemplate = generateMockTemplate(); + // Spy on the catalog method to verify it's called correctly + const getEntityByRefSpy = jest.spyOn(catalog, 'getEntityByRef'); + await request(router) .post('/v2/dry-run') .set('Authorization', `Bearer ${mockToken}`) @@ -1419,13 +1624,54 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ directoryContents: [], }); - expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); + expect(getEntityByRefSpy).toHaveBeenCalledTimes(1); - expect(catalog.getEntityByRef).toHaveBeenCalledWith( + expect(getEntityByRefSpy).toHaveBeenCalledWith( 'user:default/mock', expect.anything(), ); }); + + it('rejects when required secrets are missing', async () => { + const { router } = await createTestRouter(); + const mockToken = mockCredentials.user.token(); + + const templateWithSecrets = generateMockTemplate({ + secrets: { + schema: { + type: 'object', + required: ['NPM_TOKEN'], + properties: { + NPM_TOKEN: { type: 'string' }, + }, + }, + }, + }); + + const response = await request(router) + .post('/v2/dry-run') + .set('Authorization', `Bearer ${mockToken}`) + .send({ + template: templateWithSecrets, + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + directoryContents: [], + // No secrets provided + }); + + expect(response.status).toEqual(400); + expect(response.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: 'secrets', + message: 'secrets.NPM_TOKEN is required', + }), + ]), + ); + }); + it('allows payloads up to 10MB', async () => { const { unwrappedRouter } = await createTestRouter(); const mockToken = mockCredentials.user.token(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ef2a39eb54..0c9daf53c2 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -16,6 +16,7 @@ import { AuditorService, + AuditorServiceEvent, AuthService, BackstageCredentials, DatabaseService, @@ -26,7 +27,7 @@ import { resolveSafeChildPath, SchedulerService, } from '@backstage/backend-plugin-api'; -import { validate } from 'jsonschema'; +import { validate, ValidatorResult } from 'jsonschema'; import { CompoundEntityRef, Entity, @@ -181,6 +182,49 @@ const readDuration = ( return defaultValue; }; +function formatSecretsValidationErrors(result: ValidatorResult) { + return result.errors.map(err => { + const property = err.property.replace(/^instance/, 'secrets'); + const secretName = err.argument; + const message = + err.name === 'required' + ? `secrets.${secretName} is required` + : `${property} ${err.message}`; + return { + ...err, + property, + message, + instance: {}, + }; + }); +} + +async function validateSecrets(options: { + template: TemplateEntityV1beta3; + secrets: Record; + res: express.Response; + auditorEvent?: AuditorServiceEvent; +}): Promise { + const { template, secrets, res, auditorEvent } = options; + if (!template.spec.secrets?.schema) { + return true; + } + + const result = validate(secrets, template.spec.secrets.schema); + if (result.valid) { + return true; + } + + await auditorEvent?.fail({ + error: new InputError('Secrets validation failed'), + }); + + res.status(400).json({ + errors: formatSecretsValidationErrors(result), + }); + return false; +} + /** * A method to create a router for the scaffolder backend plugin. */ @@ -527,6 +571,16 @@ export async function createRouter( } } + const secretsValid = await validateSecrets({ + template, + secrets: req.body.secrets ?? {}, + res, + auditorEvent, + }); + if (!secretsValid) { + return; + } + const baseUrl = getEntityBaseUrl(template); const taskSpec: TaskSpec = { @@ -747,6 +801,28 @@ export async function createRouter( isTaskAuthorized, }); + // Validate secrets against template schema if defined + if (task.spec.templateInfo?.entityRef) { + const templateEntityRef = parseEntityRef( + task.spec.templateInfo.entityRef, + { defaultKind: 'template' }, + ); + const template = await authorizeTemplate( + templateEntityRef, + credentials, + ); + + const secretsValid = await validateSecrets({ + template, + secrets: req.body.secrets ?? {}, + res, + auditorEvent, + }); + if (!secretsValid) { + return; + } + } + await auditorEvent?.success(); const { token } = await auth.getPluginRequestToken({ @@ -975,6 +1051,16 @@ export async function createRouter( } } + const secretsValid = await validateSecrets({ + template, + secrets: body.secrets ?? {}, + res, + auditorEvent, + }); + if (!secretsValid) { + return; + } + const steps = template.spec.steps.map((step, index) => ({ ...step, id: step.id ?? `step-${index + 1}`, diff --git a/plugins/scaffolder-common/report.api.md b/plugins/scaffolder-common/report.api.md index c4b17c1723..5b82272955 100644 --- a/plugins/scaffolder-common/report.api.md +++ b/plugins/scaffolder-common/report.api.md @@ -418,6 +418,9 @@ export interface TemplateEntityV1beta3 extends Entity { input?: JsonObject; }[]; parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[]; + secrets?: { + schema?: JsonObject; + }; steps: Array; output?: { [name: string]: string; diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index a9f846068c..5eb61a0f6d 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -146,6 +146,16 @@ } ] }, + "secrets": { + "type": "object", + "description": "Configuration for secrets that are passed during task creation.", + "properties": { + "schema": { + "type": "object", + "description": "A JSONSchema for validating secrets passed during task creation." + } + } + }, "presentation": { "type": "object", "description": "A way to redefine the presentation of the scaffolder.", diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index 1851b7e70f..d48049946c 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -67,6 +67,13 @@ export interface TemplateEntityV1beta3 extends Entity { * variables passed from the user into each action in the template. */ parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[]; + /** + * Configuration for secrets that are passed during task creation. + * The schema field contains a JSONSchema used to validate secrets. + */ + secrets?: { + schema?: JsonObject; + }; /** * A list of steps to be executed in sequence which are defined by the template. These steps are a list of the underlying * javascript action and some optional input parameters that may or may not have been collected from the end user.