Merge pull request #21287 from backstage/blam/secrets-extension
scaffolder: `Secret` extension for use for hiding secrets securely
This commit is contained in:
@@ -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