Merge pull request #21287 from backstage/blam/secrets-extension
scaffolder: `Secret` extension for use for hiding secrets securely
This commit is contained in:
@@ -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`:
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user