From b63b3108a51c9d8478498fbd7bb2eebfda5d1938 Mon Sep 17 00:00:00 2001 From: kim5566 <28945404+kim5566@users.noreply.github.com> Date: Mon, 25 Apr 2022 06:23:44 +1000 Subject: [PATCH 001/151] fix: btoa encoding Signed-off-by: kim5566 <28945404+kim5566@users.noreply.github.com> --- plugins/techdocs/src/reader/transformers/addBaseUrl.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 2e3b853e42..96a5888a2a 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -69,7 +69,9 @@ export const addBaseUrl = ({ const svgContent = await svg.text(); elem.setAttribute( attributeName, - `data:image/svg+xml;base64,${btoa(svgContent)}`, + `data:image/svg+xml;base64,${btoa( + unescape(encodeURIComponent(svgContent)), + )}`, ); } catch (e) { elem.setAttribute('alt', `Error: ${elemAttribute}`); From bbe09df689a76b00f17116c7c68d893c1627852e Mon Sep 17 00:00:00 2001 From: kim5566 <28945404+kim5566@users.noreply.github.com> Date: Mon, 25 Apr 2022 06:35:04 +1000 Subject: [PATCH 002/151] add changeset Signed-off-by: kim5566 <28945404+kim5566@users.noreply.github.com> --- .changeset/hot-berries-collect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-berries-collect.md diff --git a/.changeset/hot-berries-collect.md b/.changeset/hot-berries-collect.md new file mode 100644 index 0000000000..d4e6ad7599 --- /dev/null +++ b/.changeset/hot-berries-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Loading SVGs correctly with bota with extended characters From 22b5f885a25d86b0169ce2f8a1f8ba820913d581 Mon Sep 17 00:00:00 2001 From: kim5566 <28945404+kim5566@users.noreply.github.com> Date: Mon, 25 Apr 2022 06:40:35 +1000 Subject: [PATCH 003/151] update changeset Signed-off-by: kim5566 <28945404+kim5566@users.noreply.github.com> --- .changeset/hot-berries-collect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/hot-berries-collect.md b/.changeset/hot-berries-collect.md index d4e6ad7599..efb8dcc7c9 100644 --- a/.changeset/hot-berries-collect.md +++ b/.changeset/hot-berries-collect.md @@ -2,4 +2,4 @@ '@backstage/plugin-techdocs': patch --- -Loading SVGs correctly with bota with extended characters +Loading SVGs correctly with `bota` with extended characters From 6735bd3ea14cd3ba16472a5939b6632de556dc8a Mon Sep 17 00:00:00 2001 From: Rakesh Thera Date: Fri, 22 Apr 2022 19:44:29 +0530 Subject: [PATCH 004/151] New PR with right sign off Signed-off-by: Rakesh Thera --- .../core-components/src/layout/Sidebar/Items.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 4a0376946a..b227d81dc1 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -369,6 +369,18 @@ const SidebarItemBase = forwardRef((props, ref) => { // Still waiting on a Q answered to fine tune the implementation const { isOpen } = useContext(SidebarContext); + const divStyle = + !isOpen && children ? { marginLeft: '24px', marginBottom: '4px' } : {}; + + const displayItemIcon = ( + <> +
+ + {!isOpen && children ? : <>} +
+ + ); + const itemIcon = ( ((props, ref) => { className={classnames({ [classes.closedItemIcon]: !isOpen })} > + {displayItemIcon} ); From aaffa18d8a9cdac5b1800b74430a1942b85245b9 Mon Sep 17 00:00:00 2001 From: Rakesh Thera Date: Mon, 25 Apr 2022 17:07:17 +0530 Subject: [PATCH 005/151] minor fixes Signed-off-by: Rakesh Thera --- packages/core-components/src/layout/Sidebar/Items.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index b227d81dc1..d2e2c22689 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -389,7 +389,6 @@ const SidebarItemBase = forwardRef((props, ref) => { invisible={!hasNotifications} className={classnames({ [classes.closedItemIcon]: !isOpen })} > - {displayItemIcon} ); From 521293b22e10e079fd3721ca6ee9b029ac05f41a Mon Sep 17 00:00:00 2001 From: Rakesh Thera Date: Tue, 26 Apr 2022 20:07:57 +0530 Subject: [PATCH 006/151] Inclued the changeset Signed-off-by: Rakesh Thera --- .changeset/tough-queens-hope.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tough-queens-hope.md diff --git a/.changeset/tough-queens-hope.md b/.changeset/tough-queens-hope.md new file mode 100644 index 0000000000..c86d7f39b0 --- /dev/null +++ b/.changeset/tough-queens-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Added a chevron Indicator when the sidebar is collapsed and has a submenu From 42bdeb514fd408af71b5d9ed71547fd790862a62 Mon Sep 17 00:00:00 2001 From: Rakesh Thera Date: Wed, 27 Apr 2022 16:33:59 +0530 Subject: [PATCH 007/151] Corrected Spell checks Signed-off-by: Rakesh Thera --- .changeset/tough-queens-hope.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tough-queens-hope.md b/.changeset/tough-queens-hope.md index c86d7f39b0..6816893611 100644 --- a/.changeset/tough-queens-hope.md +++ b/.changeset/tough-queens-hope.md @@ -2,4 +2,4 @@ '@backstage/core-components': patch --- -Added a chevron Indicator when the sidebar is collapsed and has a submenu +Added a chevron Indicator when the sidebar is collapsed and has a sub-menu From 29097461476ac14e24032707ba2d8fb3a6eaa393 Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Wed, 27 Apr 2022 11:02:40 -0700 Subject: [PATCH 008/151] fix: Updated entity query param transform to handle keys with '.' in them. Signed-off-by: Mark David Avery --- .changeset/shiny-apes-design.md | 5 +++++ .../parseEntityTransformParams.test.ts | 22 ++++++++++++++++++- .../request/parseEntityTransformParams.ts | 22 +++++++++++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 .changeset/shiny-apes-design.md diff --git a/.changeset/shiny-apes-design.md b/.changeset/shiny-apes-design.md new file mode 100644 index 0000000000..fe3d7d84f2 --- /dev/null +++ b/.changeset/shiny-apes-design.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Updated entity query param transform to handle keys with '.' in them. This will allow for querying based of annotations such as 'backstage.io/orgin-location' for instance diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts index 1d5d41fc03..8ecae1655f 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts @@ -24,6 +24,9 @@ describe('parseEntityTransformParams', () => { metadata: { name: 'n', tags: ['t1', 't2'], + annotations: { + 'test.com/url-like': 'ul1', + }, }, spec: { type: 't', @@ -61,7 +64,24 @@ describe('parseEntityTransformParams', () => { parseEntityTransformParams({ fields: 'kind,metadata.name' })!(entity), ).toEqual({ kind: 'k', metadata: { name: 'n' } }); expect(parseEntityTransformParams({ fields: 'metadata' })!(entity)).toEqual( - { metadata: { name: 'n', tags: ['t1', 't2'] } }, + { + metadata: { + name: 'n', + tags: ['t1', 't2'], + annotations: { 'test.com/url-like': 'ul1' }, + }, + }, ); }); + + it('supports dot notated feilds properly', () => { + expect( + parseEntityTransformParams({ + fields: 'kind,metadata.annotations.test.com/url-like', + })!(entity), + ).toEqual({ + kind: 'k', + metadata: { annotations: { 'test.com/url-like': 'ul1' } }, + }); + }); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index 9935da07a8..adb7b8a133 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -20,6 +20,22 @@ import lodash from 'lodash'; import { RecursivePartial } from '../../util/RecursivePartial'; import { parseStringsParam } from './common'; +function getPathArray(input: Entity, field: string) { + const pathArray = []; + let currentPathPart = ''; + + for (const pathPart of field.split('.')) { + currentPathPart += pathPart; + + if (lodash.has(input, pathArray.concat(currentPathPart))) { + pathArray.push(currentPathPart); + currentPathPart = ''; + } + } + + return pathArray; +} + export function parseEntityTransformParams( params: Record, ): ((entity: Entity) => Entity) | undefined { @@ -46,9 +62,11 @@ export function parseEntityTransformParams( const output: RecursivePartial = {}; for (const field of fields) { - const value = lodash.get(input, field); + const pathArray = getPathArray(input, field); + + const value = lodash.get(input, pathArray); if (value !== undefined) { - lodash.set(output, field, value); + lodash.set(output, pathArray, value); } } From 8aaed71fdf5baa724355f34f0ec4addf4ce12a6d Mon Sep 17 00:00:00 2001 From: Michael Haley Date: Tue, 3 May 2022 13:36:43 -0400 Subject: [PATCH 009/151] fix aria rule validations on oauth dialog Signed-off-by: Michael Haley --- .../LoginRequestListItem.tsx | 2 +- .../OAuthRequestDialog/OAuthRequestDialog.tsx | 42 ++++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index 0c6bd62bcb..c7707c07d5 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -59,7 +59,7 @@ const LoginRequestListItem = ({ request, busy, setBusy }: RowProps) => { const IconComponent = request.provider.icon; return ( - + diff --git a/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx index b11821803a..02b1afe4a1 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx +++ b/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -25,6 +25,7 @@ import React, { useMemo, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import LoginRequestListItem from './LoginRequestListItem'; import { useApi, oauthRequestApiRef } from '@backstage/core-plugin-api'; +import Typography from '@material-ui/core/Typography'; export type OAuthRequestDialogClassKey = | 'dialog' @@ -40,6 +41,9 @@ const useStyles = makeStyles( title: { minWidth: 0, }, + titleHeading: { + fontSize: theme.typography.h6.fontSize, + }, contentList: { padding: 0, }, @@ -69,23 +73,31 @@ export function OAuthRequestDialog(_props: {}) { fullWidth maxWidth="xs" classes={{ paper: classes.dialog }} + aria-labelledby="oauth-req-dialog-title" > - - Login Required - +
+ + + Login Required + + - - - {requests.map(request => ( - - ))} - - + + + {requests.map(request => ( + + ))} + + +
From 3603014e0eb5c5008fd889cee248042cee23e3cc Mon Sep 17 00:00:00 2001 From: Michael Haley Date: Tue, 3 May 2022 14:32:10 -0400 Subject: [PATCH 010/151] Add changeset Signed-off-by: Michael Haley --- .changeset/seven-panthers-pump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/seven-panthers-pump.md diff --git a/.changeset/seven-panthers-pump.md b/.changeset/seven-panthers-pump.md new file mode 100644 index 0000000000..856ab38d21 --- /dev/null +++ b/.changeset/seven-panthers-pump.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Add ARIA landmark(
), & label and a heading to OAuthRequestDialog. Removed nested interactive control (button). From 61eed179c7fe8b396e980ec833d3f678bda2bd2a Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Tue, 3 May 2022 17:43:11 -0700 Subject: [PATCH 011/151] fix: updated parseEntityTransformParams dot in key notation to properly handle multiple dot notated keys Also added additional tests to handle varried scenarios. Signed-off-by: Mark David Avery --- .changeset/shiny-apes-design.md | 2 +- .../parseEntityTransformParams.test.ts | 55 +++++++++++++++++-- .../request/parseEntityTransformParams.ts | 2 + 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/.changeset/shiny-apes-design.md b/.changeset/shiny-apes-design.md index fe3d7d84f2..69dfbf57ab 100644 --- a/.changeset/shiny-apes-design.md +++ b/.changeset/shiny-apes-design.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': patch --- -Updated entity query param transform to handle keys with '.' in them. This will allow for querying based of annotations such as 'backstage.io/orgin-location' for instance +Updated parseEntityTransformParams to handle keys with '.' in them. This will allow for querying of entities based off annotations such as 'backstage.io/orgin-location' or other entity field keys that have '.' in them. diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts index 8ecae1655f..65f85639e1 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts @@ -25,7 +25,9 @@ describe('parseEntityTransformParams', () => { name: 'n', tags: ['t1', 't2'], annotations: { - 'test.com/url-like': 'ul1', + 'example.test/url-like-key': 'ul1', + 'example.com/other-url-like-key': 'ul2', + 'other-example.test/next-url-like-key': 'ul3', }, }, spec: { @@ -68,20 +70,63 @@ describe('parseEntityTransformParams', () => { metadata: { name: 'n', tags: ['t1', 't2'], - annotations: { 'test.com/url-like': 'ul1' }, + annotations: { + 'example.test/url-like-key': 'ul1', + 'example.com/other-url-like-key': 'ul2', + 'other-example.test/next-url-like-key': 'ul3', + }, }, }, ); }); - it('supports dot notated feilds properly', () => { + it('supports dot notated fields properly', () => { expect( parseEntityTransformParams({ - fields: 'kind,metadata.annotations.test.com/url-like', + fields: 'kind,metadata.annotations.example.com/other-url-like-key', })!(entity), ).toEqual({ kind: 'k', - metadata: { annotations: { 'test.com/url-like': 'ul1' } }, + metadata: { annotations: { 'example.com/other-url-like-key': 'ul2' } }, + }); + }); + + it('supports nested dot notated fields properly', () => { + entity.spec = { + ...entity.spec, + 'field-with.dot': 'fd1', + 'other-field-with.dot-also': { + subItem: 'fd2.sub', + 'subite.with/dot': 'fd2.sub.dot', + }, + 'third-field-with.dot-again': 'fd3', + type: 't', + }; + + expect( + parseEntityTransformParams({ + fields: 'kind,spec.other-field-with.dot-also', + })!(entity), + ).toEqual({ + kind: 'k', + spec: { + 'other-field-with.dot-also': { + subItem: 'fd2.sub', + 'subite.with/dot': 'fd2.sub.dot', + }, + }, + }); + expect( + parseEntityTransformParams({ + fields: 'kind,spec.other-field-with.dot-also.subite.with/dot', + })!(entity), + ).toEqual({ + kind: 'k', + spec: { + 'other-field-with.dot-also': { + 'subite.with/dot': 'fd2.sub.dot', + }, + }, }); }); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index adb7b8a133..e09289d5c0 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -30,6 +30,8 @@ function getPathArray(input: Entity, field: string) { if (lodash.has(input, pathArray.concat(currentPathPart))) { pathArray.push(currentPathPart); currentPathPart = ''; + } else { + currentPathPart += '.'; } } From 5fcbd869604b11bc92ccf338cdf91bfef57fa22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 3 May 2022 17:23:23 +0200 Subject: [PATCH 012/151] Add an expiry time on server-to-server tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/olive-eggs-accept.md | 13 +++ packages/backend-common/api-report.md | 16 ++-- .../src/tokens/ServerTokenManager.test.ts | 32 ++++++- .../src/tokens/ServerTokenManager.ts | 84 ++++++++++++++----- packages/backend-common/src/tokens/index.ts | 1 + packages/backend-common/src/tokens/types.ts | 18 +++- 6 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 .changeset/olive-eggs-accept.md diff --git a/.changeset/olive-eggs-accept.md b/.changeset/olive-eggs-accept.md new file mode 100644 index 0000000000..81b1069f15 --- /dev/null +++ b/.changeset/olive-eggs-accept.md @@ -0,0 +1,13 @@ +--- +'@backstage/backend-common': minor +--- + +**BREAKING**: Server-to-server authentication tokens issued from a +`TokenManager` (specifically, `ServerTokenManager`) now has an expiry time set, +for one hour in the future from when issued. This improves security. + +It was always the case that users of this interface were expected to call its +`getToken()` method for every outgoing call and never hold on to any given token +for reuse. But this now has become even more important advice to heed, and you +should verify that you do not hold on to and reuse tokens such as these in your +own code. diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 961973b144..a66c8de735 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -599,18 +599,20 @@ export class ServerTokenManager implements TokenManager { // (undocumented) static fromConfig( config: Config, - options: { - logger: Logger; - }, + options: ServerTokenManagerOptions, ): ServerTokenManager; // (undocumented) getToken(): Promise<{ token: string; }>; - // (undocumented) static noop(): TokenManager; } +// @public +export interface ServerTokenManagerOptions { + logger: Logger; +} + // @public export type ServiceBuilder = { loadConfig(config: Config): ServiceBuilder; @@ -669,10 +671,8 @@ export interface StatusCheckHandlerOptions { // @public export interface TokenManager { - // (undocumented) - authenticate: (token: string) => Promise; - // (undocumented) - getToken: () => Promise<{ + authenticate(token: string): Promise; + getToken(): Promise<{ token: string; }>; } diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 0ff7bfb316..57435fe486 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '../logging/voidLogger'; + import { ConfigReader } from '@backstage/config'; -import { ServerTokenManager } from './ServerTokenManager'; -import { Logger } from 'winston'; import * as jose from 'jose'; +import { Logger } from 'winston'; +import { getVoidLogger } from '../logging/voidLogger'; +import { ServerTokenManager } from './ServerTokenManager'; import { TokenManager } from './types'; const emptyConfig = new ConfigReader({}); @@ -35,6 +36,7 @@ describe('ServerTokenManager', () => { afterEach(() => { process.env = env; + jest.useRealTimers(); }); describe('getToken', () => { @@ -166,6 +168,28 @@ describe('ServerTokenManager', () => { /invalid server token/i, ); }); + + it('should throw for expired tokens', async () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2020-02-02T02:00:00.0000000Z')); + + const tokenManager = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); + + const { token } = await tokenManager.getToken(); + await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); + + jest.setSystemTime(new Date('2020-02-02T02:59:00.0000000Z')); + await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); + + jest.setSystemTime(new Date('2020-02-02T03:00:01.0000000Z')); + await expect( + tokenManager.authenticate(token), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '"Invalid server token: JWTExpired: \\"exp\\" claim timestamp check failed"', + ); + }); }); describe('fromConfig', () => { @@ -242,7 +266,7 @@ describe('ServerTokenManager', () => { }); }); - describe('ServerTokenManager.noop', () => { + describe('noop', () => { let noopTokenManager: TokenManager; beforeEach(() => { diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 484b99ca4d..ba6a276421 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -14,12 +14,19 @@ * limitations under the License. */ -import { base64url, generateSecret, SignJWT, jwtVerify, exportJWK } from 'jose'; import { Config } from '@backstage/config'; -import { AuthenticationError } from '@backstage/errors'; -import { TokenManager } from './types'; +import { AuthenticationError, NotAllowedError } from '@backstage/errors'; +import { base64url, exportJWK, generateSecret, jwtVerify, SignJWT } from 'jose'; import { Logger } from 'winston'; +import { TokenManager } from './types'; +const TOKEN_ALG = 'HS256'; +const TOKEN_SUB = 'backstage-server'; + +/** + * A token manager that issues static dummy tokens and never fails + * authentication. This can be useful for testing. + */ class NoopTokenManager implements TokenManager { public readonly isInsecureServerTokenManager: boolean = true; @@ -30,6 +37,18 @@ class NoopTokenManager implements TokenManager { async authenticate() {} } +/** + * Options for {@link ServerTokenManager}. + * + * @public + */ +export interface ServerTokenManagerOptions { + /** + * The logger to use. + */ + logger: Logger; +} + /** * Creates and validates tokens for use during backend-to-backend * authentication. @@ -37,44 +56,48 @@ class NoopTokenManager implements TokenManager { * @public */ export class ServerTokenManager implements TokenManager { + private readonly options: ServerTokenManagerOptions; private verificationKeys: Uint8Array[]; private signingKey: Uint8Array; private privateKeyPromise?: Promise; - private logger: Logger; + /** + * Creates a token manager that issues static dummy tokens and never fails + * authentication. This can be useful for testing. + */ static noop(): TokenManager { return new NoopTokenManager(); } - static fromConfig(config: Config, options: { logger: Logger }) { - const { logger } = options; - + static fromConfig(config: Config, options: ServerTokenManagerOptions) { const keys = config.getOptionalConfigArray('backend.auth.keys'); if (keys?.length) { return new ServerTokenManager( keys.map(key => key.getString('secret')), - logger, + options, ); } + if (process.env.NODE_ENV !== 'development') { throw new Error( 'You must configure at least one key in backend.auth.keys for production.', ); } + // For development, if a secret has not been configured, we auto generate a secret instead of throwing. - logger.warn( + options.logger.warn( 'Generated a secret for backend-to-backend authentication: DEVELOPMENT USE ONLY.', ); - return new ServerTokenManager([], logger); + return new ServerTokenManager([], options); } - private constructor(secrets: string[], logger: Logger) { + private constructor(secrets: string[], options: ServerTokenManagerOptions) { if (!secrets.length && process.env.NODE_ENV !== 'development') { throw new Error( 'No secrets provided when constructing ServerTokenManager', ); } - this.logger = logger; + this.options = options; this.verificationKeys = secrets.map(s => base64url.decode(s)); this.signingKey = this.verificationKeys[0]; } @@ -86,11 +109,13 @@ export class ServerTokenManager implements TokenManager { 'Key generation is not supported outside of the dev environment', ); } + if (this.privateKeyPromise) { return this.privateKeyPromise; } + const promise = (async () => { - const secret = await generateSecret('HS256'); + const secret = await generateSecret(TOKEN_ALG); const jwk = await exportJWK(secret); this.verificationKeys.push(base64url.decode(jwk.k ?? '')); this.signingKey = this.verificationKeys[0]; @@ -98,13 +123,15 @@ export class ServerTokenManager implements TokenManager { })(); try { - // If we fail to generate a new key, we need to clear the state so that - // the next caller will try to generate another key. + this.privateKeyPromise = promise; await promise; } catch (error) { - this.logger.error(`Failed to generate new key, ${error}`); + // If we fail to generate a new key, we need to clear the state so that + // the next caller will try to generate another key. + this.options.logger.error(`Failed to generate new key, ${error}`); delete this.privateKeyPromise; } + return promise; } @@ -112,26 +139,39 @@ export class ServerTokenManager implements TokenManager { if (!this.verificationKeys.length) { await this.generateKeys(); } - const sub = 'backstage-server'; - const jwt = await new SignJWT({ alg: 'HS256' }) - .setProtectedHeader({ alg: 'HS256', sub: sub }) - .setSubject('backstage-server') + + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: TOKEN_ALG }) + .setSubject(TOKEN_SUB) + .setExpirationTime('1h') .sign(this.signingKey); + return { token: jwt }; } async authenticate(token: string): Promise { let verifyError = undefined; + for (const key of this.verificationKeys) { try { - await jwtVerify(token, key); - // If the verify succeeded, return + const result = await jwtVerify(token, key); + if (result.protectedHeader.alg !== TOKEN_ALG) { + throw new NotAllowedError( + `Illegal alg "${result.protectedHeader.alg}"`, + ); + } + if (result.payload.sub !== TOKEN_SUB) { + throw new NotAllowedError(`Illegal sub "${result.payload.sub}"`); + } + // TODO(freben): Reject missing payload.exp in the future as well + // The jose library does NOT throw if exp is not set in the token, but DOES throw if exp is set and expired return; } catch (e) { // Catch the verify exception and continue verifyError = e; } } + throw new AuthenticationError(`Invalid server token: ${verifyError}`); } } diff --git a/packages/backend-common/src/tokens/index.ts b/packages/backend-common/src/tokens/index.ts index 43ff12e597..fe1b56df80 100644 --- a/packages/backend-common/src/tokens/index.ts +++ b/packages/backend-common/src/tokens/index.ts @@ -15,4 +15,5 @@ */ export { ServerTokenManager } from './ServerTokenManager'; +export type { ServerTokenManagerOptions } from './ServerTokenManager'; export type { TokenManager } from './types'; diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index 1fea018db9..2fa771b7e2 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -20,6 +20,20 @@ * @public */ export interface TokenManager { - getToken: () => Promise<{ token: string }>; - authenticate: (token: string) => Promise; + /** + * Fetches a valid token. + * + * @remarks + * + * Tokens are valid for roughly one hour; the actual deadline is set in the + * payload `exp` claim. Never hold on to tokens for reuse; always ask for a + * new one for each outgoing request. This ensures that you always get a + * valid, fresh one. + */ + getToken(): Promise<{ token: string }>; + + /** + * Validates a given token. + */ + authenticate(token: string): Promise; } From 4874ebc2afa42531397bbce4cbeda43b21bdcff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 4 May 2022 15:26:41 +0200 Subject: [PATCH 013/151] add caching as well MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/tokens/ServerTokenManager.test.ts | 29 +++++++++----- .../src/tokens/ServerTokenManager.ts | 38 +++++++++++++++---- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 57435fe486..2827634aac 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -169,26 +169,37 @@ describe('ServerTokenManager', () => { ); }); - it('should throw for expired tokens', async () => { - jest.useFakeTimers('modern'); - jest.setSystemTime(new Date('2020-02-02T02:00:00.0000000Z')); + it('should throw for expired tokens, and re-issue new ones', async () => { + jest.useFakeTimers(); const tokenManager = ServerTokenManager.fromConfig(configWithSecret, { logger, }); - const { token } = await tokenManager.getToken(); - await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); + const { token: token1 } = await tokenManager.getToken(); + await expect(tokenManager.authenticate(token1)).resolves.not.toThrow(); - jest.setSystemTime(new Date('2020-02-02T02:59:00.0000000Z')); - await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); + // Less than ten minutes before expiry, it still returns the same token + jest.advanceTimersByTime(49 * 60 * 1000); + const { token: token1Again } = await tokenManager.getToken(); + expect(token1).toEqual(token1Again); + await expect(tokenManager.authenticate(token1)).resolves.not.toThrow(); - jest.setSystemTime(new Date('2020-02-02T03:00:01.0000000Z')); + // Right before the expiry, the old ones are still valid but returning a new token + jest.advanceTimersByTime(10 * 60 * 1000); + const { token: token2 } = await tokenManager.getToken(); + expect(token1).not.toEqual(token2); + await expect(tokenManager.authenticate(token1)).resolves.not.toThrow(); + await expect(tokenManager.authenticate(token2)).resolves.not.toThrow(); + + // After expiry, the newest one is still valid + jest.advanceTimersByTime(2 * 60 * 1000); await expect( - tokenManager.authenticate(token), + tokenManager.authenticate(token1), ).rejects.toThrowErrorMatchingInlineSnapshot( '"Invalid server token: JWTExpired: \\"exp\\" claim timestamp check failed"', ); + await expect(tokenManager.authenticate(token2)).resolves.not.toThrow(); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index ba6a276421..65354f270f 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -17,11 +17,13 @@ import { Config } from '@backstage/config'; import { AuthenticationError, NotAllowedError } from '@backstage/errors'; import { base64url, exportJWK, generateSecret, jwtVerify, SignJWT } from 'jose'; +import { DateTime, Duration } from 'luxon'; import { Logger } from 'winston'; import { TokenManager } from './types'; const TOKEN_ALG = 'HS256'; const TOKEN_SUB = 'backstage-server'; +const TOKEN_EXPIRY = Duration.fromObject({ hours: 1 }); /** * A token manager that issues static dummy tokens and never fails @@ -57,9 +59,10 @@ export interface ServerTokenManagerOptions { */ export class ServerTokenManager implements TokenManager { private readonly options: ServerTokenManagerOptions; - private verificationKeys: Uint8Array[]; + private readonly verificationKeys: Uint8Array[]; private signingKey: Uint8Array; - private privateKeyPromise?: Promise; + private privateKeyPromise: Promise | undefined; + private currentTokenPromise: Promise<{ token: string }> | undefined; /** * Creates a token manager that issues static dummy tokens and never fails @@ -140,13 +143,32 @@ export class ServerTokenManager implements TokenManager { await this.generateKeys(); } - const jwt = await new SignJWT({}) - .setProtectedHeader({ alg: TOKEN_ALG }) - .setSubject(TOKEN_SUB) - .setExpirationTime('1h') - .sign(this.signingKey); + if (this.currentTokenPromise) { + return this.currentTokenPromise; + } - return { token: jwt }; + const result = Promise.resolve().then(async () => { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: TOKEN_ALG }) + .setSubject(TOKEN_SUB) + .setExpirationTime(DateTime.now().plus(TOKEN_EXPIRY).toUnixInteger()) + .sign(this.signingKey); + return { token: jwt }; + }); + + this.currentTokenPromise = result; + + result + .then(() => { + setTimeout(() => { + this.currentTokenPromise = undefined; + }, TOKEN_EXPIRY.minus({ minutes: 5 }).toMillis()); + }) + .catch(() => { + this.currentTokenPromise = undefined; + }); + + return result; } async authenticate(token: string): Promise { From 69bf1a83edb1f747d20e36f8416b2bd3b66a87b0 Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Wed, 4 May 2022 14:51:14 -0700 Subject: [PATCH 014/151] refactor: switched to a reduce, and now returning the value as well as the path array Signed-off-by: Mark David Avery --- .../request/parseEntityTransformParams.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index e09289d5c0..9bbe9e7861 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -20,22 +20,20 @@ import lodash from 'lodash'; import { RecursivePartial } from '../../util/RecursivePartial'; import { parseStringsParam } from './common'; -function getPathArray(input: Entity, field: string) { - const pathArray = []; - let currentPathPart = ''; +function getPathArrayAndValue(input: Entity, field: string) { + return field.split('.').reduce( + ([pathArray, inputSubset], pathPart, index, fieldParts) => { + if (Object.hasOwn(inputSubset, pathPart)) { + return [pathArray.concat(pathPart), inputSubset[pathPart]]; + } else if (fieldParts[index + 1] !== undefined) { + fieldParts[index + 1] = `${pathPart}.${fieldParts[index + 1]}`; + return [pathArray, inputSubset]; + } - for (const pathPart of field.split('.')) { - currentPathPart += pathPart; - - if (lodash.has(input, pathArray.concat(currentPathPart))) { - pathArray.push(currentPathPart); - currentPathPart = ''; - } else { - currentPathPart += '.'; - } - } - - return pathArray; + return [pathArray, undefined]; + }, + [[] as unknown as string, input as any], + ); } export function parseEntityTransformParams( @@ -64,9 +62,8 @@ export function parseEntityTransformParams( const output: RecursivePartial = {}; for (const field of fields) { - const pathArray = getPathArray(input, field); + const [pathArray, value] = getPathArrayAndValue(input, field); - const value = lodash.get(input, pathArray); if (value !== undefined) { lodash.set(output, pathArray, value); } From 7bc5db06c8412a7dc2a5ff99cbe089fe90be88ed Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Wed, 4 May 2022 14:54:26 -0700 Subject: [PATCH 015/151] fix: had set the wrong type for the return value Signed-off-by: Mark David Avery --- .../src/service/request/parseEntityTransformParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index 9bbe9e7861..a7ee3cad9b 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -32,7 +32,7 @@ function getPathArrayAndValue(input: Entity, field: string) { return [pathArray, undefined]; }, - [[] as unknown as string, input as any], + [[] as string[], input as any], ); } From 4733f6766ece1c76c0fe0273b05560ffaca2926b Mon Sep 17 00:00:00 2001 From: Jan Brink Date: Thu, 5 May 2022 12:56:52 +0200 Subject: [PATCH 016/151] fix: replace email overflow by either showing email with ellipsis or showing full wrapping email on mouse over Signed-off-by: Jan Brink --- .../Group/MembersList/MembersListCard.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index 14f02c189f..7e5b683bf1 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -59,6 +59,17 @@ const useStyles = makeStyles((theme: Theme) => flex: '1', minWidth: '0px', }, + email: { + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + display: 'inline-block', + maxWidth: '100%', + '&:hover': { + overflow: 'visible', + whiteSpace: 'normal', + }, + }, }), ); @@ -88,7 +99,13 @@ const MemberComponent = (props: { member: UserEntity }) => { top: '-2rem', }} /> - + { {profile?.email && ( - {profile.email} + + {profile.email} + )} {description && ( {description} From dfee1002d7f0bfb5cfa095ae6940d3b058f9acfe Mon Sep 17 00:00:00 2001 From: Jan Brink Date: Thu, 5 May 2022 15:12:00 +0200 Subject: [PATCH 017/151] fix(frontend): add changeset Signed-off-by: Jan Brink --- .changeset/dirty-ducks-suffer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dirty-ducks-suffer.md diff --git a/.changeset/dirty-ducks-suffer.md b/.changeset/dirty-ducks-suffer.md new file mode 100644 index 0000000000..bcfef8550b --- /dev/null +++ b/.changeset/dirty-ducks-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Fixed overflow bug of name and email on EntitiyMembersListCard component which can occur on specific 'screen width' + ’character length' combinations From 74c218a54f0451211235250787bffaa9b5554eaf Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Thu, 5 May 2022 07:11:32 -0700 Subject: [PATCH 018/151] fix: node 14 is still supported and does not have hasOwn Signed-off-by: Mark David Avery --- .../src/service/request/parseEntityTransformParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index a7ee3cad9b..5d11ea84b8 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -23,7 +23,7 @@ import { parseStringsParam } from './common'; function getPathArrayAndValue(input: Entity, field: string) { return field.split('.').reduce( ([pathArray, inputSubset], pathPart, index, fieldParts) => { - if (Object.hasOwn(inputSubset, pathPart)) { + if (inputSubset.hasOwnProperty(pathPart)) { return [pathArray.concat(pathPart), inputSubset[pathPart]]; } else if (fieldParts[index + 1] !== undefined) { fieldParts[index + 1] = `${pathPart}.${fieldParts[index + 1]}`; From 2025d7c123ead5fd3acfda9208f1d0ee3c0bdf3b Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Thu, 5 May 2022 20:56:08 -0400 Subject: [PATCH 019/151] fix(sidebar): fix various styling Signed-off-by: Phil Kuang --- .changeset/shiny-moles-study.md | 5 + .changeset/wise-doors-hammer.md | 5 + packages/core-components/api-report.md | 4 +- .../src/layout/Sidebar/Page.tsx | 8 +- .../src/layout/Sidebar/SidebarSubmenuItem.tsx | 121 ++++++++++-------- .../MyGroupsSidebarItem.tsx | 11 +- 6 files changed, 93 insertions(+), 61 deletions(-) create mode 100644 .changeset/shiny-moles-study.md create mode 100644 .changeset/wise-doors-hammer.md diff --git a/.changeset/shiny-moles-study.md b/.changeset/shiny-moles-study.md new file mode 100644 index 0000000000..59ce7d560b --- /dev/null +++ b/.changeset/shiny-moles-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Properly highlight `SidebarSubmenuItem` dropdown items on hover, use ellipsis styling on long labels in `SidebarSubmenu`, allow `icon` and `to` properties to be optional on `SidebarSubmenuItem`, and fix `SidebarPage` padding to be responsive to pinned state diff --git a/.changeset/wise-doors-hammer.md b/.changeset/wise-doors-hammer.md new file mode 100644 index 0000000000..68160a3b64 --- /dev/null +++ b/.changeset/wise-doors-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Include namespace in `MyGroupSidebarItem` if not default and remove root item routing if there are multiple groups diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index a195d62496..727a50926c 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -1084,8 +1084,8 @@ export type SidebarSubmenuItemDropdownItem = { // @public export type SidebarSubmenuItemProps = { title: string; - to: string; - icon: IconComponent; + to?: string; + icon?: IconComponent; dropdownItems?: SidebarSubmenuItemDropdownItem[]; }; diff --git a/packages/core-components/src/layout/Sidebar/Page.tsx b/packages/core-components/src/layout/Sidebar/Page.tsx index 9ce4a9f997..9bf8105f81 100644 --- a/packages/core-components/src/layout/Sidebar/Page.tsx +++ b/packages/core-components/src/layout/Sidebar/Page.tsx @@ -37,20 +37,20 @@ const useStyles = makeStyles< { sidebarConfig: SidebarConfig; isPinned: boolean } >( theme => ({ - root: props => ({ + root: { width: '100%', transition: 'padding-left 0.1s ease-out', isolation: 'isolate', [theme.breakpoints.up('sm')]: { - paddingLeft: () => + paddingLeft: props => props.isPinned ? props.sidebarConfig.drawerWidthOpen : props.sidebarConfig.drawerWidthClosed, }, [theme.breakpoints.down('xs')]: { - paddingBottom: props.sidebarConfig.mobileSidebarHeight, + paddingBottom: props => props.sidebarConfig.mobileSidebarHeight, }, - }), + }, content: { zIndex: 0, isolation: 'isolate', diff --git a/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx b/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx index 5c9dce010d..41f1a1e287 100644 --- a/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx +++ b/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx @@ -16,6 +16,7 @@ import React, { useContext, useState } from 'react'; import { resolvePath, useLocation, useResolvedPath } from 'react-router-dom'; import { makeStyles } from '@material-ui/core/styles'; +import Tooltip from '@material-ui/core/Tooltip'; import Typography from '@material-ui/core/Typography'; import { Link } from '../../components/Link'; import { IconComponent } from '@backstage/core-plugin-api'; @@ -55,6 +56,9 @@ const useStyles = makeStyles( margin: 14, marginLeft: 7, fontSize: 14, + whiteSpace: 'nowrap', + overflow: 'hidden', + 'text-overflow': 'ellipsis', }, dropdownArrow: { position: 'absolute', @@ -68,16 +72,19 @@ const useStyles = makeStyles( dropdownItem: { width: '100%', padding: '10px 0 10px 0', + '&:hover': { + background: '#6f6f6f', + color: theme.palette.navigation.selectedColor, + }, }, textContent: { color: theme.palette.navigation.color, - display: 'flex', - justifyContent: 'center', - [theme.breakpoints.down('xs')]: { - display: 'block', - paddingLeft: theme.spacing(4), - }, + paddingLeft: theme.spacing(4), + paddingRight: theme.spacing(1), fontSize: '14px', + whiteSpace: 'nowrap', + overflow: 'hidden', + 'text-overflow': 'ellipsis', }, }), { name: 'BackstageSidebarSubmenuItem' }, @@ -106,8 +113,8 @@ export type SidebarSubmenuItemDropdownItem = { */ export type SidebarSubmenuItemProps = { title: string; - to: string; - icon: IconComponent; + to?: string; + icon?: IconComponent; dropdownItems?: SidebarSubmenuItemDropdownItem[]; }; @@ -123,7 +130,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => { const closeSubmenu = () => { setIsHoveredOn(false); }; - const toLocation = useResolvedPath(to); + const toLocation = useResolvedPath(to ?? ''); const currentLocation = useLocation(); let isActive = isLocationMatch(currentLocation, toLocation); @@ -139,39 +146,47 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => { }); return (
- + + + {dropdownItems && showDropDown && (
{dropdownItems.map((object, key) => ( - e.stopPropagation()} + - - {object.title} - - + e.stopPropagation()} + > + + {object.title} + + + ))}
)} @@ -181,21 +196,23 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => { return (
- e.stopPropagation()} - > - - - {title} - - + + e.stopPropagation()} + > + {Icon && } + + {title} + + +
); }; diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx index c2473670cf..5a3cc42628 100644 --- a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx @@ -15,6 +15,7 @@ */ import React from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { SidebarItem, SidebarSubmenu, @@ -31,6 +32,7 @@ import { catalogApiRef, CatalogApi, entityRouteRef, + humanizeEntityRef, } from '@backstage/plugin-catalog-react'; import { getCompoundEntityRef } from '@backstage/catalog-model'; @@ -80,15 +82,18 @@ export const MyGroupsSidebarItem = (props: { // Member of more than one group return ( - + {groups?.map(function groupsMap(group) { return ( ); })} From 9e0a6ef4facb8d960433f5bae04792c1098a993a Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 4 May 2022 16:32:04 +0200 Subject: [PATCH 020/151] feat(techdocs): add addon settings location Signed-off-by: Camila Belo --- docs/features/techdocs/addons.md | 2 ++ plugins/techdocs-react/src/types.ts | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/docs/features/techdocs/addons.md b/docs/features/techdocs/addons.md index 0fba3b92e3..dbcc3f3178 100644 --- a/docs/features/techdocs/addons.md +++ b/docs/features/techdocs/addons.md @@ -35,6 +35,8 @@ representative of physical spaces in the TechDocs UI: line as the title. - `Subheader`: For Addons that sit below the header but above all content. This is a great location for tooling/configuration of TechDocs display. +- `Settings`: These addons are items added to the settings menu list and are designed to make + the reader experience customizable, for example accessibility options. - `PrimarySidebar`: Left of the content, above of the navigation. - `SecondarySidebar`: Right of the content, above the table of contents. - `Content`: A special location intended for Addons which augment the diff --git a/plugins/techdocs-react/src/types.ts b/plugins/techdocs-react/src/types.ts index de74d4f284..e147cd1dd1 100644 --- a/plugins/techdocs-react/src/types.ts +++ b/plugins/techdocs-react/src/types.ts @@ -53,6 +53,12 @@ export const TechDocsAddonLocations = Object.freeze({ */ Subheader: 'Subheader', + /** + * These addons are items added to the settings menu list and are designed to make + * the reader experience customizable, for example accessibility options + */ + Settings: 'Settings', + /** * These addons appear left of the content and above the navigation. */ From 10b8e973ef001af1ebd93b6565d9ad59db20a0e8 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 4 May 2022 17:00:54 +0200 Subject: [PATCH 021/151] feat(techdocs): add settings menu list Signed-off-by: Camila Belo --- .../TechDocsReaderPageSubheader.tsx | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx index e0eb0ad2b0..a0013e81ab 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageSubheader/TechDocsReaderPageSubheader.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import React from 'react'; +import React, { MouseEvent, useState, useCallback } from 'react'; -import { Box, makeStyles, Toolbar, ToolbarProps } from '@material-ui/core'; +import { + Box, + makeStyles, + Toolbar, + ToolbarProps, + Menu, + Tooltip, + IconButton, +} from '@material-ui/core'; +import SettingsIcon from '@material-ui/icons/Settings'; import { TechDocsAddonLocations as locations, @@ -44,31 +53,67 @@ export const TechDocsReaderPageSubheader = ({ toolbarProps?: ToolbarProps; }) => { const classes = useStyles(); + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = useCallback((event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + const { entityMetadata: { value: entityMetadata, loading: entityMetadataLoading }, } = useTechDocsReaderPage(); + const addons = useTechDocsAddons(); + const subheaderAddons = addons.renderComponentsByLocation( locations.Subheader, ); - if (!subheaderAddons) return null; + const settingsAddons = addons.renderComponentsByLocation(locations.Settings); + + if (!subheaderAddons && !settingsAddons) return null; // No entity metadata = 404. Don't render subheader on 404. if (entityMetadataLoading === false && !entityMetadata) return null; return ( - {subheaderAddons && ( - - {subheaderAddons} - - )} + + {subheaderAddons} + {settingsAddons ? ( + <> + + + + + + + {settingsAddons} + + + ) : null} + ); }; From d601cb0283786ca9ddaa94b96a8c20ac52ea6ce3 Mon Sep 17 00:00:00 2001 From: Michael Wizner Date: Fri, 6 May 2022 13:01:03 +0100 Subject: [PATCH 022/151] [Catalog Graph] Fix kind filter in dev app Signed-off-by: Michael Wizner --- plugins/catalog-graph/README.md | 6 +++ plugins/catalog-graph/dev/index.tsx | 62 ++++++++++++++++++++++++++--- plugins/catalog-graph/package.json | 3 +- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/plugins/catalog-graph/README.md b/plugins/catalog-graph/README.md index 0034cf0c3b..33dee92935 100644 --- a/plugins/catalog-graph/README.md +++ b/plugins/catalog-graph/README.md @@ -83,3 +83,9 @@ To use the catalog graph plugin, you have to add some things to your Backstage a ``` + +## Development + +Run `yarn` in the root of this plugin to install all dependencies and then `yarn start` to run a [development version](./dev/index.tsx) of this plugin. + +![dev](https://user-images.githubusercontent.com/1190768/167130527-14d787ce-510d-408a-8f93-45bb94b3a9af.png) diff --git a/plugins/catalog-graph/dev/index.tsx b/plugins/catalog-graph/dev/index.tsx index 7d772702a1..1ef9f676de 100644 --- a/plugins/catalog-graph/dev/index.tsx +++ b/plugins/catalog-graph/dev/index.tsx @@ -13,14 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GetEntitiesResponse } from '@backstage/catalog-client'; import { - Entity, + GetEntitiesResponse, + GetEntityFacetsRequest, + GetEntityFacetsResponse, +} from '@backstage/catalog-client'; +import { CompoundEntityRef, DEFAULT_NAMESPACE, + Entity, RELATION_API_CONSUMED_BY, RELATION_API_PROVIDED_BY, RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, RELATION_HAS_PART, RELATION_OWNED_BY, RELATION_OWNER_OF, @@ -30,22 +36,24 @@ import { } from '@backstage/catalog-model'; import { Content, Header, Page } from '@backstage/core-components'; import { createDevApp } from '@backstage/dev-utils'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; import { CatalogApi, catalogApiRef, EntityProvider, } from '@backstage/plugin-catalog-react'; +import { JsonObject } from '@backstage/types'; import { Grid } from '@material-ui/core'; +import _ from 'lodash'; import React from 'react'; import { CatalogGraphPage, catalogGraphPlugin, EntityCatalogGraphCard, } from '../src'; -import { CatalogEntityPage } from '@backstage/plugin-catalog'; type DataRelation = [string, string, string]; -type DataEntity = [string, string, DataRelation[]]; +type DataEntity = [string, string, DataRelation[], JsonObject?]; const entities = ( [ @@ -75,8 +83,19 @@ const entities = ( [RELATION_OWNED_BY, 'Group', 'team-a'], [RELATION_PART_OF, 'System', 'wayback'], [RELATION_PROVIDES_API, 'API', 'wayback-api'], + [RELATION_DEPENDS_ON, 'Resource', 'wayback-archive-storage'], ], ], + [ + 'Resource', + 'wayback-archive-storage', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_DEPENDENCY_OF, 'Component', 'wayback-archive'], + ], + { type: 's3-bucket' }, + ], [ 'Component', 'wayback-search', @@ -84,8 +103,19 @@ const entities = ( [RELATION_OWNED_BY, 'Group', 'team-a'], [RELATION_PART_OF, 'System', 'wayback'], [RELATION_CONSUMES_API, 'API', 'wayback-api'], + [RELATION_DEPENDS_ON, 'Resource', 'wayback-search-db'], ], ], + [ + 'Resource', + 'wayback-search-db', + [ + [RELATION_OWNED_BY, 'Group', 'team-a'], + [RELATION_PART_OF, 'System', 'wayback'], + [RELATION_DEPENDENCY_OF, 'Component', 'wayback-search'], + ], + { type: 'database' }, + ], [ 'API', 'wayback-api', @@ -101,7 +131,9 @@ const entities = ( 'team-a', [ [RELATION_OWNER_OF, 'Component', 'wayback-archive'], + [RELATION_OWNER_OF, 'Resource', 'wayback-archive-storage'], [RELATION_OWNER_OF, 'Component', 'wayback-search'], + [RELATION_OWNER_OF, 'Resource', 'wayback-search-db'], [RELATION_OWNER_OF, 'API', 'wayback-api'], [RELATION_OWNER_OF, 'Domain', 'wayback'], [RELATION_OWNER_OF, 'System', 'wayback'], @@ -109,7 +141,7 @@ const entities = ( ], ] as DataEntity[] ).reduce((o, d) => { - const [kind, name, relations] = d; + const [kind, name, relations, spec] = d; const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -117,6 +149,7 @@ const entities = ( metadata: { name, }, + spec: spec, relations: relations.map(([type, k, n]) => ({ target: { kind: k, name: n, namespace: DEFAULT_NAMESPACE }, targetRef: stringifyEntityRef({ @@ -149,6 +182,25 @@ createDevApp() async getEntities(): Promise { return { items: Object.values(entities) }; }, + async getEntityFacets( + request: GetEntityFacetsRequest, + ): Promise { + if (request.facets.includes('kind')) { + const kinds: [string, number][] = _.chain(entities) + .map(e => e.kind) + .groupBy(k => k) + .mapValues(v => v.length) + .toPairs() + .value(); + const kindFacet = kinds.map(([kind, count]) => ({ + value: kind, + count: count, + })); + + return { facets: { kind: kindFacet } }; + } + return { facets: {} }; + }, } as Partial as unknown as CatalogApi; }, }) diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json index 8b58b60225..139cac3dc9 100644 --- a/plugins/catalog-graph/package.json +++ b/plugins/catalog-graph/package.json @@ -46,10 +46,11 @@ }, "devDependencies": { "@backstage/cli": "^0.17.1-next.1", - "@backstage/plugin-catalog": "^1.2.0-next.1", "@backstage/core-app-api": "^1.0.2-next.0", "@backstage/dev-utils": "^1.0.2-next.1", + "@backstage/plugin-catalog": "^1.2.0-next.1", "@backstage/test-utils": "^1.1.0-next.1", + "@backstage/types": "^1.0.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/react-hooks": "^8.0.0", From 3e8bf77e3f1a5aca68819f0fe19e9d86f7260ae8 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 6 May 2022 14:01:54 +0000 Subject: [PATCH 023/151] chore(deps): update github/codeql-action action to v2 Signed-off-by: Renovate Bot --- .github/workflows/sync_snyk-monitor.yml | 2 +- .github/workflows/verify_codeql.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 1c2390bfc4..d6adca927e 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -45,6 +45,6 @@ jobs: env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - name: Upload Snyk report - uses: github/codeql-action/upload-sarif@v1 + uses: github/codeql-action/upload-sarif@v2 with: sarif_file: snyk.sarif diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index 2be2d0804f..f3417ac582 100644 --- a/.github/workflows/verify_codeql.yml +++ b/.github/workflows/verify_codeql.yml @@ -43,7 +43,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 From aa70176569cb9b4d4576f89843de7a7cb3d3fb78 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 6 May 2022 14:03:05 +0000 Subject: [PATCH 024/151] chore(deps): update jamesives/github-pages-deploy-action action to v4 Signed-off-by: Renovate Bot --- .github/workflows/deploy_microsite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy_microsite.yml b/.github/workflows/deploy_microsite.yml index 16e738abda..86bc72517c 100644 --- a/.github/workflows/deploy_microsite.yml +++ b/.github/workflows/deploy_microsite.yml @@ -55,7 +55,7 @@ jobs: run: ls microsite/build/backstage && ls microsite/build/backstage/storybook - name: Deploy both microsite and storybook to gh-pages - uses: JamesIves/github-pages-deploy-action@3.7.1 + uses: JamesIves/github-pages-deploy-action@v4.3.3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages From 95598f4fd5aa8b005414896de68f2d74bf1409bb Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 6 May 2022 14:30:36 +0000 Subject: [PATCH 025/151] chore(deps): update dependency @testing-library/user-event to v14 Signed-off-by: Renovate Bot --- .changeset/renovate-9008399.md | 5 +++++ plugins/codescene/package.json | 2 +- yarn.lock | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/renovate-9008399.md diff --git a/.changeset/renovate-9008399.md b/.changeset/renovate-9008399.md new file mode 100644 index 0000000000..64e1dd07a8 --- /dev/null +++ b/.changeset/renovate-9008399.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-codescene': patch +--- + +Updated dependency `@testing-library/user-event` to `^14.0.0`. diff --git a/plugins/codescene/package.json b/plugins/codescene/package.json index a28973c9c4..a6a3f8f130 100644 --- a/plugins/codescene/package.json +++ b/plugins/codescene/package.json @@ -44,7 +44,7 @@ "@backstage/test-utils": "^1.1.0-next.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", - "@testing-library/user-event": "^13.1.8", + "@testing-library/user-event": "^14.0.0", "@types/jest": "*", "@types/node": "*", "msw": "^0.35.0", diff --git a/yarn.lock b/yarn.lock index fe194f4114..dc5095dd1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4014,7 +4014,7 @@ react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" -"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2", "@material-ui/core@^4.9.13": +"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2", "@material-ui/core@^4.9.10", "@material-ui/core@^4.9.13": version "4.12.4" resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz#4ac17488e8fcaf55eb6a7f5efb2a131e10138a73" integrity sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ== @@ -4061,7 +4061,7 @@ prop-types "^15.7.2" react-is "^16.8.0 || ^17.0.0" -"@material-ui/lab@^4.0.0-alpha.60": +"@material-ui/lab@^4.0.0-alpha.57", "@material-ui/lab@^4.0.0-alpha.60": version "4.0.0-alpha.61" resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz#9bf8eb389c0c26c15e40933cc114d4ad85e3d978" integrity sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg== From acfd217142613b52efa02f807dfa9f3b9804072e Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Fri, 6 May 2022 08:26:45 -0700 Subject: [PATCH 026/151] spec: added additional tests to account for potential lookups of keys under values Signed-off-by: Mark David Avery --- .../parseEntityTransformParams.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts index 65f85639e1..ae1c8855ea 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts @@ -100,7 +100,6 @@ describe('parseEntityTransformParams', () => { 'subite.with/dot': 'fd2.sub.dot', }, 'third-field-with.dot-again': 'fd3', - type: 't', }; expect( @@ -129,4 +128,23 @@ describe('parseEntityTransformParams', () => { }, }); }); + + it('does not return a sub key if an incorrect longer key is requested', () => { + entity.spec = { + ...entity.spec, + 'field-with.dot': 'fd1', + plain: true, + numLike: 4, + arrayLike: [4, 5], + }; + + expect( + parseEntityTransformParams({ + fields: + 'kind,spec.field-withdot.other.item,kind.other.other,spec.plain.other,spec.numLike.other,spec.arrayLike.other', + })!(entity), + ).toEqual({ + kind: 'k', + }); + }); }); From fe2d99e471e9cca7a97cb06d1769006eb65dc378 Mon Sep 17 00:00:00 2001 From: Mark David Avery Date: Fri, 6 May 2022 09:04:25 -0700 Subject: [PATCH 027/151] fix: cleaned up the tests and added in a lowdash 'hasOwn' alternative Signed-off-by: Mark David Avery --- .../parseEntityTransformParams.test.ts | 50 +++++++++++++++---- .../request/parseEntityTransformParams.ts | 2 +- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts index ae1c8855ea..3dce579ae5 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts @@ -132,19 +132,49 @@ describe('parseEntityTransformParams', () => { it('does not return a sub key if an incorrect longer key is requested', () => { entity.spec = { ...entity.spec, + strValue: 'st1', + boolValue: true, + numValue: 4, + arrValue: [4, 5], + nullValue: null, + undefValue: undefined, 'field-with.dot': 'fd1', - plain: true, - numLike: 4, - arrayLike: [4, 5], }; expect( - parseEntityTransformParams({ - fields: - 'kind,spec.field-withdot.other.item,kind.other.other,spec.plain.other,spec.numLike.other,spec.arrayLike.other', - })!(entity), - ).toEqual({ - kind: 'k', - }); + parseEntityTransformParams({ fields: 'kind,spec.strValue.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); + expect( + parseEntityTransformParams({ fields: 'kind,spec.boolValue.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); + expect( + parseEntityTransformParams({ fields: 'kind,spec.numValue.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); + expect( + parseEntityTransformParams({ fields: 'kind,spec.arrValue.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); + expect( + parseEntityTransformParams({ fields: 'kind,spec.nullValue.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); + expect( + parseEntityTransformParams({ fields: 'kind,spec.undefValue.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); + expect( + parseEntityTransformParams({ fields: 'kind,spec.field-with.dot.other' })!( + entity, + ), + ).toEqual({ kind: 'k' }); }); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index 5d11ea84b8..cef6e5ef64 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts @@ -23,7 +23,7 @@ import { parseStringsParam } from './common'; function getPathArrayAndValue(input: Entity, field: string) { return field.split('.').reduce( ([pathArray, inputSubset], pathPart, index, fieldParts) => { - if (inputSubset.hasOwnProperty(pathPart)) { + if (lodash.hasIn(inputSubset, pathPart)) { return [pathArray.concat(pathPart), inputSubset[pathPart]]; } else if (fieldParts[index + 1] !== undefined) { fieldParts[index + 1] = `${pathPart}.${fieldParts[index + 1]}`; From 69093c5f91b699611938b5be7f0817412fdb9168 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Thu, 5 May 2022 12:47:59 -0400 Subject: [PATCH 028/151] feat(starredEntitiesCard): show title if defined & hide unregistered entities Signed-off-by: Phil Kuang --- .changeset/stale-pugs-kiss.md | 5 ++ .../StarredEntities/Content.test.tsx | 42 +++++++-- .../StarredEntities/Content.tsx | 89 ++++++++++++++----- .../StarredEntities.stories.tsx | 47 +++++++++- 4 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 .changeset/stale-pugs-kiss.md diff --git a/.changeset/stale-pugs-kiss.md b/.changeset/stale-pugs-kiss.md new file mode 100644 index 0000000000..db25d1ab2a --- /dev/null +++ b/.changeset/stale-pugs-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Display entity titles in `StarredEntities` home page card (if defined) and don't show entities which no longer exist diff --git a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx index 9087ed3605..2b9fae8221 100644 --- a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx +++ b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx @@ -15,6 +15,7 @@ */ import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { + catalogApiRef, starredEntitiesApiRef, MockStarredEntitiesApi, entityRouteRef, @@ -22,14 +23,44 @@ import { import React from 'react'; import { Content } from './Content'; +const entities = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock-starred-entity', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock-starred-entity-2', + title: 'Mock Starred Entity 2!', + }, + }, +]; + describe('StarredEntitiesContent', () => { - it('should render list of tools', async () => { + it('should render list of starred entities', async () => { const mockedApi = new MockStarredEntitiesApi(); mockedApi.toggleStarred('component:default/mock-starred-entity'); mockedApi.toggleStarred('component:default/mock-starred-entity-2'); + mockedApi.toggleStarred('component:default/mock-starred-entity-3'); - const { getByText } = await renderInTestApp( - + const mockCatalogApi = { + getEntities: jest + .fn() + .mockImplementation(async () => ({ items: entities })), + }; + + const { getByText, queryByText } = await renderInTestApp( + , { @@ -40,12 +71,13 @@ describe('StarredEntitiesContent', () => { ); expect(getByText('mock-starred-entity')).toBeInTheDocument(); - expect(getByText('mock-starred-entity-2')).toBeInTheDocument(); + expect(getByText('Mock Starred Entity 2!')).toBeInTheDocument(); + expect(queryByText('mock-starred-entity-3')).not.toBeInTheDocument(); expect(getByText('mock-starred-entity').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/mock-starred-entity', ); - expect(getByText('mock-starred-entity-2').closest('a')).toHaveAttribute( + expect(getByText('Mock Starred Entity 2!').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/mock-starred-entity-2', ); diff --git a/plugins/home/src/homePageComponents/StarredEntities/Content.tsx b/plugins/home/src/homePageComponents/StarredEntities/Content.tsx index e82d915e3b..31fa00378e 100644 --- a/plugins/home/src/homePageComponents/StarredEntities/Content.tsx +++ b/plugins/home/src/homePageComponents/StarredEntities/Content.tsx @@ -15,12 +15,14 @@ */ import { + catalogApiRef, useStarredEntities, + entityRouteParams, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { parseEntityRef } from '@backstage/catalog-model'; -import { useRouteRef } from '@backstage/core-plugin-api'; -import { Link } from '@backstage/core-components'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { Link, Progress, ResponseErrorPanel } from '@backstage/core-components'; import { List, ListItem, @@ -32,6 +34,7 @@ import { } from '@material-ui/core'; import StarIcon from '@material-ui/icons/Star'; import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; /** * A component to display a list of starred entities for the user. @@ -39,9 +42,37 @@ import React from 'react'; * @public */ export const Content = () => { + const catalogApi = useApi(catalogApiRef); const catalogEntityRoute = useRouteRef(entityRouteRef); const { starredEntities, toggleStarredEntity } = useStarredEntities(); + // Grab starred entities from catalog to ensure they still exist and also retrieve display titles + const entities = useAsync(async () => { + if (!starredEntities.size) { + return []; + } + + const filter = [...starredEntities] + .map(ent => parseEntityRef(ent)) + .map(ref => ({ + kind: ref.kind, + 'metadata.namespace': ref.namespace, + 'metadata.name': ref.name, + })); + + return ( + await catalogApi.getEntities({ + filter, + fields: [ + 'kind', + 'metadata.namespace', + 'metadata.name', + 'metadata.title', + ], + }) + ).items; + }, [catalogApi, starredEntities]); + if (starredEntities.size === 0) return ( @@ -49,26 +80,40 @@ export const Content = () => { ); - return ( + if (entities.loading) { + return ; + } + + return entities.error ? ( + + ) : ( - {Array.from(starredEntities).map(entity => ( - - - - - - - toggleStarredEntity(entity)} - > - - - - - - ))} + {entities.value + ?.sort((a, b) => + (a.metadata.title ?? a.metadata.name).localeCompare( + b.metadata.title ?? b.metadata.name, + ), + ) + .map(entity => ( + + + + + + + toggleStarredEntity(entity)} + > + + + + + + ))} ); }; diff --git a/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx b/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx index 1b01820482..a5e05e56c1 100644 --- a/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx +++ b/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx @@ -17,6 +17,7 @@ import { HomePageStarredEntities } from '../../plugin'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { + catalogApiRef, starredEntitiesApiRef, MockStarredEntitiesApi, entityRouteRef, @@ -30,12 +31,56 @@ starredEntitiesApi.toggleStarred('component:default/example-starred-entity-2'); starredEntitiesApi.toggleStarred('component:default/example-starred-entity-3'); starredEntitiesApi.toggleStarred('component:default/example-starred-entity-4'); +const entities = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock-starred-entity', + title: 'Mock Starred Entity!', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock-starred-entity-2', + title: 'Mock Starred Entity 2!', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock-starred-entity-3', + title: 'Mock Starred Entity 3!', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock-starred-entity-4', + title: 'Mock Starred Entity 4!', + }, + }, +]; + +const mockCatalogApi = { + getEntities: async () => ({ items: entities }), +}; + export default { title: 'Plugins/Home/Components/StarredEntities', decorators: [ (Story: ComponentType<{}>) => wrapInTestApp( - + , { From d2c5a3232398d26a10ad2d69146202f2b5fa192d Mon Sep 17 00:00:00 2001 From: Daniel Bravo Date: Sat, 30 Apr 2022 09:30:38 -0500 Subject: [PATCH 029/151] Add oidc authProvider to kubernetes-backend plugin Signed-off-by: Daniel Bravo --- plugins/kubernetes-backend/schema.d.ts | 4 +- .../cluster-locator/ConfigClusterLocator.ts | 5 ++ .../KubernetesAuthTranslatorGenerator.test.ts | 7 ++ .../KubernetesAuthTranslatorGenerator.ts | 4 ++ .../OidcKubernetesAuthTranslator.test.ts | 65 +++++++++++++++++++ .../OidcKubernetesAuthTranslator.ts | 50 ++++++++++++++ plugins/kubernetes-backend/src/types/types.ts | 4 ++ plugins/kubernetes-common/src/types.ts | 2 +- 8 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts create mode 100644 plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index c2ac017713..8a11f7c08a 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -52,7 +52,9 @@ export interface Config { /** @visibility secret */ serviceAccountToken?: string; /** @visibility frontend */ - authProvider: 'aws' | 'google' | 'serviceAccount' | 'azure'; + authProvider: 'aws' | 'google' | 'serviceAccount' | 'azure' | 'oidc'; + /** @visibility frontend */ + oidcTokenProvider?: string; /** @visibility frontend */ skipTLSVerify?: boolean; }>; diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 5598740873..de5cac6d8d 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -64,6 +64,11 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { case 'azure': { return clusterDetails; } + case 'oidc': { + const oidcTokenProvider = c.getString('oidcTokenProvider'); + + return { oidcTokenProvider, ...clusterDetails }; + } case 'serviceAccount': { return clusterDetails; } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts index 33592b1c41..b31443cdd1 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -19,6 +19,7 @@ import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator'; import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; +import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; describe('getKubernetesAuthTranslatorInstance', () => { const sut = KubernetesAuthTranslatorGenerator; @@ -43,6 +44,12 @@ describe('getKubernetesAuthTranslatorInstance', () => { ).toBe(true); }); + it('can return an auth translator for oidc auth', () => { + const authTranslator: KubernetesAuthTranslator = + sut.getKubernetesAuthTranslatorInstance('oidc'); + expect(authTranslator instanceof OidcKubernetesAuthTranslator).toBe(true); + }); + it('throws an error when asked for an auth translator for an unsupported auth type', () => { expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow( 'authProvider "linode" has no KubernetesAuthTranslator associated with it', diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index e9a8a00ae9..4b17653359 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -20,6 +20,7 @@ import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernet import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; import { GoogleServiceAccountAuthTranslator } from './GoogleServiceAccountAuthProvider'; import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator'; +import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( @@ -41,6 +42,9 @@ export class KubernetesAuthTranslatorGenerator { case 'googleServiceAccount': { return new GoogleServiceAccountAuthTranslator(); } + case 'oidc': { + return new OidcKubernetesAuthTranslator(); + } default: { throw new Error( `authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`, diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts new file mode 100644 index 0000000000..3c8edd38b4 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2020 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 { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; +import { ClusterDetails } from '../types/types'; +import { Entity } from '@backstage/catalog-model'; + +describe('OidcKubernetesAuthTranslator tests', () => { + const at = new OidcKubernetesAuthTranslator(); + const entity: Entity = { + apiVersion: 'v1', + kind: 'service', + metadata: { name: 'test' }, + }; + const baseClusterDetails: ClusterDetails = { + name: 'test', + authProvider: 'oidc', + url: '', + }; + + it('returns cluster details with auth token', async () => { + const details = await at.decorateClusterDetailsWithAuth( + { + oidcTokenProvider: 'okta', + ...baseClusterDetails, + }, + { + auth: { okta: 'fakeToken' }, + entity, + }, + ); + + expect(details.serviceAccountToken).toBe('fakeToken'); + }); + + it('returns error when oidcTokenProvider is not configured', async () => { + await expect( + at.decorateClusterDetailsWithAuth(baseClusterDetails, { entity }), + ).rejects.toThrow( + 'oidc authProvider requires a configured oidcTokenProvider', + ); + }); + + it('returns error when token is not included in request body', async () => { + await expect( + at.decorateClusterDetailsWithAuth( + { oidcTokenProvider: 'okta', ...baseClusterDetails }, + { entity }, + ), + ).rejects.toThrow('Auth token not found under auth.okta in request body'); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..cbcbd3c6ab --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 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 { KubernetesAuthTranslator } from './types'; +import { ClusterDetails } from '../types/types'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; + +export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: KubernetesRequestBody, + ): Promise { + const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + {}, + clusterDetails, + ); + + const { oidcTokenProvider } = clusterDetails; + + if (!oidcTokenProvider || oidcTokenProvider === '') { + throw new Error( + `oidc authProvider requires a configured oidcTokenProvider`, + ); + } + + const authToken: string | undefined = requestBody.auth?.[oidcTokenProvider]; + + if (authToken) { + clusterDetailsWithAuthToken.serviceAccountToken = authToken; + } else { + throw new Error( + `Auth token not found under auth.${oidcTokenProvider} in request body`, + ); + } + return clusterDetailsWithAuthToken; + } +} diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 706582f439..e8665c57eb 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -105,6 +105,10 @@ export interface ClusterDetails { url: string; authProvider: string; serviceAccountToken?: string | undefined; + /** + * oidc provider used to get id tokens to authenticate against kubernetes + */ + oidcTokenProvider?: string | undefined; skipTLSVerify?: boolean; /** * Whether to skip the lookup to the metrics server to retrieve pod resource usage. diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index cb388bad0a..135be4099c 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -30,7 +30,7 @@ import { Entity } from '@backstage/catalog-model'; export interface KubernetesRequestBody { auth?: { - google?: string; + [key: string]: string; }; entity: Entity; } From 2f50c323fbfcf2ab4edbde6aa07cecd0c84073e5 Mon Sep 17 00:00:00 2001 From: Daniel Bravo Date: Mon, 2 May 2022 15:46:00 -0500 Subject: [PATCH 030/151] Add oidc authProvider handling to kubernetes frontend plugin Signed-off-by: Daniel Bravo --- .../OidcKubernetesAuthTranslator.test.ts | 4 +- .../OidcKubernetesAuthTranslator.ts | 4 +- .../src/service/KubernetesBuilder.ts | 1 + plugins/kubernetes-common/src/types.ts | 3 + plugins/kubernetes/src/api/types.ts | 8 +- .../src/hooks/useKubernetesObjects.ts | 10 ++- .../KubernetesAuthProviders.test.ts | 79 +++++++++++++++++++ .../KubernetesAuthProviders.ts | 26 +++++- .../OidcKubernetesAuthProvider.ts | 41 ++++++++++ plugins/kubernetes/src/plugin.ts | 26 +++++- 10 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts create mode 100644 plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts index 3c8edd38b4..1aacb7f059 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.test.ts @@ -38,7 +38,7 @@ describe('OidcKubernetesAuthTranslator tests', () => { ...baseClusterDetails, }, { - auth: { okta: 'fakeToken' }, + oidc: { okta: 'fakeToken' }, entity, }, ); @@ -60,6 +60,6 @@ describe('OidcKubernetesAuthTranslator tests', () => { { oidcTokenProvider: 'okta', ...baseClusterDetails }, { entity }, ), - ).rejects.toThrow('Auth token not found under auth.okta in request body'); + ).rejects.toThrow('Auth token not found under oidc.okta in request body'); }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts index cbcbd3c6ab..05b87f0ce0 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/OidcKubernetesAuthTranslator.ts @@ -36,13 +36,13 @@ export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator { ); } - const authToken: string | undefined = requestBody.auth?.[oidcTokenProvider]; + const authToken: string | undefined = requestBody.oidc?.[oidcTokenProvider]; if (authToken) { clusterDetailsWithAuthToken.serviceAccountToken = authToken; } else { throw new Error( - `Auth token not found under auth.${oidcTokenProvider} in request body`, + `Auth token not found under oidc.${oidcTokenProvider} in request body`, ); } return clusterDetailsWithAuthToken; diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index a3dfc8b005..18c3fdc931 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -246,6 +246,7 @@ export class KubernetesBuilder { name: cd.name, dashboardUrl: cd.dashboardUrl, authProvider: cd.authProvider, + oidcTokenProvider: cd.oidcTokenProvider, })), }); }); diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 135be4099c..a71d1feaad 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -30,6 +30,9 @@ import { Entity } from '@backstage/catalog-model'; export interface KubernetesRequestBody { auth?: { + google: string; + }; + oidc?: { [key: string]: string; }; entity: Entity; diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 8b95dd820c..7c5943a6eb 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -28,5 +28,11 @@ export interface KubernetesApi { getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise; - getClusters(): Promise<{ name: string; authProvider: string }[]>; + getClusters(): Promise< + { + name: string; + authProvider: string; + oidcTokenProvider?: string | undefined; + }[] + >; } diff --git a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts index d9aca875c7..8e2597c1d2 100644 --- a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts +++ b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts @@ -53,8 +53,16 @@ export const useKubernetesObjects = ( } const authProviders: string[] = [ - ...new Set(clusters.map(c => c.authProvider)), + ...new Set( + clusters.map( + c => + `${c.authProvider}${ + c.oidcTokenProvider ? `.${c.oidcTokenProvider}` : '' + }`, + ), + ), ]; + // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider let requestBody: KubernetesRequestBody = { entity, diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts new file mode 100644 index 0000000000..ffeaa6137c --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2020 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 { OAuthApi, OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { KubernetesAuthProviders } from './KubernetesAuthProviders'; + +class MockAuthApi implements OAuthApi, OpenIdConnectApi { + constructor(private readonly token: string) {} + + getAccessToken = jest.fn(async () => { + return this.token; + }); + + getIdToken = jest.fn(async () => { + return this.token; + }); +} + +const requestBody: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'service', + metadata: { name: 'test' }, + }, +}; + +describe('KubernetesAuthProviders tests', () => { + const kap = new KubernetesAuthProviders({ + googleAuthApi: new MockAuthApi('googleToken'), + oidcProviders: { + okta: new MockAuthApi('oktaToken'), + }, + }); + + it('adds token to request body for google authProvider', async () => { + const details = await kap.decorateRequestBodyForAuth('google', requestBody); + + expect(details.auth?.google).toBe('googleToken'); + }); + + it('adds token to request body for oidc authProvider', async () => { + const details = await kap.decorateRequestBodyForAuth( + 'oidc.okta', + requestBody, + ); + + expect(details.oidc?.okta).toBe('oktaToken'); + }); + + it('returns error for unknown authProvider', async () => { + await expect( + kap.decorateRequestBodyForAuth('unknown', requestBody), + ).rejects.toThrow( + 'authProvider "unknown" has no KubernetesAuthProvider defined for it', + ); + }); + + it('returns error for missconfigured oidc authProvider', async () => { + await expect( + kap.decorateRequestBodyForAuth('oidc.random', requestBody), + ).rejects.toThrow( + 'KubernetesAuthProviders has no oidcProvider configured for oidc.random', + ); + }); +}); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 00d5a17f72..bcd1b2911a 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -19,9 +19,10 @@ import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; import { AwsKubernetesAuthProvider } from './AwsKubernetesAuthProvider'; -import { OAuthApi } from '@backstage/core-plugin-api'; +import { OAuthApi, OpenIdConnectApi } from '@backstage/core-plugin-api'; import { GoogleServiceAccountAuthProvider } from './GoogleServiceAccountAuthProvider'; import { AzureKubernetesAuthProvider } from './AzureKubernetesAuthProvider'; +import { OidcKubernetesAuthProvider } from './OidcKubernetesAuthProvider'; export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { private readonly kubernetesAuthProviderMap: Map< @@ -29,7 +30,10 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { KubernetesAuthProvider >; - constructor(options: { googleAuthApi: OAuthApi }) { + constructor(options: { + googleAuthApi: OAuthApi; + oidcProviders?: { [key: string]: OpenIdConnectApi }; + }) { this.kubernetesAuthProviderMap = new Map(); this.kubernetesAuthProviderMap.set( 'google', @@ -48,6 +52,18 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { 'azure', new AzureKubernetesAuthProvider(), ); + + if (options.oidcProviders) { + Object.keys(options.oidcProviders).forEach(provider => { + this.kubernetesAuthProviderMap.set( + `oidc.${provider}`, + new OidcKubernetesAuthProvider( + provider, + options.oidcProviders![provider], + ), + ); + }); + } } async decorateRequestBodyForAuth( @@ -61,6 +77,12 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { requestBody, ); } + + if (authProvider.startsWith('oidc.')) { + throw new Error( + `KubernetesAuthProviders has no oidcProvider configured for ${authProvider}`, + ); + } throw new Error( `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, ); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts new file mode 100644 index 0000000000..f308c09c2d --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 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 { KubernetesAuthProvider } from './types'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { OpenIdConnectApi } from '@backstage/core-plugin-api'; + +export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { + providerName: string; + authProvider: OpenIdConnectApi; + + constructor(providerName: string, authProvider: OpenIdConnectApi) { + this.providerName = providerName; + this.authProvider = authProvider; + } + + async decorateRequestBodyForAuth( + requestBody: KubernetesRequestBody, + ): Promise { + const authToken: string = await this.authProvider.getIdToken(); + if ('oidc' in requestBody) { + requestBody.oidc![this.providerName] = authToken; + } else { + requestBody.oidc = { [this.providerName]: authToken }; + } + return requestBody; + } +} diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index a1f2f57621..b54850307e 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -24,6 +24,9 @@ import { discoveryApiRef, identityApiRef, googleAuthApiRef, + microsoftAuthApiRef, + oktaAuthApiRef, + oneloginAuthApiRef, createRoutableExtension, } from '@backstage/core-plugin-api'; @@ -45,9 +48,26 @@ export const kubernetesPlugin = createPlugin({ }), createApiFactory({ api: kubernetesAuthProvidersApiRef, - deps: { googleAuthApi: googleAuthApiRef }, - factory: ({ googleAuthApi }) => { - return new KubernetesAuthProviders({ googleAuthApi }); + deps: { + googleAuthApi: googleAuthApiRef, + microsoftAuthApi: microsoftAuthApiRef, + oktaAuthApi: oktaAuthApiRef, + oneloginAuthApi: oneloginAuthApiRef, + }, + factory: ({ + googleAuthApi, + microsoftAuthApi, + oktaAuthApi, + oneloginAuthApi, + }) => { + const oidcProviders = { + google: googleAuthApi, + microsoft: microsoftAuthApi, + okta: oktaAuthApi, + onelogin: oneloginAuthApi, + }; + + return new KubernetesAuthProviders({ googleAuthApi, oidcProviders }); }, }), ], From 447e060872b6e5302ffa1babe5cc9ec9e55aaee7 Mon Sep 17 00:00:00 2001 From: Daniel Bravo Date: Wed, 4 May 2022 11:29:25 -0500 Subject: [PATCH 031/151] Add changeset and docs for kubernetes oidcTokenProvider feature Signed-off-by: Daniel Bravo --- .changeset/fluffy-sloths-deliver.md | 10 ++++++++ docs/features/kubernetes/configuration.md | 29 +++++++++++++++++++++++ plugins/kubernetes-backend/api-report.md | 1 + plugins/kubernetes-common/api-report.md | 6 ++++- plugins/kubernetes/api-report.md | 9 ++++++- 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .changeset/fluffy-sloths-deliver.md diff --git a/.changeset/fluffy-sloths-deliver.md b/.changeset/fluffy-sloths-deliver.md new file mode 100644 index 0000000000..ba4e6e4fe5 --- /dev/null +++ b/.changeset/fluffy-sloths-deliver.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +Add support for 'oidc' as authProvider for kubernetes authentication +and adds optional 'oidcTokenProvider' config value. This will allow +users to authenticate to kubernetes cluster using id tokens obtained +from the configured auth provider in their backstage instance. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 5b60316fd0..3b8fef453a 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -92,6 +92,8 @@ cluster. Valid values are: | `aws` | This will use AWS credentials to access resources in EKS clusters | | `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters | | `azure` | This will use [Azure Identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) to access resources in clusters | +| `oidc` | This will use [Oidc Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens) to authenticate to the Kubernetes API. When this is used the `oidcTokenProvider` | +| field should also be set. | ##### `clusters.\*.skipTLSVerify` @@ -115,6 +117,33 @@ kubectl -n get secret $(kubectl -n get sa ; // (undocumented) @@ -242,7 +244,12 @@ export const kubernetesApiRef: ApiRef; // // @public (undocumented) export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { - constructor(options: { googleAuthApi: OAuthApi }); + constructor(options: { + googleAuthApi: OAuthApi; + oidcProviders?: { + [key: string]: OpenIdConnectApi; + }; + }); // (undocumented) decorateRequestBodyForAuth( authProvider: string, From 532a23865b0ae057cbecdd9f34472fa8734bd313 Mon Sep 17 00:00:00 2001 From: Michael Haley Date: Fri, 6 May 2022 12:05:24 -0400 Subject: [PATCH 032/151] Add aria-label to sidebar & fix heading sizes in about card Signed-off-by: Michael Haley --- packages/core-components/src/layout/Sidebar/Bar.tsx | 2 +- plugins/catalog/src/components/AboutCard/AboutField.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index ea3200176d..e78706bd62 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -189,7 +189,7 @@ const DesktopSidebar = (props: DesktopSidebarProps) => { }; return ( -