feat(scaffolder): implementing secrets schema for scaffolder templates (#32320)

* feat: implementing secrets schema for scaffolder templates

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: fix and regenerate openapi
Signed-off-by: benjdlambert <ben@blam.sh>

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: fix review feedback

Signed-off-by: benjdlambert <ben@blam.sh>

* 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 <ben@blam.sh>

* 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 <ben@blam.sh>

* chore: update API reports

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: use InputError for secrets validation audit event

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-03-10 11:47:40 +01:00
committed by GitHub
parent c74b69788e
commit e8736ea2e8
11 changed files with 473 additions and 28 deletions
+5
View File
@@ -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.
@@ -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.
@@ -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).
@@ -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: []
@@ -116,7 +116,7 @@ export type Retry = {
taskId: string;
};
body: RetryRequest;
response: Scaffold201Response;
response: Scaffold201Response | Scaffold400Response;
};
/**
* @public
@@ -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: [
{},
@@ -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<string, unknown>) => ({
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<string, AutocompleteHandler>;
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<TaskBroker>['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<TaskBroker>['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<TaskBroker>['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<TaskBroker>['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();
@@ -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<string, unknown>;
res: express.Response;
auditorEvent?: AuditorServiceEvent;
}): Promise<boolean> {
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}`,
+3
View File
@@ -418,6 +418,9 @@ export interface TemplateEntityV1beta3 extends Entity {
input?: JsonObject;
}[];
parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[];
secrets?: {
schema?: JsonObject;
};
steps: Array<TemplateEntityStepV1beta3>;
output?: {
[name: string]: string;
@@ -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.",
@@ -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.