From 2e5c4d1a151580e61b60ce5599e0bc83cc52231f Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 15:17:04 +0100 Subject: [PATCH 01/21] chore: added some additional scopes support for the ScmAuthApi Signed-off-by: blam --- .../integration-react/src/api/ScmAuth.test.ts | 52 +++++++++++++++++++ packages/integration-react/src/api/ScmAuth.ts | 48 ++++++++++++++--- .../integration-react/src/api/ScmAuthApi.ts | 10 ++++ 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/integration-react/src/api/ScmAuth.test.ts b/packages/integration-react/src/api/ScmAuth.test.ts index 2d0ea9b97d..4ebfef749e 100644 --- a/packages/integration-react/src/api/ScmAuth.test.ts +++ b/packages/integration-react/src/api/ScmAuth.test.ts @@ -141,6 +141,58 @@ describe('ScmAuth', () => { }); }); + it('should support additional provided scopes from the caller', async () => { + const mockAuthApi = { + getAccessToken: async (scopes: string[]) => { + return scopes.join(' '); + }, + }; + + const githubAuth = ScmAuth.forGithub(mockAuthApi); + await expect( + githubAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { + customScopes: { github: ['org:read', 'workflow:write'] }, + }, + }), + ).resolves.toMatchObject({ + token: 'repo read:org read:user org:read workflow:write', + }); + + const gitlabAuth = ScmAuth.forGitlab(mockAuthApi); + await expect( + gitlabAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { customScopes: { gitlab: ['write_repository'] } }, + }), + ).resolves.toMatchObject({ + token: 'read_user read_api read_repository write_repository', + }); + + const azureAuth = ScmAuth.forAzure(mockAuthApi); + await expect( + azureAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { customScopes: { azure: ['vso.org'] } }, + }), + ).resolves.toMatchObject({ + token: 'vso.build vso.code vso.graph vso.project vso.profile vso.org', + }); + + const bitbucketAuth = ScmAuth.forBitbucket(mockAuthApi); + await expect( + bitbucketAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { + customScopes: { bitbucket: ['snippet:write', 'issue:write'] }, + }, + }), + ).resolves.toMatchObject({ + token: 'account team pullrequest snippet issue snippet:write issue:write', + }); + }); + it('should handle host option', () => { const mockAuthApi = { getAccessToken: jest.fn(), diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index be3216962d..f450d63cec 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -35,6 +35,9 @@ type ScopeMapping = { repoWrite: string[]; }; +// An enum of all supported providers +type ProviderName = 'generic' | 'github' | 'azure' | 'bitbucket' | 'gitlab'; + class ScmAuthMux implements ScmAuthApi { #providers: Array; @@ -93,12 +96,14 @@ export class ScmAuth implements ScmAuthApi { github: githubAuthApiRef, gitlab: gitlabAuthApiRef, azure: microsoftAuthApiRef, + bitbucket: bitbucketAuthApiRef, }, - factory: ({ github, gitlab, azure }) => + factory: ({ github, gitlab, azure, bitbucket }) => ScmAuth.merge( ScmAuth.forGithub(github), ScmAuth.forGitlab(gitlab), ScmAuth.forAzure(azure), + ScmAuth.forBitbucket(bitbucket), ), }); } @@ -116,7 +121,7 @@ export class ScmAuth implements ScmAuthApi { }; }, ): ScmAuth { - return new ScmAuth(authApi, options.host, options.scopeMapping); + return new ScmAuth('generic', authApi, options.host, options.scopeMapping); } /** @@ -139,7 +144,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'github.com'; - return new ScmAuth(githubAuthApi, host, { + return new ScmAuth('github', githubAuthApi, host, { default: ['repo', 'read:org', 'read:user'], repoWrite: ['gist'], }); @@ -165,7 +170,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'gitlab.com'; - return new ScmAuth(gitlabAuthApi, host, { + return new ScmAuth('gitlab', gitlabAuthApi, host, { default: ['read_user', 'read_api', 'read_repository'], repoWrite: ['write_repository', 'api'], }); @@ -191,7 +196,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'dev.azure.com'; - return new ScmAuth(microsoftAuthApi, host, { + return new ScmAuth('azure', microsoftAuthApi, host, { default: [ 'vso.build', 'vso.code', @@ -223,7 +228,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'bitbucket.org'; - return new ScmAuth(bitbucketAuthApi, host, { + return new ScmAuth('bitbucket', bitbucketAuthApi, host, { default: ['account', 'team', 'pullrequest', 'snippet', 'issue'], repoWrite: ['pullrequest:write', 'snippet:write', 'issue:write'], }); @@ -240,11 +245,18 @@ export class ScmAuth implements ScmAuthApi { #api: OAuthApi; #host: string; #scopeMapping: ScopeMapping; + #providerName: ProviderName; - private constructor(api: OAuthApi, host: string, scopeMapping: ScopeMapping) { + private constructor( + providerName: ProviderName, + api: OAuthApi, + host: string, + scopeMapping: ScopeMapping, + ) { this.#api = api; this.#host = host; this.#scopeMapping = scopeMapping; + this.#providerName = providerName; } /** @@ -254,6 +266,16 @@ export class ScmAuth implements ScmAuthApi { return url.host === this.#host; } + private getAdditionalScopesForProvider( + additionalScopes: ScmAuthTokenOptions['additionalScope'], + ): string[] { + if (!additionalScopes?.customScopes || this.#providerName === 'generic') { + return []; + } + + return additionalScopes.customScopes?.[this.#providerName] ?? []; + } + /** * Fetches credentials for the given resource. */ @@ -267,7 +289,17 @@ export class ScmAuth implements ScmAuthApi { scopes.push(...this.#scopeMapping.repoWrite); } - const token = await this.#api.getAccessToken(scopes, restOptions); + const additionalScopes = + this.getAdditionalScopesForProvider(additionalScope); + + if (additionalScopes.length) { + scopes.push(...additionalScopes); + } + + const uniqueScopes = [...new Set(scopes)]; + + const token = await this.#api.getAccessToken(uniqueScopes, restOptions); + return { token, headers: { diff --git a/packages/integration-react/src/api/ScmAuthApi.ts b/packages/integration-react/src/api/ScmAuthApi.ts index 44b007c4a2..be65842bc1 100644 --- a/packages/integration-react/src/api/ScmAuthApi.ts +++ b/packages/integration-react/src/api/ScmAuthApi.ts @@ -44,6 +44,16 @@ export interface ScmAuthTokenOptions extends AuthRequestOptions { * the ability to create things like issues and pull requests. */ repoWrite?: boolean; + /** + * Allow an arbitrary list of scopes provided from the user + * to request from the provider. + */ + customScopes?: { + github?: string[]; + azure?: string[]; + bitbucket?: string[]; + gitlab?: string[]; + }; }; } From 6d9f426eabc5d32d7d77c5101e10b0154bbe538c Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 16:23:43 +0100 Subject: [PATCH 02/21] feat: added a SecretsContext for storing the secrets in the frontend and being able to add secrets to the context using a hooks Signed-off-by: blam --- .../secrets/SecretsContext.test.tsx | 41 ++++++++++ .../src/components/secrets/SecretsContext.tsx | 74 +++++++++++++++++++ .../src/components/secrets/index.ts | 16 ++++ 3 files changed, 131 insertions(+) create mode 100644 plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx create mode 100644 plugins/scaffolder/src/components/secrets/SecretsContext.tsx create mode 100644 plugins/scaffolder/src/components/secrets/index.ts diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx new file mode 100644 index 0000000000..f238b4be86 --- /dev/null +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2022 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, { useContext } from 'react'; +import { + useSecretsContext, + SecretsContextProvider, + SecretsContext, +} from './SecretsContext'; +import { renderHook, act } from '@testing-library/react-hooks'; + +describe('SecretsContext', () => { + it('should allow the setting of secrets in the context', async () => { + const { result } = renderHook( + () => ({ + hook: useSecretsContext(), + context: useContext(SecretsContext), + }), + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + + act(() => result.current.hook.setSecret({ foo: 'bar' })); + expect(result.current.context?.secrets.foo).toEqual('bar'); + }); +}); diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx new file mode 100644 index 0000000000..0a3358be31 --- /dev/null +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2022 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, { + useState, + useCallback, + useContext, + createContext, + PropsWithChildren, +} from 'react'; +import { JsonObject } from '@backstage/types'; + +type SecretsContextContents = { + secrets: JsonObject; + setSecrets: React.Dispatch>; +}; + +/** + * The actual context object. + */ +export const SecretsContext = createContext( + undefined, +); + +/** + * The Context Provider that holds the state for the secrets. + * + * @public + */ +export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => { + const [secrets, setSecrets] = useState({}); + + return ( + + {children} + + ); +}; + +/** + * Hook to access the secrets context. + * @public + */ +export const useSecretsContext = () => { + const value = useContext(SecretsContext); + if (!value) { + throw new Error( + 'useSecretsContext must be used within a SecretsContextProvider', + ); + } + + const { secrets, setSecrets } = value; + + const setSecret = useCallback( + (input: JsonObject) => { + setSecrets({ ...secrets, ...input }); + }, + [secrets, setSecrets], + ); + + return { setSecret }; +}; diff --git a/plugins/scaffolder/src/components/secrets/index.ts b/plugins/scaffolder/src/components/secrets/index.ts new file mode 100644 index 0000000000..2f2cbdf7ac --- /dev/null +++ b/plugins/scaffolder/src/components/secrets/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 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 { useSecretsContext } from './SecretsContext'; From 288ad92b64e53bc5075b865eba58e15077c19677 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 16:52:50 +0100 Subject: [PATCH 03/21] feat: reworking the secret types for the context. Signed-off-by: blam --- plugins/scaffolder/src/components/Router.tsx | 7 ++++++- .../src/components/TemplatePage/TemplatePage.tsx | 10 ++++++++-- .../src/components/secrets/SecretsContext.test.tsx | 2 ++ .../src/components/secrets/SecretsContext.tsx | 9 ++++----- plugins/scaffolder/src/index.ts | 1 + 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index e667c676f2..ab79f9dec3 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -21,6 +21,7 @@ import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; +import { SecretsContextProvider } from './secrets/SecretsContext'; import { FieldExtensionOptions, @@ -77,7 +78,11 @@ export const Router = ({ TemplateCardComponent, groups }: RouterProps) => { /> } + element={ + + + + } /> } /> } /> diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 8ce7ecf6b3..852ae3d81c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -17,12 +17,13 @@ import { JsonObject, JsonValue } from '@backstage/types'; import { LinearProgress } from '@material-ui/core'; import { FormValidation, IChangeEvent } from '@rjsf/core'; import qs from 'qs'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { generatePath, Navigate, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; import { scaffolderApiRef } from '../../api'; import { CustomFieldValidator, FieldExtensionOptions } from '../../extensions'; +import { SecretsContext } from '../secrets/SecretsContext'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; @@ -115,6 +116,7 @@ export const TemplatePage = ({ customFieldExtensions?: FieldExtensionOptions[]; }) => { const apiHolder = useApiHolder(); + const secretsContext = useContext(SecretsContext); const errorApi = useApi(errorApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); @@ -135,7 +137,11 @@ export const TemplatePage = ({ ); const handleCreate = async () => { - const id = await scaffolderApi.scaffold(templateName, formState); + const id = await scaffolderApi.scaffold( + templateName, + formState, + secretsContext?.secrets, + ); const formParams = qs.stringify( { formData: formState }, diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx index f238b4be86..5699b3188a 100644 --- a/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx @@ -34,8 +34,10 @@ describe('SecretsContext', () => { ), }, ); + expect(result.current.context?.secrets.foo).toEqual(undefined); act(() => result.current.hook.setSecret({ foo: 'bar' })); + expect(result.current.context?.secrets.foo).toEqual('bar'); }); }); diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx index 0a3358be31..771a53fda1 100644 --- a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx @@ -20,11 +20,10 @@ import React, { createContext, PropsWithChildren, } from 'react'; -import { JsonObject } from '@backstage/types'; type SecretsContextContents = { - secrets: JsonObject; - setSecrets: React.Dispatch>; + secrets: Record; + setSecrets: React.Dispatch>>; }; /** @@ -40,7 +39,7 @@ export const SecretsContext = createContext( * @public */ export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [secrets, setSecrets] = useState({}); + const [secrets, setSecrets] = useState>({}); return ( @@ -64,7 +63,7 @@ export const useSecretsContext = () => { const { secrets, setSecrets } = value; const setSecret = useCallback( - (input: JsonObject) => { + (input: Record) => { setSecrets({ ...secrets, ...input }); }, [secrets, setSecrets], diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 3cf65d7a07..133234e20b 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -56,3 +56,4 @@ export { FavouriteTemplate } from './components/FavouriteTemplate'; export { TemplateList } from './components/TemplateList'; export type { TemplateListProps } from './components/TemplateList'; export { TemplateTypePicker } from './components/TemplateTypePicker'; +export * from './components/secrets'; From 6708dbfffe6e1c3b2a9035839af73ef97d29e37e Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 19:10:08 +0100 Subject: [PATCH 04/21] feat: added grabbing the token from the form with a debounce to not happen on every key press Signed-off-by: blam --- packages/integration-react/src/api/ScmAuth.ts | 1 + .../RepoUrlPicker/RepoUrlPicker.test.tsx | 53 +++++++++++++++++++ .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 45 +++++++++++++++- .../RepoUrlPicker/RepoUrlPickerHost.tsx | 2 +- .../src/components/secrets/SecretsContext.tsx | 4 +- 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index f450d63cec..071374e6cb 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -15,6 +15,7 @@ */ import { + bitbucketAuthApiRef, createApiFactory, githubAuthApiRef, gitlabAuthApiRef, diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx new file mode 100644 index 0000000000..f31b152882 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2022 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 { render } from '@testing-library/react'; +import { RepoUrlPicker } from './RepoUrlPicker'; +import Form from '@rjsf/core'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { + scmIntegrationsApiRef, + scmAuthApiRef, +} from '@backstage/integration-react'; +import { scaffolderApiRef } from '../../../api'; +import { SecretsContextProvider } from '../../secrets/SecretsContext'; + +describe('RepoUrlPicker', () => { + describe('happy path rendering', () => { + it('should render the repo url picker', async () => { + const { getByRole } = await renderInTestApp( + + +
+ + , + , + ); + + console.log(getByRole('form')); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 998fa41588..b4a1eedc8e 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ import { useApi } from '@backstage/core-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { + scmIntegrationsApiRef, + scmAuthApiRef, +} from '@backstage/integration-react'; import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { GithubRepoPicker } from './GithubRepoPicker'; import { GitlabRepoPicker } from './GitlabRepoPicker'; @@ -24,10 +27,21 @@ import { FieldExtensionComponentProps } from '../../../extensions'; import { RepoUrlPickerHost } from './RepoUrlPickerHost'; import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; import { RepoUrlPickerState } from './types'; +import useDebounce from 'react-use/lib/useDebounce'; +import { useSecretsContext } from '../../secrets'; export interface RepoUrlPickerUiOptions { allowedHosts?: string[]; allowedOwners?: string[]; + requestUserCredentials?: { + resultSecretsKey: string; + additionalScopes?: { + github?: string[]; + gitlab?: string[]; + bitbucket?: string[]; + azure?: string[]; + }; + }; } export const RepoUrlPicker = ( @@ -38,7 +52,8 @@ export const RepoUrlPicker = ( parseRepoPickerUrl(formData), ); const integrationApi = useApi(scmIntegrationsApiRef); - + const scmAuthApi = useApi(scmAuthApiRef); + const { setSecret } = useSecretsContext(); const allowedHosts = useMemo( () => uiSchema?.['ui:options']?.allowedHosts ?? [], [uiSchema], @@ -66,6 +81,32 @@ export const RepoUrlPicker = ( [setState], ); + useDebounce( + async () => { + const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {}; + + if ( + !requestUserCredentials || + !(state.host && state.owner && !state.repoName) + ) { + return; + } + + // user has requested that we use the users credentials + const { token } = await scmAuthApi.getCredentials({ + url: `https://${state.host}/${state.owner}/${state.repoName}`, + additionalScope: { + repoWrite: true, + customScopes: requestUserCredentials.additionalScopes, + }, + }); + + setSecret({ [requestUserCredentials.resultSecretsKey]: token }); + }, + 1000, + [state], + ); + const hostType = (state.host && integrationApi.byHost(state.host)?.type) ?? null; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx index 261429c757..652e214714 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx @@ -37,7 +37,7 @@ export const RepoUrlPickerHost = (props: { }); useEffect(() => { - if (hosts && !host) { + if (hosts && hosts.length && !host) { // This is only hear to set the default as the first one in the hosts array // if the host is not set yet and there is a list of hosts. onChange(hosts[0]); diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx index 771a53fda1..4f8295bb27 100644 --- a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx @@ -64,9 +64,9 @@ export const useSecretsContext = () => { const setSecret = useCallback( (input: Record) => { - setSecrets({ ...secrets, ...input }); + setSecrets(currentSecrets => ({ ...currentSecrets, ...input })); }, - [secrets, setSecrets], + [setSecrets], ); return { setSecret }; From 5521945cc712d3166ff62aae1c7b01a81bd36ba7 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 19:37:04 +0100 Subject: [PATCH 05/21] feat: added some more behaviour to the HostPicker as noticed that when no hosts are supplied it breaks currently Signed-off-by: blam --- .../RepoUrlPicker/RepoUrlPicker.test.tsx | 19 ++++++++++++++++--- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 6 +++++- .../RepoUrlPicker/RepoUrlPickerHost.tsx | 19 +++++++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index f31b152882..6f37a9a1f6 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -14,26 +14,37 @@ * limitations under the License. */ import React from 'react'; -import { render } from '@testing-library/react'; import { RepoUrlPicker } from './RepoUrlPicker'; import Form from '@rjsf/core'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { scmIntegrationsApiRef, + ScmIntegrationsApi, scmAuthApiRef, } from '@backstage/integration-react'; import { scaffolderApiRef } from '../../../api'; import { SecretsContextProvider } from '../../secrets/SecretsContext'; +import { ScaffolderApi } from '../../..'; describe('RepoUrlPicker', () => { + const mockScaffolderApi: Partial = { + getIntegrationsList: async () => [ + { host: 'github.com', type: 'github', title: 'github.com' }, + ], + }; + + const mockIntegrationsApi: Partial = { + byHost: () => ({ type: 'github' }), + }; + describe('happy path rendering', () => { it('should render the repo url picker', async () => { const { getByRole } = await renderInTestApp( @@ -47,6 +58,8 @@ describe('RepoUrlPicker', () => { , ); + await new Promise(resolve => setTimeout(resolve, 3000)); + console.log(getByRole('form')); }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index b4a1eedc8e..e436a76f3c 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -93,6 +93,8 @@ export const RepoUrlPicker = ( } // user has requested that we use the users credentials + // so lets grab them using the scmAuthApi and pass through + // any additional scopes from the ui:options const { token } = await scmAuthApi.getCredentials({ url: `https://${state.host}/${state.owner}/${state.repoName}`, additionalScope: { @@ -101,10 +103,12 @@ export const RepoUrlPicker = ( }, }); + // set the secret using the key provided in the the ui:options for use + // in the templating the manifest with ${{ secrets[resultSecretsKey] }} setSecret({ [requestUserCredentials.resultSecretsKey]: token }); }, 1000, - [state], + [state, uiSchema], ); const hostType = diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx index 652e214714..ec09494a1b 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx @@ -37,16 +37,23 @@ export const RepoUrlPickerHost = (props: { }); useEffect(() => { - if (hosts && hosts.length && !host) { - // This is only hear to set the default as the first one in the hosts array - // if the host is not set yet and there is a list of hosts. - onChange(hosts[0]); + // If there is no host chosen currently + if (!host) { + // Set the first of the allowedHosts option if that available + if (hosts?.length) { + onChange(hosts[0]); + // if there's no hosts provided, fallback to using the first integration + } else if (integrations?.length) { + onChange(integrations[0].host); + } } - }, [hosts, host, onChange]); + }, [hosts, host, onChange, integrations]); + // If there are no allowedHosts provided, then show all integrations. Otherwise, only show integrations + // that are provided in the dropdown for the user to choose from. const hostsOptions: SelectItem[] = integrations ? integrations - .filter(i => hosts?.includes(i.host)) + .filter(i => (hosts?.length ? hosts?.includes(i.host) : true)) .map(i => ({ label: i.title, value: i.host })) : [{ label: 'Loading...', value: 'loading' }]; From 3e96fa47d0e47af505ba3e38c777c61513f49afa Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 20:20:17 +0100 Subject: [PATCH 06/21] chore: need to convert these to empty strings as they are controlled input values and material ui throws some errors in the frontend unless we have them Signed-off-by: blam --- .../RepoUrlPicker/RepoUrlPicker.test.tsx | 23 +++++++++++++++---- .../components/fields/RepoUrlPicker/utils.ts | 22 +++++++++--------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 6f37a9a1f6..07ee5c2dad 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -25,6 +25,7 @@ import { import { scaffolderApiRef } from '../../../api'; import { SecretsContextProvider } from '../../secrets/SecretsContext'; import { ScaffolderApi } from '../../..'; +import { fireEvent } from '@testing-library/react'; describe('RepoUrlPicker', () => { const mockScaffolderApi: Partial = { @@ -38,8 +39,9 @@ describe('RepoUrlPicker', () => { }; describe('happy path rendering', () => { - it('should render the repo url picker', async () => { - const { getByRole } = await renderInTestApp( + it('should render the repo url picker with minimal props', async () => { + const onSubmit = jest.fn(); + const { getAllByRole, getByRole } = await renderInTestApp( { schema={{ type: 'string' }} uiSchema={{ 'ui:field': 'RepoUrlPicker' }} fields={{ RepoUrlPicker: RepoUrlPicker }} + onSubmit={onSubmit} /> - , , ); - await new Promise(resolve => setTimeout(resolve, 3000)); + const [ownerInput, repoInput] = getAllByRole('textbox'); + const submitButton = getByRole('button'); - console.log(getByRole('form')); + fireEvent.change(ownerInput, { target: { value: 'backstage' } }); + fireEvent.change(repoInput, { target: { value: 'repo123' } }); + + fireEvent.click(submitButton); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + formData: 'github.com?owner=backstage&repo=repo123', + }), + expect.anything(), + ); }); }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts index d1f2302d3e..2f17f0aef7 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts @@ -44,22 +44,22 @@ export function serializeRepoPickerUrl(data: RepoUrlPickerState) { export function parseRepoPickerUrl( url: string | undefined, ): RepoUrlPickerState { - let host = undefined; - let owner = undefined; - let repoName = undefined; - let organization = undefined; - let workspace = undefined; - let project = undefined; + let host = ''; + let owner = ''; + let repoName = ''; + let organization = ''; + let workspace = ''; + let project = ''; try { if (url) { const parsed = new URL(`https://${url}`); host = parsed.host; - owner = parsed.searchParams.get('owner') || undefined; - repoName = parsed.searchParams.get('repo') || undefined; - organization = parsed.searchParams.get('organization') || undefined; - workspace = parsed.searchParams.get('workspace') || undefined; - project = parsed.searchParams.get('project') || undefined; + owner = parsed.searchParams.get('owner') || ''; + repoName = parsed.searchParams.get('repo') || ''; + organization = parsed.searchParams.get('organization') || ''; + workspace = parsed.searchParams.get('workspace') || ''; + project = parsed.searchParams.get('project') || ''; } } catch { /* ok */ From 2ae059d35a13072cd5cafb4fbd6f67b806cdb53a Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 20:38:56 +0100 Subject: [PATCH 07/21] feat: testing the RepoUrlPicker component with SecretsContexts Signed-off-by: blam --- .../RepoUrlPicker/RepoUrlPicker.test.tsx | 85 ++++++++++++++++++- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 4 +- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 07ee5c2dad..153d05c657 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -21,16 +21,18 @@ import { scmIntegrationsApiRef, ScmIntegrationsApi, scmAuthApiRef, + ScmAuthApi, } from '@backstage/integration-react'; import { scaffolderApiRef } from '../../../api'; import { SecretsContextProvider } from '../../secrets/SecretsContext'; import { ScaffolderApi } from '../../..'; -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; describe('RepoUrlPicker', () => { const mockScaffolderApi: Partial = { getIntegrationsList: async () => [ { host: 'github.com', type: 'github', title: 'github.com' }, + { host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' }, ], }; @@ -38,6 +40,10 @@ describe('RepoUrlPicker', () => { byHost: () => ({ type: 'github' }), }; + const mockScmAuthApi: Partial = { + getCredentials: jest.fn().mockResolvedValue({ token: 'abc123' }), + }; + describe('happy path rendering', () => { it('should render the repo url picker with minimal props', async () => { const onSubmit = jest.fn(); @@ -75,5 +81,82 @@ describe('RepoUrlPicker', () => { expect.anything(), ); }); + + it('should render properly with allowedHosts', async () => { + const { getByRole } = await renderInTestApp( + + + + + , + ); + + expect( + getByRole('option', { name: 'dev.azure.com' }), + ).toBeInTheDocument(); + }); + }); + + describe('requestUserCredentials', () => { + it('should call the scmAuthApi with the correct params', async () => { + const { getByRole, getAllByRole } = await renderInTestApp( + + + + + , + ); + + const [ownerInput, repoInput] = getAllByRole('textbox'); + + await act(async () => { + fireEvent.change(ownerInput, { target: { value: 'backstage' } }); + fireEvent.change(repoInput, { target: { value: 'repo123' } }); + + // need to wait for the debounce to finish + await new Promise(resolve => setTimeout(resolve, 600)); + }); + + expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({ + url: 'https://github.com/backstage/repo123', + additionalScope: { + repoWrite: true, + customScopes: { + github: ['workflow:write'], + }, + }, + }); + }); }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index e436a76f3c..549bab6a9b 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -87,7 +87,7 @@ export const RepoUrlPicker = ( if ( !requestUserCredentials || - !(state.host && state.owner && !state.repoName) + !(state.host && state.owner && state.repoName) ) { return; } @@ -107,7 +107,7 @@ export const RepoUrlPicker = ( // in the templating the manifest with ${{ secrets[resultSecretsKey] }} setSecret({ [requestUserCredentials.resultSecretsKey]: token }); }, - 1000, + 500, [state, uiSchema], ); From 7d740b86c9c23dbcd570ecaebc4d3113248da407 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 20:40:03 +0100 Subject: [PATCH 08/21] chore: added a todo for test Signed-off-by: blam --- .../src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 153d05c657..67fab3c188 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -158,5 +158,7 @@ describe('RepoUrlPicker', () => { }, }); }); + + // TODO(blam): need a test here for making sure that the secret is pushed to the context }); }); From b61c34067658f55ae85be839c315320fc4002599 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 20:46:50 +0100 Subject: [PATCH 09/21] chore: generated API reports Signed-off-by: blam --- packages/integration-react/api-report.md | 7 +++++++ plugins/scaffolder/api-report.md | 15 +++++++++++++++ .../fields/RepoUrlPicker/RepoUrlPicker.test.tsx | 2 +- .../src/components/secrets/SecretsContext.tsx | 2 +- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md index 2db29631a3..5b0c9d6174 100644 --- a/packages/integration-react/api-report.md +++ b/packages/integration-react/api-report.md @@ -29,6 +29,7 @@ export class ScmAuth implements ScmAuthApi { ProfileInfoApi & BackstageIdentityApi & SessionApi; + bitbucket: OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi; } >; static forAuthApi( @@ -82,6 +83,12 @@ export const scmAuthApiRef: ApiRef; export interface ScmAuthTokenOptions extends AuthRequestOptions { additionalScope?: { repoWrite?: boolean; + customScopes?: { + github?: string[]; + azure?: string[]; + bitbucket?: string[]; + gitlab?: string[]; + }; }; url: string; } diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index b77fb5f939..cff2aab821 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -164,6 +164,16 @@ export interface RepoUrlPickerUiOptions { allowedHosts?: string[]; // (undocumented) allowedOwners?: string[]; + // (undocumented) + requestUserCredentials?: { + resultSecretsKey: string; + additionalScopes?: { + github?: string[]; + gitlab?: string[]; + bitbucket?: string[]; + azure?: string[]; + }; + }; } // @public @@ -317,4 +327,9 @@ export const TextValuePicker: ({ idSchema, placeholder, }: FieldProps) => JSX.Element; + +// @public +export const useSecretsContext: () => { + setSecret: (input: Record) => void; +}; ``` diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 67fab3c188..f10dd3ea0e 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -112,7 +112,7 @@ describe('RepoUrlPicker', () => { describe('requestUserCredentials', () => { it('should call the scmAuthApi with the correct params', async () => { - const { getByRole, getAllByRole } = await renderInTestApp( + const { getAllByRole } = await renderInTestApp( { ); } - const { secrets, setSecrets } = value; + const { setSecrets } = value; const setSecret = useCallback( (input: Record) => { From cee44ad2898bcd6aa2ac7ca7778c921c992cb864 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jan 2022 20:53:33 +0100 Subject: [PATCH 10/21] chore: added changeset Signed-off-by: blam --- .changeset/lemon-jars-teach.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/lemon-jars-teach.md diff --git a/.changeset/lemon-jars-teach.md b/.changeset/lemon-jars-teach.md new file mode 100644 index 0000000000..d8392d3a92 --- /dev/null +++ b/.changeset/lemon-jars-teach.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Added the ability to collect users `oauth` token from the `RepoUrlPicker` for use in the template manifest From c345946703cd65082a04a7aa8825a84a2e045bdc Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 09:27:15 +0100 Subject: [PATCH 11/21] chore: added a test to check the secrets context is being updated with the secrets returned from the scmAuthApi Signed-off-by: blam --- .../RepoUrlPicker/RepoUrlPicker.test.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index f10dd3ea0e..8456825462 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { RepoUrlPicker } from './RepoUrlPicker'; import Form from '@rjsf/core'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; @@ -24,7 +24,10 @@ import { ScmAuthApi, } from '@backstage/integration-react'; import { scaffolderApiRef } from '../../../api'; -import { SecretsContextProvider } from '../../secrets/SecretsContext'; +import { + SecretsContextProvider, + SecretsContext, +} from '../../secrets/SecretsContext'; import { ScaffolderApi } from '../../..'; import { act, fireEvent } from '@testing-library/react'; @@ -112,7 +115,11 @@ describe('RepoUrlPicker', () => { describe('requestUserCredentials', () => { it('should call the scmAuthApi with the correct params', async () => { - const { getAllByRole } = await renderInTestApp( + const SecretsComponent = () => { + const value = useContext(SecretsContext); + return
{JSON.stringify(value)}
; + }; + const { getAllByRole, getByTestId } = await renderInTestApp( { }} fields={{ RepoUrlPicker: RepoUrlPicker }} /> + , ); @@ -157,8 +165,14 @@ describe('RepoUrlPicker', () => { }, }, }); - }); - // TODO(blam): need a test here for making sure that the secret is pushed to the context + const currentSecrets = JSON.parse( + getByTestId('current-secrets').textContent!, + ); + + expect(currentSecrets).toEqual({ + secrets: { testKey: 'abc123' }, + }); + }); }); }); From c91ba8cf2fe1a4ee779c5a8266191a2d906c50b3 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 09:42:29 +0100 Subject: [PATCH 12/21] feat: Added the ability for the scaffolder backend engine to template secrets into the input of actions Signed-off-by: blam --- .../tasks/NunjucksWorkflowRunner.test.ts | 54 +++++++++++++++++++ .../tasks/NunjucksWorkflowRunner.ts | 9 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 161d5b865b..2fc6b99406 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -497,6 +497,60 @@ describe('DefaultWorkflowRunner', () => { expect.objectContaining({ secrets: { foo: 'bar' } }), ); }); + + it('should be able to template secrets into the input of an action', async () => { + const task = createMockTaskWithSpec( + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-mock-action', + input: { + b: '${{ secrets.foo }}', + }, + }, + ], + output: {}, + parameters: {}, + }, + { foo: 'bar' }, + ); + + await runner.execute(task); + + expect(fakeActionHandler).toHaveBeenCalledWith( + expect.objectContaining({ input: { b: 'bar' } }), + ); + }); + + it('does not allow templating of secrets as an output', async () => { + const task = createMockTaskWithSpec( + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-mock-action', + input: { + b: '${{ secrets.foo }}', + }, + }, + ], + output: { + b: '${{ secrets.foo }}', + }, + parameters: {}, + }, + { foo: 'bar' }, + ); + + const executedTask = await runner.execute(task); + + expect(executedTask.output.b).toBeUndefined(); + }); }); describe('filters', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 5089998834..889e94d000 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -53,6 +53,7 @@ type TemplateContext = { steps: { [stepName: string]: { output: { [outputName: string]: JsonValue } }; }; + secrets?: Record; }; const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta3 => { @@ -231,8 +232,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const action = this.options.actionRegistry.get(step.action); const { taskLogger, streamLogger } = createStepLogger({ task, step }); + // Secrets are only passed when templating the input to actions for security reasons const input = - (step.input && this.render(step.input, context, renderTemplate)) ?? + (step.input && + this.render( + step.input, + { ...context, secrets: task.secrets ?? {} }, + renderTemplate, + )) ?? {}; if (action.schema?.input) { From 2c34749bb66bef1b3ce12acb0fe409024044677a Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 16:23:05 +0100 Subject: [PATCH 13/21] feat: added support for overriding the token used for publish actions. Signed-off-by: blam --- .../builtin/github/OctokitProvider.test.ts | 11 +++++ .../actions/builtin/github/OctokitProvider.ts | 19 ++++++++- .../builtin/github/githubActionsDispatch.ts | 20 +++++++-- .../actions/builtin/github/githubWebhook.ts | 12 +++++- .../actions/builtin/publish/azure.ts | 20 ++++++--- .../actions/builtin/publish/bitbucket.ts | 41 +++++++++++++++---- .../actions/builtin/publish/github.ts | 14 ++++++- .../builtin/publish/githubPullRequest.ts | 21 +++++++++- .../actions/builtin/publish/gitlab.ts | 17 ++++++-- .../builtin/publish/gitlabMergeRequest.ts | 12 +++++- 10 files changed, 157 insertions(+), 30 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts index 0d4353c857..e2449917ab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts @@ -74,4 +74,15 @@ describe('getOctokit', () => { expect(owner).toBe('owner'); expect(repo).toBe('bob'); }); + + it('should return an octokit client with the passed in token if it is provided', async () => { + const { client, token, owner, repo } = await octokitProvider.getOctokit( + 'github.com?repo=bob&owner=owner', + { token: 'tokenlols2' }, + ); + expect(client).toBeDefined(); + expect(token).toBe('tokenlols2'); + expect(owner).toBe('owner'); + expect(repo).toBe('bob'); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts index a3eeec4373..3ac66a0023 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts @@ -52,7 +52,10 @@ export class OctokitProvider { * * @param repoUrl - Repository URL */ - async getOctokit(repoUrl: string): Promise { + async getOctokit( + repoUrl: string, + options?: { token?: string }, + ): Promise { const { owner, repo, host } = parseRepoUrl(repoUrl, this.integrations); if (!owner) { @@ -65,7 +68,19 @@ export class OctokitProvider { throw new InputError(`No integration for host ${host}`); } - // TODO(blam): Consider changing this API to have owner, repo interface instead of URL as the it's + // Short circuit the internal Github Token provider the token provided + // by the action or the caller. + if (options?.token) { + const client = new Octokit({ + auth: options.token, + baseUrl: integrationConfig.apiBaseUrl, + previews: ['nebula-preview'], + }); + + return { client, token: options.token, owner, repo }; + } + + // TODO(blam): Consider changing this API to have owner, repoo interface instead of URL as the it's // needless to create URL and then parse again the other side. const { token } = await this.githubCredentialsProvider.getCredentials({ url: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index 1f7f0f60b9..0c8b4d4aab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts @@ -37,6 +37,7 @@ export function createGithubActionsDispatchAction(options: { workflowId: string; branchOrTagName: string; workflowInputs?: { [key: string]: string }; + token?: string; }>({ id: 'github:actions:dispatch', description: @@ -68,18 +69,31 @@ export function createGithubActionsDispatchAction(options: { 'Inputs keys and values to send to GitHub Action configured on the workflow file. The maximum number of properties is 10. ', type: 'object', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITHUB_TOKEN to use for authorization to GitHub', + }, }, }, }, async handler(ctx) { - const { repoUrl, workflowId, branchOrTagName, workflowInputs } = - ctx.input; + const { + repoUrl, + workflowId, + branchOrTagName, + workflowInputs, + token: providedToken, + } = ctx.input; ctx.logger.info( `Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`, ); - const { client, owner, repo } = await octokitProvider.getOctokit(repoUrl); + const { client, owner, repo } = await octokitProvider.getOctokit( + repoUrl, + { token: providedToken }, + ); await client.rest.actions.createWorkflowDispatch({ owner, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts index 39e8c3aa70..660c7793f2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts @@ -47,6 +47,7 @@ export function createGithubWebhookAction(options: { active?: boolean; contentType?: ContentType; insecureSsl?: boolean; + token?: string; }>({ id: 'github:webhook', description: 'Creates webhook for a repository on GitHub.', @@ -107,6 +108,11 @@ export function createGithubWebhookAction(options: { type: 'boolean', description: `Determines whether the SSL certificate of the host for url will be verified when delivering payloads. Default 'false'`, }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITHUB_TOKEN to use for authorization to GitHub', + }, }, }, }, @@ -119,11 +125,15 @@ export function createGithubWebhookAction(options: { active = true, contentType = 'form', insecureSsl = false, + token: providedToken, } = ctx.input; ctx.logger.info(`Creating webhook ${webhookUrl} for repo ${repoUrl}`); - const { client, owner, repo } = await octokitProvider.getOctokit(repoUrl); + const { client, owner, repo } = await octokitProvider.getOctokit( + repoUrl, + { token: providedToken }, + ); try { const insecure_ssl = insecureSsl ? '1' : '0'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 7e394a8d8b..2fd26c9de8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -34,6 +34,7 @@ export function createPublishAzureAction(options: { description?: string; defaultBranch?: string; sourcePath?: string; + token?: string; }>({ id: 'publish:azure', description: @@ -57,10 +58,16 @@ export function createPublishAzureAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The AZURE_TOKEN to use for authorization to Azure', + }, }, }, output: { @@ -98,12 +105,13 @@ export function createPublishAzureAction(options: { `No matching integration configuration for host ${host}, please check your integrations config`, ); } - if (!integrationConfig.config.token) { + + if (!integrationConfig.config.token && !ctx.input.token) { throw new InputError(`No token provided for Azure Integration ${host}`); } - const authHandler = getPersonalAccessTokenHandler( - integrationConfig.config.token, - ); + + const token = ctx.input.token ?? integrationConfig.config.token!; + const authHandler = getPersonalAccessTokenHandler(token); const webApi = new WebApi(`https://${host}/${organization}`, authHandler); const client = await webApi.getGitApi(); @@ -139,7 +147,7 @@ export function createPublishAzureAction(options: { defaultBranch, auth: { username: 'notempty', - password: integrationConfig.config.token, + password: token, }, logger: ctx.logger, commitMessage: config.getOptionalString( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index fbfe87b076..bc0be1a309 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -205,6 +205,7 @@ export function createPublishBitbucketAction(options: { repoVisibility: 'private' | 'public'; sourcePath?: string; enableLFS: boolean; + token?: string; }>({ id: 'publish:bitbucket', description: @@ -233,15 +234,23 @@ export function createPublishBitbucketAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, enableLFS: { - title: + title: 'Enable LFS?', + description: 'Enable LFS for the repository. Only available for hosted Bitbucket.', type: 'boolean', }, + token: { + title: 'Authentication Token', + type: 'string', + description: + 'The BITBUCKET_TOKEN to use for authorization to BitBucket', + }, }, }, output: { @@ -296,7 +305,12 @@ export function createPublishBitbucketAction(options: { ); } - const authorization = getAuthorizationHeader(integrationConfig.config); + const authorization = getAuthorizationHeader( + ctx.input.token + ? { host: integrationConfig.config.host, token: ctx.input.token } + : integrationConfig.config, + ); + const apiBaseUrl = integrationConfig.config.apiBaseUrl; const createMethod = @@ -320,17 +334,28 @@ export function createPublishBitbucketAction(options: { email: config.getOptionalString('scaffolder.defaultAuthor.email'), }; - await initRepoAndPush({ - dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), - remoteUrl, - auth: { + let auth; + + if (ctx.input.token) { + auth = { + username: 'x-token-auth', + password: ctx.input.token, + }; + } else { + auth = { username: integrationConfig.config.username ? integrationConfig.config.username : 'x-token-auth', password: integrationConfig.config.appPassword ? integrationConfig.config.appPassword : integrationConfig.config.token ?? '', - }, + }; + } + + await initRepoAndPush({ + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), + remoteUrl, + auth, defaultBranch, logger: ctx.logger, commitMessage: config.getOptionalString( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 081f9616ac..c91c703eaa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -52,6 +52,7 @@ export function createPublishGithubAction(options: { requireCodeOwnerReviews?: boolean; repoVisibility: 'private' | 'internal' | 'public'; collaborators: Collaborator[]; + token?: string; topics?: string[]; }>({ id: 'publish:github', @@ -77,7 +78,8 @@ export function createPublishGithubAction(options: { type: 'string', }, requireCodeOwnerReviews: { - title: + title: 'Require CODEOWNER Reviews?', + description: 'Require an approved review in PR including files with a designated Code Owner', type: 'boolean', }, @@ -92,7 +94,8 @@ export function createPublishGithubAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, @@ -116,6 +119,11 @@ export function createPublishGithubAction(options: { }, }, }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITHUB_TOKEN to use for authorization to GitHub', + }, topics: { title: 'Topics', type: 'array', @@ -149,10 +157,12 @@ export function createPublishGithubAction(options: { defaultBranch = 'master', collaborators, topics, + token: providedToken, } = ctx.input; const { client, token, owner, repo } = await octokitProvider.getOctokit( repoUrl, + { token: providedToken }, ); const user = await client.rest.users.getByUsername({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 841d4c7a82..50d54c93c8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -55,6 +55,7 @@ export type GithubPullRequestActionInput = { repoUrl: string; targetPath?: string; sourcePath?: string; + token?: string; }; export type ClientFactoryInput = { @@ -63,6 +64,7 @@ export type ClientFactoryInput = { host: string; owner: string; repo: string; + token?: string; }; export const defaultClientFactory = async ({ @@ -71,13 +73,22 @@ export const defaultClientFactory = async ({ owner, repo, host = 'github.com', + token: providedToken, }: ClientFactoryInput): Promise => { const integrationConfig = integrations.github.byHost(host)?.config; + const OctokitPR = Octokit.plugin(createPullRequest); if (!integrationConfig) { throw new InputError(`No integration for host ${host}`); } + if (providedToken) { + return new OctokitPR({ + auth: providedToken, + baseUrl: integrationConfig.apiBaseUrl, + }); + } + const credentialsProvider = githubCredentialsProvider || SingleInstanceGithubCredentialsProvider.create(integrationConfig); @@ -94,8 +105,6 @@ export const defaultClientFactory = async ({ ); } - const OctokitPR = Octokit.plugin(createPullRequest); - return new OctokitPR({ auth: token, baseUrl: integrationConfig.apiBaseUrl, @@ -151,6 +160,11 @@ export const createPublishGithubPullRequestAction = ({ title: 'Repository Subdirectory', description: 'Subdirectory of repository to apply changes to', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITHUB_TOKEN to use for authorization to GitHub', + }, }, }, output: { @@ -173,6 +187,7 @@ export const createPublishGithubPullRequestAction = ({ description, targetPath, sourcePath, + token: providedToken, } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); @@ -189,7 +204,9 @@ export const createPublishGithubPullRequestAction = ({ host, owner, repo, + token: providedToken, }); + const fileRoot = sourcePath ? resolveSafeChildPath(ctx.workspacePath, sourcePath) : ctx.workspacePath; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 5941f52746..be81db45d8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -33,6 +33,7 @@ export function createPublishGitlabAction(options: { defaultBranch?: string; repoVisibility: 'private' | 'internal' | 'public'; sourcePath?: string; + token?: string; }>({ id: 'publish:gitlab', description: @@ -57,10 +58,16 @@ export function createPublishGitlabAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITLAB_TOKEN to use for authorization to GitLab', + }, }, }, output: { @@ -100,13 +107,15 @@ export function createPublishGitlabAction(options: { ); } - if (!integrationConfig.config.token) { + if (!integrationConfig.config.token && !ctx.input.token) { throw new InputError(`No token available for host ${host}`); } + const token = ctx.input.token || integrationConfig.config.token!; + const client = new Gitlab({ host: integrationConfig.config.baseUrl, - token: integrationConfig.config.token, + token, }); let { id: targetNamespace } = (await client.Namespaces.show(owner)) as { @@ -140,7 +149,7 @@ export function createPublishGitlabAction(options: { defaultBranch, auth: { username: 'oauth2', - password: integrationConfig.config.token, + password: token, }, logger: ctx.logger, commitMessage: config.getOptionalString( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts index 2ac369249b..9ed4efe748 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -31,6 +31,7 @@ export type GitlabMergeRequestActionInput = { description: string; branchName: string; targetPath: string; + token?: string; }; export const createPublishGitlabMergeRequestAction = (options: { @@ -75,6 +76,11 @@ export const createPublishGitlabMergeRequestAction = (options: { title: 'Repository Subdirectory', description: 'Subdirectory of repository to apply changes to', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITLAB_TOKEN to use for authorization to GitLab', + }, }, }, output: { @@ -107,13 +113,15 @@ export const createPublishGitlabMergeRequestAction = (options: { ); } - if (!integrationConfig.config.token) { + if (!integrationConfig.config.token && !ctx.input.token) { throw new InputError(`No token available for host ${host}`); } + const token = ctx.input.token ?? integrationConfig.config.token!; + const api = new Gitlab({ host: integrationConfig.config.baseUrl, - token: integrationConfig.config.token, + token, }); const fileRoot = ctx.workspacePath; From 72eccc0f5dc6dbbf52df81b12105c74892caa343 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 17:47:26 +0100 Subject: [PATCH 14/21] chore: added tests for ensuring that the token is used properly when passed into the actions Signed-off-by: blam --- .../actions/builtin/publish/azure.test.ts | 26 +++++++++++++ .../actions/builtin/publish/bitbucket.test.ts | 39 +++++++++++++++++++ .../actions/builtin/publish/gitlab.test.ts | 22 +++++++++++ 3 files changed, 87 insertions(+) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index cee99e1646..9247109121 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -119,6 +119,32 @@ describe('publish:azure', () => { ).rejects.toThrow(/Unable to create the repository/); }); + it('should not throw if there is a token provided through ctx.input', async () => { + mockGitClient.createRepository.mockImplementation(() => ({ + remoteUrl: 'http://google.com', + })); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'myazurehostnotoken.com?repo=bob&owner=owner&organization=org', + token: 'lols', + }, + }); + + expect(WebApi).toHaveBeenCalledWith( + 'https://myazurehostnotoken.com/org', + expect.any(Function), + ); + + expect(mockGitClient.createRepository).toHaveBeenCalledWith( + { + name: 'bob', + }, + 'owner', + ); + }); + it('should throw if there is no remoteUrl returned', async () => { mockGitClient.createRepository.mockImplementation(() => ({ remoteUrl: null, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts index 2678abd9dd..3d14710f3e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts @@ -194,6 +194,45 @@ describe('publish:bitbucket', () => { }); }); + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(2); + server.use( + rest.post( + 'https://notoken.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer lols'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + await action.handler({ + ...mockContext, + input: { + repoUrl: 'notoken.bitbucket.com?project=project&repo=repo', + token: 'lols', + }, + }); + }); + describe('LFS for hosted bitbucket', () => { const repoCreationResponse = { links: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts index 2cd27da359..a32fea8830 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts @@ -96,6 +96,28 @@ describe('publish:gitlab', () => { ).rejects.toThrow(/No token available for host/); }); + it('should work when there is a token provided through ctx.input', async () => { + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'http://mockurl.git', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'hosted.gitlab.com?repo=bob&owner=owner', + token: 'token', + }, + }); + + expect(mockGitlabClient.Namespaces.show).toHaveBeenCalledWith('owner'); + expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ + namespace_id: 1234, + name: 'bob', + visibility: 'private', + }); + }); + it('should call the correct Gitlab APIs when the owner is an organization', async () => { mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 }); mockGitlabClient.Projects.create.mockResolvedValue({ From 0f264c4f842c7010109339d53d99e5a108d107ff Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 17:55:16 +0100 Subject: [PATCH 15/21] chore: api-reports needed updating for scaffolder bacend Signed-off-by: blam --- plugins/scaffolder-backend/api-report.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index cfe8f5d2b0..5e7b5a428b 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -299,7 +299,12 @@ export class OctokitProvider { githubCredentialsProvider?: GithubCredentialsProvider, ); // Warning: (ae-forgotten-export) The symbol "OctokitIntegration" needs to be exported by the entry point index.d.ts - getOctokit(repoUrl: string): Promise; + getOctokit( + repoUrl: string, + options?: { + token?: string; + }, + ): Promise; } // @public From c95df1631e4ae930dfbbd172520699e2256f3040 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 18:08:13 +0100 Subject: [PATCH 16/21] chore: added changeset and updating documentation about being able to grab the user token Signed-off-by: blam --- .changeset/twenty-queens-scream.md | 5 + .../software-templates/writing-templates.md | 115 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 .changeset/twenty-queens-scream.md diff --git a/.changeset/twenty-queens-scream.md b/.changeset/twenty-queens-scream.md new file mode 100644 index 0000000000..4ea1152ae2 --- /dev/null +++ b/.changeset/twenty-queens-scream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added support for templating secrets into actions input, and also added an extra `token` input argument to all publishers to provide a token that would override the `integrations.config` diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index a1dffe1580..22525541f3 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -287,6 +287,121 @@ The `RepoUrlPicker` is a custom field that we provide part of the `plugin-scaffolder`. You can provide your own custom fields by [writing your own Custom Field Extensions](./writing-custom-field-extensions.md) +##### Using the Users `oauth` token + +There's a little bit of extra magic that you get out of the box when using the +`RepoUrlPicker` as a field input. You can provide some additional options under +`ui:options` to allow the `RepoUrlPicker` to grab an `oauth` token for the user +for the required `repository`. + +This is great for when you are wanting to create a new repository, or wanting to +perform operations on top of an existing repository. + +A sample template that takes advantage of this is like so: + +```yaml +# Notice the v1beta3 version +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +# some metadata about the template itself +metadata: + name: v1beta3-demo + title: Test Action template + description: scaffolder v1beta3 template demo +spec: + owner: backstage/techdocs-core + type: service + + # these are the steps which are rendered in the frontend with the form input + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + requestUserCredentials: + resultSecretsKey: USER_OAUTH_TOKEN + additionalScopes: + github: + - workflow:write + allowedHosts: + - github.com + + # here's the steps that are executed in series in the scaffolder backend + steps: + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} + + - id: fetch-docs + name: Fetch Docs + action: fetch:plain + input: + targetPath: ./community + url: https://github.com/backstage/community/tree/main/backstage-community-sessions + + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: This is ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + token: ${{ secrets.USER_OAUTH_TOKEN }} + + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} + catalogInfoPath: '/catalog-info.yaml' + + # some outputs which are saved along with the job for use in the frontend + output: + remoteUrl: ${{ steps.publish.output.remoteUrl }} + entityRef: ${{ steps.register.output.entityRef }} +``` + +You will see from above that there is an additional `requestUserCredentials` +object that is passed to the `RepoUrlPicker`. This object defines what the +returned `secret` should be stored as when accessing using +`${{ secrets.secretName }}`, in this case it is `USER_OAUTH_TOKEN`. And then you +will see that there is an additional `input` field into the `publish:github` +action called `token`, in which you can use the `secret` like so: +`token: ${{ secrets.USER_OAUTH_TOKEN }}`. + +There's also the ability to pass additional scopes when requesting the `oauth` +token from the user, which you can do on a per-provider basis, in case your +template can be published to multiple providers. + #### The Owner Picker When the scaffolder needs to add new components to the catalog, it needs to have From b856b156c2ee96660627dcc2f46e1c4a06a3b06e Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jan 2022 18:13:01 +0100 Subject: [PATCH 17/21] docs: making the docs a little clearer Signed-off-by: blam --- .../software-templates/writing-templates.md | 56 +++---------------- 1 file changed, 7 insertions(+), 49 deletions(-) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 22525541f3..398e18dfd1 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -300,10 +300,8 @@ perform operations on top of an existing repository. A sample template that takes advantage of this is like so: ```yaml -# Notice the v1beta3 version apiVersion: scaffolder.backstage.io/v1beta3 kind: Template -# some metadata about the template itself metadata: name: v1beta3-demo title: Test Action template @@ -312,27 +310,9 @@ spec: owner: backstage/techdocs-core type: service - # these are the steps which are rendered in the frontend with the form input parameters: - - title: Fill in some steps - required: - - name - properties: - name: - title: Name - type: string - description: Unique name of the component - ui:autofocus: true - ui:options: - rows: 5 - owner: - title: Owner - type: string - description: Owner of the component - ui:field: OwnerPicker - ui:options: - allowedKinds: - - Group + ... + - title: Choose a location required: - repoUrl @@ -342,6 +322,7 @@ spec: type: string ui:field: RepoUrlPicker ui:options: + # here's the new option you can pass to the RepoUrlPicker requestUserCredentials: resultSecretsKey: USER_OAUTH_TOKEN additionalScopes: @@ -349,24 +330,10 @@ spec: - workflow:write allowedHosts: - github.com + ... - # here's the steps that are executed in series in the scaffolder backend steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./template - values: - name: ${{ parameters.name }} - owner: ${{ parameters.owner }} - - - id: fetch-docs - name: Fetch Docs - action: fetch:plain - input: - targetPath: ./community - url: https://github.com/backstage/community/tree/main/backstage-community-sessions + ... - id: publish name: Publish @@ -375,19 +342,10 @@ spec: allowedHosts: ['github.com'] description: This is ${{ parameters.name }} repoUrl: ${{ parameters.repoUrl }} + # here's where the secret can be used token: ${{ secrets.USER_OAUTH_TOKEN }} - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} - catalogInfoPath: '/catalog-info.yaml' - - # some outputs which are saved along with the job for use in the frontend - output: - remoteUrl: ${{ steps.publish.output.remoteUrl }} - entityRef: ${{ steps.register.output.entityRef }} + ... ``` You will see from above that there is an additional `requestUserCredentials` From c1be2801a9011704bc677b88f9789e7fcc6dcd7d Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 Jan 2022 20:53:13 +0100 Subject: [PATCH 18/21] chore: rewording `PROVIDER_TOKEN` to `token` Signed-off-by: blam --- .../src/scaffolder/actions/builtin/publish/azure.ts | 2 +- .../src/scaffolder/actions/builtin/publish/bitbucket.ts | 3 +-- .../src/scaffolder/actions/builtin/publish/github.ts | 2 +- .../scaffolder/actions/builtin/publish/githubPullRequest.ts | 2 +- .../src/scaffolder/actions/builtin/publish/gitlab.ts | 2 +- .../scaffolder/actions/builtin/publish/gitlabMergeRequest.ts | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 2fd26c9de8..4f67a0e45f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -66,7 +66,7 @@ export function createPublishAzureAction(options: { token: { title: 'Authentication Token', type: 'string', - description: 'The AZURE_TOKEN to use for authorization to Azure', + description: 'The token to use for authorization to Azure', }, }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index bc0be1a309..8ba8592642 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -248,8 +248,7 @@ export function createPublishBitbucketAction(options: { token: { title: 'Authentication Token', type: 'string', - description: - 'The BITBUCKET_TOKEN to use for authorization to BitBucket', + description: 'The token to use for authorization to BitBucket', }, }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index c91c703eaa..139fad04d4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -122,7 +122,7 @@ export function createPublishGithubAction(options: { token: { title: 'Authentication Token', type: 'string', - description: 'The GITHUB_TOKEN to use for authorization to GitHub', + description: 'The token to use for authorization to GitHub', }, topics: { title: 'Topics', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 50d54c93c8..80333c4966 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -163,7 +163,7 @@ export const createPublishGithubPullRequestAction = ({ token: { title: 'Authentication Token', type: 'string', - description: 'The GITHUB_TOKEN to use for authorization to GitHub', + description: 'The token to use for authorization to GitHub', }, }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index be81db45d8..a04766f941 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -66,7 +66,7 @@ export function createPublishGitlabAction(options: { token: { title: 'Authentication Token', type: 'string', - description: 'The GITLAB_TOKEN to use for authorization to GitLab', + description: 'The token to use for authorization to GitLab', }, }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts index 9ed4efe748..281b72789f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -79,7 +79,7 @@ export const createPublishGitlabMergeRequestAction = (options: { token: { title: 'Authentication Token', type: 'string', - description: 'The GITLAB_TOKEN to use for authorization to GitLab', + description: 'The token to use for authorization to GitLab', }, }, }, From 37a3fc75c06fa51a9e2dfb85ae54a5eb7ad28d60 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 Jan 2022 20:58:51 +0100 Subject: [PATCH 19/21] chore: more code review fixes Signed-off-by: blam --- docs/features/software-templates/writing-templates.md | 4 ++-- plugins/scaffolder/api-report.md | 4 ++-- .../fields/RepoUrlPicker/RepoUrlPicker.test.tsx | 2 +- .../components/fields/RepoUrlPicker/RepoUrlPicker.tsx | 10 +++++----- .../src/components/secrets/SecretsContext.test.tsx | 4 ++-- .../src/components/secrets/SecretsContext.tsx | 4 ++-- plugins/scaffolder/src/components/secrets/index.ts | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 398e18dfd1..415f8cd85e 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -322,9 +322,9 @@ spec: type: string ui:field: RepoUrlPicker ui:options: - # here's the new option you can pass to the RepoUrlPicker + # Here's the option you can pass to the RepoUrlPicker requestUserCredentials: - resultSecretsKey: USER_OAUTH_TOKEN + secretsKey: USER_OAUTH_TOKEN additionalScopes: github: - workflow:write diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index cff2aab821..80b5faf201 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -166,7 +166,7 @@ export interface RepoUrlPickerUiOptions { allowedOwners?: string[]; // (undocumented) requestUserCredentials?: { - resultSecretsKey: string; + secretsKey: string; additionalScopes?: { github?: string[]; gitlab?: string[]; @@ -329,7 +329,7 @@ export const TextValuePicker: ({ }: FieldProps) => JSX.Element; // @public -export const useSecretsContext: () => { +export const useTemplateSecrets: () => { setSecret: (input: Record) => void; }; ``` diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 8456825462..3769176cb4 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -134,7 +134,7 @@ describe('RepoUrlPicker', () => { 'ui:field': 'RepoUrlPicker', 'ui:options': { requestUserCredentials: { - resultSecretsKey: 'testKey', + secretsKey: 'testKey', additionalScopes: { github: ['workflow:write'] }, }, }, diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 549bab6a9b..c8b1a0cf31 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -28,13 +28,13 @@ import { RepoUrlPickerHost } from './RepoUrlPickerHost'; import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; import { RepoUrlPickerState } from './types'; import useDebounce from 'react-use/lib/useDebounce'; -import { useSecretsContext } from '../../secrets'; +import { useTemplateSecrets } from '../../secrets'; export interface RepoUrlPickerUiOptions { allowedHosts?: string[]; allowedOwners?: string[]; requestUserCredentials?: { - resultSecretsKey: string; + secretsKey: string; additionalScopes?: { github?: string[]; gitlab?: string[]; @@ -53,7 +53,7 @@ export const RepoUrlPicker = ( ); const integrationApi = useApi(scmIntegrationsApiRef); const scmAuthApi = useApi(scmAuthApiRef); - const { setSecret } = useSecretsContext(); + const { setSecret } = useTemplateSecrets(); const allowedHosts = useMemo( () => uiSchema?.['ui:options']?.allowedHosts ?? [], [uiSchema], @@ -104,8 +104,8 @@ export const RepoUrlPicker = ( }); // set the secret using the key provided in the the ui:options for use - // in the templating the manifest with ${{ secrets[resultSecretsKey] }} - setSecret({ [requestUserCredentials.resultSecretsKey]: token }); + // in the templating the manifest with ${{ secrets[secretsKey] }} + setSecret({ [requestUserCredentials.secretsKey]: token }); }, 500, [state, uiSchema], diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx index 5699b3188a..37d35c9015 100644 --- a/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx @@ -15,7 +15,7 @@ */ import React, { useContext } from 'react'; import { - useSecretsContext, + useTemplateSecrets, SecretsContextProvider, SecretsContext, } from './SecretsContext'; @@ -25,7 +25,7 @@ describe('SecretsContext', () => { it('should allow the setting of secrets in the context', async () => { const { result } = renderHook( () => ({ - hook: useSecretsContext(), + hook: useTemplateSecrets(), context: useContext(SecretsContext), }), { diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx index f66171b0dd..6570f9d901 100644 --- a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx @@ -52,11 +52,11 @@ export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => { * Hook to access the secrets context. * @public */ -export const useSecretsContext = () => { +export const useTemplateSecrets = () => { const value = useContext(SecretsContext); if (!value) { throw new Error( - 'useSecretsContext must be used within a SecretsContextProvider', + 'useTemplateSecrets must be used within a SecretsContextProvider', ); } diff --git a/plugins/scaffolder/src/components/secrets/index.ts b/plugins/scaffolder/src/components/secrets/index.ts index 2f2cbdf7ac..44adb2a00a 100644 --- a/plugins/scaffolder/src/components/secrets/index.ts +++ b/plugins/scaffolder/src/components/secrets/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { useSecretsContext } from './SecretsContext'; +export { useTemplateSecrets } from './SecretsContext'; From ac23003f62f18a969a4ac8a0c6a52a72d8ef156f Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 Jan 2022 21:17:53 +0100 Subject: [PATCH 20/21] chore: encode some values too Signed-off-by: blam --- .../fields/RepoUrlPicker/RepoUrlPicker.test.tsx | 3 +-- .../src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 3769176cb4..829495cdda 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -23,12 +23,11 @@ import { scmAuthApiRef, ScmAuthApi, } from '@backstage/integration-react'; -import { scaffolderApiRef } from '../../../api'; +import { scaffolderApiRef, ScaffolderApi } from '../../../api'; import { SecretsContextProvider, SecretsContext, } from '../../secrets/SecretsContext'; -import { ScaffolderApi } from '../../..'; import { act, fireEvent } from '@testing-library/react'; describe('RepoUrlPicker', () => { diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index c8b1a0cf31..1ccaed4e86 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -92,11 +92,17 @@ export const RepoUrlPicker = ( return; } + const [host, owner, repoName] = [ + state.host, + state.owner, + state.repoName, + ].map(encodeURIComponent); + // user has requested that we use the users credentials // so lets grab them using the scmAuthApi and pass through // any additional scopes from the ui:options const { token } = await scmAuthApi.getCredentials({ - url: `https://${state.host}/${state.owner}/${state.repoName}`, + url: `https://${host}/${owner}/${repoName}`, additionalScope: { repoWrite: true, customScopes: requestUserCredentials.additionalScopes, From 7d2589de9da5bb25e4e0a60129fa12becccc3f37 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 Jan 2022 21:24:35 +0100 Subject: [PATCH 21/21] chore: adding a link to the docs Signed-off-by: blam --- .changeset/twenty-queens-scream.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/twenty-queens-scream.md b/.changeset/twenty-queens-scream.md index 4ea1152ae2..92626ed12c 100644 --- a/.changeset/twenty-queens-scream.md +++ b/.changeset/twenty-queens-scream.md @@ -2,4 +2,5 @@ '@backstage/plugin-scaffolder-backend': patch --- -Added support for templating secrets into actions input, and also added an extra `token` input argument to all publishers to provide a token that would override the `integrations.config` +Added support for templating secrets into actions input, and also added an extra `token` input argument to all publishers to provide a token that would override the `integrations.config`. +You can find more information over at [Writing Templates](https://backstage.io/docs/features/software-templates/writing-templates#using-the-users-oauth-token)