diff --git a/.changeset/itchy-otters-switch.md b/.changeset/itchy-otters-switch.md new file mode 100644 index 0000000000..de155a05ea --- /dev/null +++ b/.changeset/itchy-otters-switch.md @@ -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. diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 7019ecfb28..35458446c2 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -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`: diff --git a/plugins/scaffolder-react/api-report-alpha.md b/plugins/scaffolder-react/api-report-alpha.md index 615b54df84..dc698e0c51 100644 --- a/plugins/scaffolder-react/api-report-alpha.md +++ b/plugins/scaffolder-react/api-report-alpha.md @@ -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) => { diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx index 2bb66fd782..d7e271f8b3 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -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( - , + + + , ); for (const step of manifest.steps) { @@ -52,7 +55,9 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - , + + + , ); expect(getByRole('button', { name: 'Next' })).toBeInTheDocument(); @@ -92,7 +97,9 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - , + + + , ); await fireEvent.change(getByRole('textbox', { name: 'name' }), { @@ -140,7 +147,9 @@ describe('Stepper', () => { }; const { getByRole, getByLabelText } = await renderInTestApp( - , + + + , ); await fireEvent.change(getByRole('textbox', { name: 'name' }), { @@ -219,14 +228,16 @@ describe('Stepper', () => { }); const { getByRole } = await renderInTestApp( - , + + + , ); await fireEvent.change(getByRole('textbox', { name: 'repo' }), { @@ -275,11 +286,13 @@ describe('Stepper', () => { }; const { getByText } = await renderInTestApp( - , + + + , ); expect(getByText('im a custom field extension')).toBeInTheDocument(); @@ -308,17 +321,19 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - new Promise(r => setTimeout(r, 1000)), - }, - ]} - onCreate={jest.fn()} - />, + + new Promise(r => setTimeout(r, 1000)), + }, + ]} + onCreate={jest.fn()} + /> + , ); act(() => { @@ -356,12 +371,14 @@ describe('Stepper', () => { }; const { getByText, getByRole } = await renderInTestApp( - , + + + , ); await fireEvent.change(getByRole('textbox', { name: 'postcode' }), { @@ -401,7 +418,9 @@ describe('Stepper', () => { }); const { getByRole } = await renderInTestApp( - , + + + , ); expect(getByRole('textbox', { name: 'firstName' })).toHaveValue('John'); @@ -429,7 +448,9 @@ describe('Stepper', () => { }); const { getByRole } = await renderInTestApp( - , + + + , ); await act(async () => { @@ -464,15 +485,17 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - Make, - reviewButtonText: Inspect, - }} - />, + + Make, + reviewButtonText: Inspect, + }} + /> + , ); await act(async () => { @@ -516,12 +539,14 @@ describe('Stepper', () => { }; const { getByText, getByRole } = await renderInTestApp( - , + + + , ); expect(getByText('A Scaffolder Layout')).toBeInTheDocument(); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 5b41163ae4..70071721fa 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -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(); 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( + () => ({ + 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 ? (
{ noPadding titleTypographyProps={{ component: 'h2' }} > - + + + )} @@ -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) => ( - - - -); +export const EmbeddableWorkflow = Workflow; diff --git a/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx new file mode 100644 index 0000000000..fa810b9d17 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.test.tsx @@ -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('', () => { + const SecretsComponent = () => { + const { secrets } = useTemplateSecrets(); + return ( +
{JSON.stringify({ secrets })}
+ ); + }; + + it('should set the secret value to the unmasked value', async () => { + const mockSecret = 'backstage'; + const onSubmit = jest.fn(); + + const { getByLabelText, getByTestId } = await renderInTestApp( + + + + , + ); + + 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); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx new file mode 100644 index 0000000000..d68216650b --- /dev/null +++ b/plugins/scaffolder/src/components/fields/SecretInput/SecretInput.tsx @@ -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 ( + + {title} + setSecrets({ [name]: e.target?.value })} + value={secrets[name] ?? ''} + type="password" + autoComplete="off" + /> + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/SecretInput/index.tsx b/plugins/scaffolder/src/components/fields/SecretInput/index.tsx new file mode 100644 index 0000000000..859a04a4d3 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/SecretInput/index.tsx @@ -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'; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index 7ee4448f6b..7f119f27be 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -19,4 +19,5 @@ export * from './RepoUrlPicker'; export * from './OwnedEntityPicker'; export * from './EntityTagsPicker'; export * from './MyGroupsPicker'; + export { type FieldSchema, makeFieldSchemaFromZod } from './utils'; diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index 013f242a12..2e4b7032de 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -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', + }, ];