Merge pull request #9168 from backstage/blam/scaffodler-secrets-frontend

This commit is contained in:
Ben Lambert
2022-02-03 10:23:05 +01:00
committed by GitHub
34 changed files with 922 additions and 62 deletions
+6
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
---
'@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`.
You can find more information over at [Writing Templates](https://backstage.io/docs/features/software-templates/writing-templates#using-the-users-oauth-token)
@@ -287,6 +287,79 @@ 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
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: v1beta3-demo
title: Test Action template
description: scaffolder v1beta3 template demo
spec:
owner: backstage/techdocs-core
type: service
parameters:
...
- title: Choose a location
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
# Here's the option you can pass to the RepoUrlPicker
requestUserCredentials:
secretsKey: USER_OAUTH_TOKEN
additionalScopes:
github:
- workflow:write
allowedHosts:
- github.com
...
steps:
...
- id: publish
name: Publish
action: publish:github
input:
allowedHosts: ['github.com']
description: This is ${{ parameters.name }}
repoUrl: ${{ parameters.repoUrl }}
# here's where the secret can be used
token: ${{ secrets.USER_OAUTH_TOKEN }}
...
```
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
+7
View File
@@ -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<ScmAuthApi>;
export interface ScmAuthTokenOptions extends AuthRequestOptions {
additionalScope?: {
repoWrite?: boolean;
customScopes?: {
github?: string[];
azure?: string[];
bitbucket?: string[];
gitlab?: string[];
};
};
url: string;
}
@@ -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(),
+41 -8
View File
@@ -15,6 +15,7 @@
*/
import {
bitbucketAuthApiRef,
createApiFactory,
githubAuthApiRef,
gitlabAuthApiRef,
@@ -35,6 +36,9 @@ type ScopeMapping = {
repoWrite: string[];
};
// An enum of all supported providers
type ProviderName = 'generic' | 'github' | 'azure' | 'bitbucket' | 'gitlab';
class ScmAuthMux implements ScmAuthApi {
#providers: Array<ScmAuth>;
@@ -93,12 +97,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 +122,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 +145,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 +171,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 +197,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 +229,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 +246,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 +267,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 +290,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: {
@@ -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[];
};
};
}
+6 -1
View File
@@ -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<OctokitIntegration>;
getOctokit(
repoUrl: string,
options?: {
token?: string;
},
): Promise<OctokitIntegration>;
}
// @public
@@ -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');
});
});
@@ -52,7 +52,10 @@ export class OctokitProvider {
*
* @param repoUrl - Repository URL
*/
async getOctokit(repoUrl: string): Promise<OctokitIntegration> {
async getOctokit(
repoUrl: string,
options?: { token?: string },
): Promise<OctokitIntegration> {
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(
@@ -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,
@@ -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';
@@ -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,
@@ -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 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(
@@ -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: {
@@ -205,6 +205,7 @@ export function createPublishBitbucketAction(options: {
repoVisibility: 'private' | 'public';
sourcePath?: string;
enableLFS: boolean;
token?: string;
}>({
id: 'publish:bitbucket',
description:
@@ -233,15 +234,22 @@ 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 token to use for authorization to BitBucket',
},
},
},
output: {
@@ -296,7 +304,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 +333,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(
@@ -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 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({
@@ -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<PullRequestCreator> => {
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 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;
@@ -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({
@@ -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 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(
@@ -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 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;
@@ -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', () => {
@@ -53,6 +53,7 @@ type TemplateContext = {
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
};
secrets?: Record<string, string>;
};
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) {
+15
View File
@@ -164,6 +164,16 @@ export interface RepoUrlPickerUiOptions {
allowedHosts?: string[];
// (undocumented)
allowedOwners?: string[];
// (undocumented)
requestUserCredentials?: {
secretsKey: string;
additionalScopes?: {
github?: string[];
gitlab?: string[];
bitbucket?: string[];
azure?: string[];
};
};
}
// @public
@@ -317,4 +327,9 @@ export const TextValuePicker: ({
idSchema,
placeholder,
}: FieldProps<string>) => JSX.Element;
// @public
export const useTemplateSecrets: () => {
setSecret: (input: Record<string, string>) => void;
};
```
+6 -1
View File
@@ -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) => {
/>
<Route
path="/templates/:templateName"
element={<TemplatePage customFieldExtensions={fieldExtensions} />}
element={
<SecretsContextProvider>
<TemplatePage customFieldExtensions={fieldExtensions} />
</SecretsContextProvider>
}
/>
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
@@ -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 },
@@ -0,0 +1,177 @@
/*
* 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 { RepoUrlPicker } from './RepoUrlPicker';
import Form from '@rjsf/core';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
scmIntegrationsApiRef,
ScmIntegrationsApi,
scmAuthApiRef,
ScmAuthApi,
} from '@backstage/integration-react';
import { scaffolderApiRef, ScaffolderApi } from '../../../api';
import {
SecretsContextProvider,
SecretsContext,
} from '../../secrets/SecretsContext';
import { act, fireEvent } from '@testing-library/react';
describe('RepoUrlPicker', () => {
const mockScaffolderApi: Partial<ScaffolderApi> = {
getIntegrationsList: async () => [
{ host: 'github.com', type: 'github', title: 'github.com' },
{ host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' },
],
};
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
byHost: () => ({ type: 'github' }),
};
const mockScmAuthApi: Partial<ScmAuthApi> = {
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();
const { getAllByRole, getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, mockScaffolderApi],
]}
>
<SecretsContextProvider>
<Form
schema={{ type: 'string' }}
uiSchema={{ 'ui:field': 'RepoUrlPicker' }}
fields={{ RepoUrlPicker: RepoUrlPicker }}
onSubmit={onSubmit}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
const [ownerInput, repoInput] = getAllByRole('textbox');
const submitButton = getByRole('button');
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(),
);
});
it('should render properly with allowedHosts', async () => {
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, {}],
[scaffolderApiRef, mockScaffolderApi],
]}
>
<SecretsContextProvider>
<Form
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoUrlPicker',
'ui:options': { allowedHosts: ['dev.azure.com'] },
}}
fields={{ RepoUrlPicker: RepoUrlPicker }}
/>
</SecretsContextProvider>
</TestApiProvider>,
);
expect(
getByRole('option', { name: 'dev.azure.com' }),
).toBeInTheDocument();
});
});
describe('requestUserCredentials', () => {
it('should call the scmAuthApi with the correct params', async () => {
const SecretsComponent = () => {
const value = useContext(SecretsContext);
return <div data-testid="current-secrets">{JSON.stringify(value)}</div>;
};
const { getAllByRole, getByTestId } = await renderInTestApp(
<TestApiProvider
apis={[
[scmIntegrationsApiRef, mockIntegrationsApi],
[scmAuthApiRef, mockScmAuthApi],
[scaffolderApiRef, mockScaffolderApi],
]}
>
<SecretsContextProvider>
<Form
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoUrlPicker',
'ui:options': {
requestUserCredentials: {
secretsKey: 'testKey',
additionalScopes: { github: ['workflow:write'] },
},
},
}}
fields={{ RepoUrlPicker: RepoUrlPicker }}
/>
<SecretsComponent />
</SecretsContextProvider>
</TestApiProvider>,
);
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'],
},
},
});
const currentSecrets = JSON.parse(
getByTestId('current-secrets').textContent!,
);
expect(currentSecrets).toEqual({
secrets: { testKey: 'abc123' },
});
});
});
});
@@ -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 { useTemplateSecrets } from '../../secrets';
export interface RepoUrlPickerUiOptions {
allowedHosts?: string[];
allowedOwners?: string[];
requestUserCredentials?: {
secretsKey: 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 } = useTemplateSecrets();
const allowedHosts = useMemo(
() => uiSchema?.['ui:options']?.allowedHosts ?? [],
[uiSchema],
@@ -66,6 +81,42 @@ export const RepoUrlPicker = (
[setState],
);
useDebounce(
async () => {
const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {};
if (
!requestUserCredentials ||
!(state.host && state.owner && state.repoName)
) {
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://${host}/${owner}/${repoName}`,
additionalScope: {
repoWrite: true,
customScopes: requestUserCredentials.additionalScopes,
},
});
// set the secret using the key provided in the the ui:options for use
// in the templating the manifest with ${{ secrets[secretsKey] }}
setSecret({ [requestUserCredentials.secretsKey]: token });
},
500,
[state, uiSchema],
);
const hostType =
(state.host && integrationApi.byHost(state.host)?.type) ?? null;
@@ -37,16 +37,23 @@ export const RepoUrlPickerHost = (props: {
});
useEffect(() => {
if (hosts && !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' }];
@@ -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 */
@@ -0,0 +1,43 @@
/*
* 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 {
useTemplateSecrets,
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: useTemplateSecrets(),
context: useContext(SecretsContext),
}),
{
wrapper: ({ children }) => (
<SecretsContextProvider>{children}</SecretsContextProvider>
),
},
);
expect(result.current.context?.secrets.foo).toEqual(undefined);
act(() => result.current.hook.setSecret({ foo: 'bar' }));
expect(result.current.context?.secrets.foo).toEqual('bar');
});
});
@@ -0,0 +1,73 @@
/*
* 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';
type SecretsContextContents = {
secrets: Record<string, string>;
setSecrets: React.Dispatch<React.SetStateAction<Record<string, string>>>;
};
/**
* The actual context object.
*/
export const SecretsContext = createContext<SecretsContextContents | undefined>(
undefined,
);
/**
* The Context Provider that holds the state for the secrets.
*
* @public
*/
export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => {
const [secrets, setSecrets] = useState<Record<string, string>>({});
return (
<SecretsContext.Provider value={{ secrets, setSecrets }}>
{children}
</SecretsContext.Provider>
);
};
/**
* Hook to access the secrets context.
* @public
*/
export const useTemplateSecrets = () => {
const value = useContext(SecretsContext);
if (!value) {
throw new Error(
'useTemplateSecrets must be used within a SecretsContextProvider',
);
}
const { setSecrets } = value;
const setSecret = useCallback(
(input: Record<string, string>) => {
setSecrets(currentSecrets => ({ ...currentSecrets, ...input }));
},
[setSecrets],
);
return { setSecret };
};
@@ -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 { useTemplateSecrets } from './SecretsContext';
+1
View File
@@ -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';