feat(scaffolder-backend): add defaultEnvironment config to scaffolder

Signed-off-by: Rogerio Angeliski <angeliski@hotmail.com>
This commit is contained in:
Rogerio Angeliski
2025-09-24 18:00:01 -03:00
parent 8bfe82fbc6
commit a4cd4051f2
12 changed files with 450 additions and 6 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/plugin-scaffolder-node': patch
---
Add `defaultEnvironment` config to scaffolder to enable more flexible and custom templates. Now it's possible enable access to default parameters and secrets in templates, improving security and reducing complexity.
+5
View File
@@ -207,6 +207,11 @@ scaffolder:
email: scaffolder@backstage.io
# Use to customize the default commit message when new components are created
defaultCommitMessage: 'Initial commit'
defaultEnvironment:
parameters:
region: eu-west-1
secrets:
environment: ${NODE_ENV}
auth:
experimentalDynamicClientRegistration:
+61
View File
@@ -64,6 +64,67 @@ you will not have any templates available to use. These need to be [added to the
To get up and running and try out some templates quickly, you can or copy the
catalog locations from the [create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs).
## Configuration
### Default Environment
The scaffolder supports a `defaultEnvironment` configuration that provides default parameters and secrets to all templates. This reduces template complexity and improves security by centralizing common values.
```yaml
scaffolder:
defaultEnvironment:
parameters:
region: eu-west-1
organizationName: acme-corp
defaultRegistry: registry.acme-corp.com
secrets:
AWS_ACCESS_KEY: ${AWS_ACCESS_KEY}
GITHUB_TOKEN: ${GITHUB_TOKEN}
DOCKER_REGISTRY_TOKEN: ${DOCKER_REGISTRY_TOKEN}
```
#### Default parameters
Default parameters are accessible via `${{ environment.parameters.* }}` in templates. Default parameters are isolated in their own context to avoid naming conflicts.
```yaml
parameters:
- title: Fill in some steps
required:
- organizationName
properties:
organizationName:
title: organizationName
type: string
description: Unique name of the organization
ui:autofocus: true
ui:options:
rows: 5
steps:
- id: deploy
name: Deploy Application
action: aws:deploy
input:
region: ${{ environment.parameters.region }} # Resolves to defaultEnvironment.parameters.region
organization: ${{ parameters.organizationName }} # Resolves to frontend input value
otherOrganization: ${{ environment.parameters.organizationName }} # Resolves to defaultEnvironment.parameters.organizationName
```
#### Secrets
Default secrets are resolved from environment variables and accessible via `${{ environment.secrets.* }}` in template actions. Secrets are only available during action execution, not in frontend forms.
```yaml
- id: deploy
name: Deploy with credentials
action: aws:deploy
input:
accessKey: ${{ environment.secrets.AWS_ACCESS_KEY }} # Resolves to defaultEnvironment.secrets.AWS_ACCESS_KEY
```
**Security Note:** Secrets are automatically masked in logs and are only available to backend actions, never exposed to the frontend.
## Audit Events
The Scaffolder backend emits audit events for various operations. Events are grouped logically by `eventId`, with `subEventId` providing further distinction when needed.
+21
View File
@@ -108,5 +108,26 @@ export interface Config {
auditor?: {
taskParameterMaxLength?: number;
};
/**
* Default environment variables and secrets available to all templates.
*/
defaultEnvironment?: {
/**
* Default parameters accessible via ${{ environment.parameters.* }} in templates.
*/
parameters?: {
[key: string]: string;
};
/**
* Secret values from environment variables accessible via ${{ environment.secrets.* }} in templates.
* Values should reference environment variables like ${SECRET_NAME}.
* @visibility secret
*/
secrets?: {
[key: string]: string;
};
};
};
}
@@ -0,0 +1,87 @@
/*
* Copyright 2025 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 { ConfigReader } from '@backstage/config';
import { resolveDefaultEnvironment } from './defaultEnvironment';
describe('defaultEnvironment', () => {
describe('resolveDefaultEnvironment', () => {
it('should return empty when no defaultEnvironment config is provided', () => {
const config = new ConfigReader({});
const result = resolveDefaultEnvironment(config);
expect(result).toEqual({
parameters: {},
secrets: {},
});
});
it('should resolve parameters and secrets from config', () => {
const config = new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'us-east-1',
organizationName: 'acme-corp',
version: '1.0.0',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
DATABASE_PASSWORD: 'db-password',
LITERAL_SECRET: 'literal-value',
},
},
},
});
const result = resolveDefaultEnvironment(config);
expect(result).toEqual({
parameters: {
region: 'us-east-1',
organizationName: 'acme-corp',
version: '1.0.0',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
DATABASE_PASSWORD: 'db-password',
LITERAL_SECRET: 'literal-value',
},
});
});
it('should handle only parameters without secrets', () => {
const config = new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'eu-west-1',
},
},
},
});
const result = resolveDefaultEnvironment(config);
expect(result).toEqual({
parameters: {
region: 'eu-west-1',
},
secrets: {},
});
});
});
});
@@ -0,0 +1,66 @@
/*
* Copyright 2025 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 { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
export interface DefaultEnvironmentConfig {
parameters?: Record<string, any>;
secrets?: Record<string, string>;
}
export interface ResolvedDefaultEnvironment {
parameters: JsonObject;
secrets: Record<string, string>;
}
export function resolveDefaultEnvironment(
config: Config,
): ResolvedDefaultEnvironment {
const defaultEnvConfig = config.getOptionalConfig(
'scaffolder.defaultEnvironment',
);
if (!defaultEnvConfig) {
return {
parameters: {},
secrets: {},
};
}
const parameters: JsonObject = {};
const secrets: Record<string, string> = {};
const parametersConfig = defaultEnvConfig.getOptionalConfig('parameters');
if (parametersConfig) {
for (const paramKey of parametersConfig.keys()) {
const paramValue = parametersConfig.getString(paramKey);
parameters[paramKey] = paramValue;
}
}
const secretsConfig = defaultEnvConfig.getOptionalConfig('secrets');
if (secretsConfig) {
for (const secretKey of secretsConfig.keys()) {
const secretValue = secretsConfig.getString(secretKey);
secrets[secretKey] = secretValue;
}
}
return {
parameters,
secrets,
};
}
@@ -20,6 +20,7 @@ import {
LoggerService,
} from '@backstage/backend-plugin-api';
import type { UserEntity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import {
@@ -79,6 +80,7 @@ export type TemplateTesterCreateOptions = {
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionEvaluator;
config?: Config;
};
/**
@@ -105,6 +107,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) {
},
}),
]),
config: options.config,
});
// Extracting contentsPath and dryRunId from the baseUrl
@@ -53,6 +53,16 @@ describe('NunjucksWorkflowRunner', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'us-east-1',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
},
},
},
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
@@ -212,12 +222,26 @@ describe('NunjucksWorkflowRunner', () => {
{ result: AuthorizeResult.ALLOW },
]);
const config = new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'us-east-1',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
},
},
},
});
runner = new NunjucksWorkflowRunner({
actionRegistry,
integrations,
workingDirectory: mockDir.path,
logger,
permissions: mockedPermissionApi,
config,
});
});
@@ -494,6 +518,7 @@ describe('NunjucksWorkflowRunner', () => {
action: 'jest-mock-action',
input: {
foo: '${{parameters.input | lower }}',
region: '${{environment.parameters.region}}',
},
},
],
@@ -505,7 +530,9 @@ describe('NunjucksWorkflowRunner', () => {
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { foo: 'backstage' } }),
expect.objectContaining({
input: { foo: 'backstage', region: 'us-east-1' },
}),
);
});
@@ -777,6 +804,49 @@ describe('NunjucksWorkflowRunner', () => {
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets that are passed in the environment', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.secret);
},
schema: {
input: {
type: 'object',
required: ['secret'],
properties: {
secret: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
secret: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
],
},
{ secret: 'my-secret-value' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact meta fields properly', async () => {
actionRegistry.register({
@@ -1083,7 +1153,9 @@ describe('NunjucksWorkflowRunner', () => {
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ secrets: { foo: 'bar' } }),
expect.objectContaining({
secrets: { foo: 'bar' },
}),
);
});
@@ -1097,6 +1169,7 @@ describe('NunjucksWorkflowRunner', () => {
action: 'jest-mock-action',
input: {
b: '${{ secrets.foo }}',
aws_key: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
],
@@ -1107,7 +1180,41 @@ describe('NunjucksWorkflowRunner', () => {
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { b: 'bar' } }),
expect.objectContaining({
input: { b: 'bar', aws_key: 'test-secret-value' },
}),
);
});
it('should separate task secrets from environment secrets', async () => {
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
b: '${{ secrets.foo }}',
aws_key: '${{ secrets.AWS_ACCESS_KEY }}',
env_aws_key: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
],
},
{ foo: 'bar', AWS_ACCESS_KEY: 'another-value-from-task' },
);
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({
input: {
b: 'bar',
aws_key: 'another-value-from-task',
env_aws_key: 'test-secret-value',
},
}),
);
});
@@ -1126,6 +1233,7 @@ describe('NunjucksWorkflowRunner', () => {
],
output: {
b: '${{ secrets.foo }}',
c: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
{ foo: 'bar' },
@@ -1134,6 +1242,7 @@ describe('NunjucksWorkflowRunner', () => {
const executedTask = await runner.execute(task);
expect(executedTask.output.b).toBeUndefined();
expect(executedTask.output.c).toBeUndefined();
});
});
@@ -51,6 +51,7 @@ import { createConditionAuthorizer } from '@backstage/plugin-permission-node';
import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha';
import {
TaskContext,
TaskSecrets,
TemplateAction,
TemplateFilter,
TemplateGlobal,
@@ -64,6 +65,8 @@ import {
CheckpointState,
CheckpointContext,
} from '@backstage/plugin-scaffolder-node/alpha';
import { Config } from '@backstage/config';
import { resolveDefaultEnvironment } from '../../lib/defaultEnvironment';
type NunjucksWorkflowRunnerOptions = {
workingDirectory: string;
@@ -74,10 +77,12 @@ type NunjucksWorkflowRunnerOptions = {
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionsService;
config?: Config;
};
type TemplateContext = {
parameters: JsonObject;
environment: JsonObject;
EXPERIMENTAL_recovery?: TaskRecovery;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
@@ -103,10 +108,12 @@ const createStepLogger = ({
task,
step,
rootLogger,
secretsForRedaction,
}: {
task: TaskContext;
step: TaskStep;
rootLogger: LoggerService;
secretsForRedaction?: string[];
}) => {
const taskLogger = WinstonLogger.create({
level: process.env.LOG_LEVEL || 'info',
@@ -118,6 +125,7 @@ const createStepLogger = ({
});
taskLogger.addRedactions(Object.values(task.secrets ?? {}));
taskLogger.addRedactions(Object.values(secretsForRedaction ?? {}));
return { taskLogger };
};
@@ -128,6 +136,10 @@ const isActionAuthorized = createConditionAuthorizer(
export class NunjucksWorkflowRunner implements WorkflowRunner {
private readonly defaultTemplateFilters: Record<string, TemplateFilter>;
private environment: {
parameters: JsonObject;
secrets?: Record<string, string>;
} = { parameters: {}, secrets: {} };
constructor(private readonly options: NunjucksWorkflowRunnerOptions) {
this.defaultTemplateFilters = convertFiltersToRecord(
@@ -139,6 +151,24 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
private readonly tracker = scaffoldingTracker();
async getEnvironmentConfig(): Promise<{
parameters: JsonObject;
secrets?: TaskSecrets;
}> {
if (this.options.config) {
const defaultEnvironment = resolveDefaultEnvironment(this.options.config);
return {
parameters: defaultEnvironment.parameters,
secrets: defaultEnvironment.secrets,
};
}
return {
parameters: {},
secrets: {},
};
}
private isSingleTemplateString(input: string) {
const { parser, nodes } = nunjucks as unknown as {
parser: {
@@ -250,18 +280,32 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
task,
step,
rootLogger: this.options.logger,
secretsForRedaction: this.environment?.secrets
? Object.values(this.environment.secrets)
: [],
});
if (task.isDryRun) {
const redactedSecrets = Object.fromEntries(
Object.entries(task.secrets ?? {}).map(secret => [secret[0], '***']),
);
const redactedEnvironmentSecrets = Object.fromEntries(
Object.entries(this.environment?.secrets ?? {}).map(secret => [
secret[0],
'***',
]),
);
const debugInput =
(step.input &&
this.render(
step.input,
{
...context,
environment: {
parameters: this.environment?.parameters || {},
secrets: redactedEnvironmentSecrets,
},
secrets: redactedSecrets,
},
renderTemplate,
@@ -296,7 +340,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
step.each &&
this.render(
step.each,
{ ...context, secrets: task.secrets ?? {} },
{
...context,
environment: {
parameters: this.environment?.parameters || {},
secrets: this.environment?.secrets ?? {},
},
secrets: task?.secrets ?? {},
},
renderTemplate,
);
@@ -318,7 +369,15 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
input: step.input
? this.render(
step.input,
{ ...context, secrets: task.secrets ?? {}, ...i },
{
...context,
environment: {
parameters: this.environment?.parameters ?? {},
secrets: this.environment?.secrets ?? {},
},
secrets: task.secrets ?? {},
...i,
},
renderTemplate,
)
: {},
@@ -481,6 +540,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const { additionalTemplateFilters, additionalTemplateGlobals } =
this.options;
this.environment = await this.getEnvironmentConfig();
const renderTemplate = await SecureTemplater.loadRenderer({
templateFilters: {
...this.defaultTemplateFilters,
@@ -497,6 +558,10 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const context: TemplateContext = {
parameters: task.spec.parameters,
environment: {
parameters: this.environment?.parameters || {},
secrets: {},
},
steps: {},
user: task.spec.user,
context: {
@@ -103,6 +103,22 @@ describe('StorageTaskBroker', () => {
expect(task.secrets).toEqual(fakeSecrets);
}, 10000);
it('should return secrets with priority over defaults', async () => {
const broker = new StorageTaskBroker(storage, logger);
await broker.dispatch(emptyTaskWithFakeSecretsSpec);
const task = await broker.claim();
expect(task.secrets).toEqual(fakeSecrets);
}, 10000);
it('should return all secrets', async () => {
const broker = new StorageTaskBroker(storage, logger);
await broker.dispatch(emptyTaskWithFakeSecretsSpec);
const task = await broker.claim();
expect(task.secrets).toEqual({ ...fakeSecrets });
}, 10000);
it('should complete a task', async () => {
const broker = new StorageTaskBroker(storage, logger);
const dispatchResult = await broker.dispatch(emptyTaskSpec);
@@ -232,7 +248,7 @@ describe('StorageTaskBroker', () => {
id: taskId,
}),
]),
totalTasks: 13,
totalTasks: 15,
});
});
@@ -160,6 +160,10 @@ export type WorkflowResponse = { output: { [key: string]: JsonValue } };
export interface WorkflowRunner {
execute(task: TaskContext): Promise<WorkflowResponse>;
getEnvironmentConfig?(): Promise<{
parameters: JsonObject;
secrets?: TaskSecrets;
}>;
}
export type TaskTrackType = {
@@ -368,6 +368,7 @@ export async function createRouter(
auditor,
workingDirectory,
permissions,
config,
...templateExtensions,
});