Merge pull request #21287 from backstage/blam/secrets-extension

scaffolder: `Secret` extension for use for hiding secrets securely
This commit is contained in:
Ben Lambert
2023-12-19 12:58:37 +01:00
committed by GitHub
11 changed files with 317 additions and 65 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-scaffolder': minor
'@backstage/plugin-scaffolder-react': minor
---
Added support for dealing with user provided secrets using a new field extension `ui:field: Secret`
The `@alpha` exports of `Stepper` now needs to be wrapped in a `SecretsContextProvider` for access to secrets.
@@ -231,8 +231,53 @@ spec:
inputType: tel
```
### Using Secrets
You may want to mark things as secret and make sure that these values are protected and not available through REST endpoints. You can do this by using the built in `ui:field: Secret`.
You can define this property as any normal parameter, however the consumption of this parameter will not be available through `${{ parameters.myKey }}` you will instead need to use `${{ secrets.myKey }}` in your `template.yaml`.
Parameters will be automatically masked in the review step.
```yaml
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: v1beta3-demo
title: Test Action template
description: scaffolder v1beta3 template demo
spec:
owner: backstage/techdocs-core
type: service
parameters:
- title: Authenticaion
description: Provide authentication for the resource
required:
- username
- password
properties:
username:
type: string
# use the built in Secret field extension
ui:field: Secret
password:
type: string
ui:field: Secret
steps:
- id: setupAuthentication
action: auth:create
input:
# make sure to use ${{ secrets.parameterName }} to reference these values
username: ${{ secrets.username }}
password: ${{ secrets.password }}
```
### Hide or mask sensitive data on Review step
> Note: this approach is soon to be deprecated, please mark things as secret by using the `Secret` field extension instead as mentioned above.
Sometimes, specially in custom fields, you collect some data on Create form that
must not be shown to the user on Review step. To hide or mask this data, you can
use `ui:widget: password` or set some properties of `ui:backstage`:
+4 -2
View File
@@ -64,8 +64,10 @@ export const DefaultTemplateOutputs: (props: {
output?: ScaffolderTaskOutput;
}) => React_2.JSX.Element | null;
// @alpha (undocumented)
export const EmbeddableWorkflow: (props: WorkflowProps) => React_2.JSX.Element;
// @alpha
export const EmbeddableWorkflow: (
workflowProps: WorkflowProps,
) => JSX.Element | null;
// @alpha
export const extractSchemaFromStep: (inputStep: JsonObject) => {
@@ -21,6 +21,7 @@ import { act, fireEvent } from '@testing-library/react';
import type { RJSFValidationError } from '@rjsf/utils';
import { JsonValue } from '@backstage/types';
import { FieldExtensionComponentProps } from '../../../extensions';
import { SecretsContextProvider } from '../../../secrets';
import { LayoutTemplate } from '../../../layouts';
describe('Stepper', () => {
@@ -34,7 +35,9 @@ describe('Stepper', () => {
};
const { getByText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />
</SecretsContextProvider>,
);
for (const step of manifest.steps) {
@@ -52,7 +55,9 @@ describe('Stepper', () => {
};
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />
</SecretsContextProvider>,
);
expect(getByRole('button', { name: 'Next' })).toBeInTheDocument();
@@ -92,7 +97,9 @@ describe('Stepper', () => {
};
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />
</SecretsContextProvider>,
);
await fireEvent.change(getByRole('textbox', { name: 'name' }), {
@@ -140,7 +147,9 @@ describe('Stepper', () => {
};
const { getByRole, getByLabelText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />
</SecretsContextProvider>,
);
await fireEvent.change(getByRole('textbox', { name: 'name' }), {
@@ -219,14 +228,16 @@ describe('Stepper', () => {
});
const { getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
onCreate={onCreate}
extensions={[
{ name: 'Repo', component: Repo },
{ name: 'Owner', component: Owner },
]}
/>,
<SecretsContextProvider>
<Stepper
manifest={manifest}
onCreate={onCreate}
extensions={[
{ name: 'Repo', component: Repo },
{ name: 'Owner', component: Owner },
]}
/>
</SecretsContextProvider>,
);
await fireEvent.change(getByRole('textbox', { name: 'repo' }), {
@@ -275,11 +286,13 @@ describe('Stepper', () => {
};
const { getByText } = await renderInTestApp(
<Stepper
manifest={manifest}
extensions={[{ name: 'Mock', component: MockComponent }]}
onCreate={jest.fn()}
/>,
<SecretsContextProvider>
<Stepper
manifest={manifest}
extensions={[{ name: 'Mock', component: MockComponent }]}
onCreate={jest.fn()}
/>
</SecretsContextProvider>,
);
expect(getByText('im a custom field extension')).toBeInTheDocument();
@@ -308,17 +321,19 @@ describe('Stepper', () => {
};
const { getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
extensions={[
{
name: 'Mock',
component: MockComponent,
validation: async () => new Promise(r => setTimeout(r, 1000)),
},
]}
onCreate={jest.fn()}
/>,
<SecretsContextProvider>
<Stepper
manifest={manifest}
extensions={[
{
name: 'Mock',
component: MockComponent,
validation: async () => new Promise(r => setTimeout(r, 1000)),
},
]}
onCreate={jest.fn()}
/>
</SecretsContextProvider>,
);
act(() => {
@@ -356,12 +371,14 @@ describe('Stepper', () => {
};
const { getByText, getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
extensions={[]}
onCreate={jest.fn()}
formProps={{ transformErrors }}
/>,
<SecretsContextProvider>
<Stepper
manifest={manifest}
extensions={[]}
onCreate={jest.fn()}
formProps={{ transformErrors }}
/>
</SecretsContextProvider>,
);
await fireEvent.change(getByRole('textbox', { name: 'postcode' }), {
@@ -401,7 +418,9 @@ describe('Stepper', () => {
});
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />,
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />
</SecretsContextProvider>,
);
expect(getByRole('textbox', { name: 'firstName' })).toHaveValue('John');
@@ -429,7 +448,9 @@ describe('Stepper', () => {
});
const { getByRole } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} onCreate={onCreate} />,
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={onCreate} />
</SecretsContextProvider>,
);
await act(async () => {
@@ -464,15 +485,17 @@ describe('Stepper', () => {
};
const { getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
onCreate={jest.fn()}
extensions={[]}
components={{
createButtonText: <b>Make</b>,
reviewButtonText: <i>Inspect</i>,
}}
/>,
<SecretsContextProvider>
<Stepper
manifest={manifest}
onCreate={jest.fn()}
extensions={[]}
components={{
createButtonText: <b>Make</b>,
reviewButtonText: <i>Inspect</i>,
}}
/>
</SecretsContextProvider>,
);
await act(async () => {
@@ -516,12 +539,14 @@ describe('Stepper', () => {
};
const { getByText, getByRole } = await renderInTestApp(
<Stepper
manifest={manifest}
extensions={[]}
onCreate={jest.fn()}
layouts={[{ name: 'Layout', component: ScaffolderLayout }]}
/>,
<SecretsContextProvider>
<Stepper
manifest={manifest}
extensions={[]}
onCreate={jest.fn()}
layouts={[{ name: 'Layout', component: ScaffolderLayout }]}
/>
</SecretsContextProvider>,
);
expect(getByText('A Scaffolder Layout')).toBeInTheDocument();
@@ -24,7 +24,7 @@ import {
LinearProgress,
} from '@material-ui/core';
import { type IChangeEvent } from '@rjsf/core';
import { ErrorSchema } from '@rjsf/utils';
import { ErrorSchema, ValidatorType } from '@rjsf/utils';
import React, {
useCallback,
useMemo,
@@ -49,6 +49,7 @@ import {
LayoutOptions,
FieldExtensionOptions,
FormProps,
useTemplateSecrets,
} from '@backstage/plugin-scaffolder-react';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
@@ -102,6 +103,7 @@ export const Stepper = (stepperProps: StepperProps) => {
reviewButtonText = 'Review',
} = components;
const analytics = useAnalytics();
const { secrets } = useTemplateSecrets();
const { presentation, steps } = useTemplateSchema(props.manifest);
const apiHolder = useApiHolder();
const [activeStep, setActiveStep] = useState(0);
@@ -111,6 +113,31 @@ export const Stepper = (stepperProps: StepperProps) => {
const [errors, setErrors] = useState<undefined | FormValidation>();
const styles = useStyles();
const stringifiedSecrets = JSON.stringify(secrets);
// Because secrets can be defined in the schema, we need to make sure that they
// are included in the validation process. So we merge the secrets and the formData
// together in validation.
const customValidator = useMemo<ValidatorType>(
() => ({
isValid: (schema, formData, rootSchema) =>
validator.isValid(schema, { ...formData, ...secrets }, rootSchema),
rawValidation: (schema, formData) =>
validator.rawValidation(schema, { ...formData, ...secrets }),
validateFormData: (formData, schema, customFormats, transformErrors) =>
validator.validateFormData(
{ ...formData, ...secrets },
schema,
customFormats,
transformErrors,
),
// @deprecated
toErrorList: validator.toErrorList,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[secrets, stringifiedSecrets],
);
const extensions = useMemo(() => {
return Object.fromEntries(
props.extensions.map(({ name, component }) => [name, component]),
@@ -220,7 +247,7 @@ export const Stepper = (stepperProps: StepperProps) => {
{/* eslint-disable-next-line no-nested-ternary */}
{activeStep < steps.length ? (
<Form
validator={validator}
validator={customValidator}
extraErrors={errors as unknown as ErrorSchema}
formData={formState}
formContext={{ formData: formState }}
@@ -111,11 +111,13 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
noPadding
titleTypographyProps={{ component: 'h2' }}
>
<Stepper
manifest={sortedManifest}
templateName={templateName}
{...props}
/>
<SecretsContextProvider>
<Stepper
manifest={sortedManifest}
templateName={templateName}
{...props}
/>
</SecretsContextProvider>
</InfoCard>
)}
</Content>
@@ -123,10 +125,8 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
};
/**
* TODO(blam): work out what we want to do with these components in the new API.
* Should we really have EmbeddableWorkflow, Workflow, Stepper and Form, or should we revisit this?
* @alpha
*/
export const EmbeddableWorkflow = (props: WorkflowProps) => (
<SecretsContextProvider>
<Workflow {...props} />
</SecretsContextProvider>
);
export const EmbeddableWorkflow = Workflow;
@@ -0,0 +1,70 @@
/*
* 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 React from 'react';
import {
SecretsContextProvider,
useTemplateSecrets,
} from '@backstage/plugin-scaffolder-react';
import { SecretInput } from './SecretInput';
import { renderInTestApp } from '@backstage/test-utils';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
import validator from '@rjsf/validator-ajv8';
import { fireEvent, act } from '@testing-library/react';
describe('<SecretInput />', () => {
const SecretsComponent = () => {
const { secrets } = useTemplateSecrets();
return (
<div data-testid="current-secrets">{JSON.stringify({ secrets })}</div>
);
};
it('should set the secret value to the unmasked value', async () => {
const mockSecret = 'backstage';
const onSubmit = jest.fn();
const { getByLabelText, getByTestId } = await renderInTestApp(
<SecretsContextProvider>
<Form
validator={validator}
schema={{
properties: { myKey: { type: 'string', title: 'secret' } },
}}
uiSchema={{
myKey: {
'ui:field': 'Secret',
},
}}
fields={{
Secret: SecretInput,
}}
onSubmit={onSubmit}
/>
<SecretsComponent />
</SecretsContextProvider>,
);
const secretInput = getByLabelText('secret');
await act(async () => {
fireEvent.change(secretInput, { target: { value: mockSecret } });
});
const { secrets } = JSON.parse(getByTestId('current-secrets').textContent!);
expect(secrets.myKey).toBe(mockSecret);
});
});
@@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { useTemplateSecrets } from '@backstage/plugin-scaffolder-react';
import { ScaffolderRJSFFieldProps } from '@backstage/plugin-scaffolder-react';
import { ScaffolderField } from '@backstage/plugin-scaffolder-react/alpha';
import { Input, InputLabel } from '@material-ui/core';
export const SecretInput = (props: ScaffolderRJSFFieldProps) => {
const { setSecrets, secrets } = useTemplateSecrets();
const {
name,
schema: { title, description },
rawErrors,
disabled,
errors,
required,
} = props;
return (
<ScaffolderField
rawErrors={rawErrors}
rawDescription={description}
disabled={disabled}
errors={errors}
required={required}
>
<InputLabel htmlFor={title}>{title}</InputLabel>
<Input
id={title}
aria-describedby={title}
onChange={e => setSecrets({ [name]: e.target?.value })}
value={secrets[name] ?? ''}
type="password"
autoComplete="off"
/>
</ScaffolderField>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './SecretInput';
@@ -19,4 +19,5 @@ export * from './RepoUrlPicker';
export * from './OwnedEntityPicker';
export * from './EntityTagsPicker';
export * from './MyGroupsPicker';
export { type FieldSchema, makeFieldSchemaFromZod } from './utils';
@@ -44,6 +44,8 @@ import {
MyGroupsPickerSchema,
} from '../components/fields/MyGroupsPicker/MyGroupsPicker';
import { SecretInput } from '../components/fields/SecretInput';
export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
{
component: EntityPicker,
@@ -82,4 +84,8 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
name: 'MyGroupsPicker',
schema: MyGroupsPickerSchema,
},
{
component: SecretInput,
name: 'Secret',
},
];