diff --git a/.changeset/strong-spies-begin.md b/.changeset/strong-spies-begin.md new file mode 100644 index 0000000000..6ec2fb58d4 --- /dev/null +++ b/.changeset/strong-spies-begin.md @@ -0,0 +1,22 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Introduce the `useEntityOwnership` hook, which implements the full new ownership model. + +This also means a breaking change to the interface of `UserListFilter`. It no longer +accepts a user entity as input, but rather a function that checks ownership of an +entity. This function is taken from the above mentioned hook output. So if you are +instantiating the filter yourself, you will change from something like + +```ts +const { entity } = useOwnUser(); +const filter = new UserListFilter('owned', user, ...); +``` + +to + +```ts +const { isOwnedEntity } = useEntityOwnership(); +const filter = new UserListFilter('owned', isOwnedEntity, ...); +``` diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 01c88be089..a8175c8ec2 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -724,6 +724,14 @@ export function useEntityListProvider< EntityFilters extends DefaultEntityFilters = DefaultEntityFilters >(): EntityListContextProps; +// Warning: (ae-missing-release-tag) "useEntityOwnership" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function useEntityOwnership(): { + loading: boolean; + isOwnedEntity: (entity: Entity | EntityName) => boolean; +}; + // Warning: (ae-forgotten-export) The symbol "EntityTypeReturn" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "useEntityTypeFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -759,18 +767,18 @@ export function useRelatedEntities( export class UserListFilter implements EntityFilter { constructor( value: UserListFilterKind, - user: UserEntity | undefined, + isOwnedEntity: (entity: Entity) => boolean, isStarredEntity: (entity: Entity) => boolean, ); // (undocumented) filterEntity(entity: Entity): boolean; // (undocumented) + readonly isOwnedEntity: (entity: Entity) => boolean; + // (undocumented) readonly isStarredEntity: (entity: Entity) => boolean; // (undocumented) toQueryValue(): string; // (undocumented) - readonly user: UserEntity | undefined; - // (undocumented) readonly value: UserListFilterKind; } diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 6ae75197af..b7df4f3450 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -38,6 +38,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", + "jwt-decode": "^3.1.0", "lodash": "^4.17.15", "qs": "^6.9.4", "react": "^16.13.1", @@ -54,6 +55,7 @@ "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", + "@types/jwt-decode": "^3.1.0", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", "msw": "^0.29.0", diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index b54284da36..4662e58dd2 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { Entity, RELATION_OWNED_BY, @@ -58,7 +58,8 @@ const mockCatalogApi = { } as Partial; const mockIdentityApi = { - getUserId: () => '', + getUserId: () => 'testUser', + getIdToken: async () => undefined, } as Partial; const apis = ApiRegistry.from([ @@ -68,6 +69,9 @@ const apis = ApiRegistry.from([ [storageApiRef, MockStorageApi.create()], ]); +const mockIsOwnedEntity = (entity: Entity) => + entity.metadata.name === 'component-1'; + const mockIsStarredEntity = (entity: Entity) => entity.metadata.name === 'component-3'; @@ -75,7 +79,9 @@ jest.mock('../../hooks', () => { const actual = jest.requireActual('../../hooks'); return { ...actual, - useOwnUser: () => ({ value: mockUser }), + useEntityOwnership: () => ({ + isOwnedEntity: mockIsOwnedEntity, + }), useStarredEntities: () => ({ isStarredEntity: mockIsStarredEntity, }), @@ -161,7 +167,7 @@ describe('', () => { ).toEqual(['Owned', 'Starred', 'All']); }); - it('includes counts alongside each filter', () => { + it('includes counts alongside each filter', async () => { const { getAllByRole } = render( @@ -172,14 +178,16 @@ describe('', () => { // Material UI renders ListItemSecondaryActions outside the // menuitem itself, so we pick off the next sibling. - expect( - getAllByRole('menuitem').map( - ({ nextSibling }) => nextSibling?.textContent, - ), - ).toEqual(['2', '1', '4']); + await waitFor(() => { + expect( + getAllByRole('menuitem').map( + ({ nextSibling }) => nextSibling?.textContent, + ), + ).toEqual(['1', '1', '4']); + }); }); - it('respects other frontend filters in counts', () => { + it('respects other frontend filters in counts', async () => { const { getAllByRole } = render( ', () => { , ); - expect( - getAllByRole('menuitem').map( - ({ nextSibling }) => nextSibling?.textContent, - ), - ).toEqual(['1', '0', '2']); + await waitFor(() => { + expect( + getAllByRole('menuitem').map( + ({ nextSibling }) => nextSibling?.textContent, + ), + ).toEqual(['1', '0', '2']); + }); }); it('respects the query parameter filter value', () => { @@ -214,7 +224,7 @@ describe('', () => { ); expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('owned', mockUser, mockIsStarredEntity), + user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity), }); }); @@ -233,7 +243,11 @@ describe('', () => { fireEvent.click(getByText('Starred')); expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('starred', mockUser, mockIsStarredEntity), + user: new UserListFilter( + 'starred', + mockIsOwnedEntity, + mockIsStarredEntity, + ), }); }); }); diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx index 0212c0f0be..0e764507fe 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -14,15 +14,11 @@ * limitations under the License. */ -import React, { Fragment, useEffect, useMemo, useState } from 'react'; -import { compact } from 'lodash'; -import { UserListFilterKind } from '../../types'; -import { UserListFilter } from '../../filters'; import { - useEntityListProvider, - useOwnUser, - useStarredEntities, -} from '../../hooks'; + configApiRef, + IconComponent, + useApi, +} from '@backstage/core-plugin-api'; import { Card, List, @@ -36,12 +32,16 @@ import { } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; import StarIcon from '@material-ui/icons/Star'; -import { reduceEntityFilters } from '../../utils'; +import { compact } from 'lodash'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { UserListFilter } from '../../filters'; import { - configApiRef, - IconComponent, - useApi, -} from '@backstage/core-plugin-api'; + useEntityListProvider, + useStarredEntities, + useEntityOwnership, +} from '../../hooks'; +import { UserListFilterKind } from '../../types'; +import { reduceEntityFilters } from '../../utils'; const useStyles = makeStyles(theme => ({ root: { @@ -136,20 +136,20 @@ export const UserListPicker = ({ queryParameters, } = useEntityListProvider(); - const { value: user } = useOwnUser(); const { isStarredEntity } = useStarredEntities(); + const { isOwnedEntity } = useEntityOwnership(); const [selectedUserFilter, setSelectedUserFilter] = useState( [queryParameters.user].flat()[0] ?? initialFilter, ); // Static filters; used for generating counts of potentially unselected kinds const ownedFilter = useMemo( - () => new UserListFilter('owned', user, isStarredEntity), - [user, isStarredEntity], + () => new UserListFilter('owned', isOwnedEntity, isStarredEntity), + [isOwnedEntity, isStarredEntity], ); const starredFilter = useMemo( - () => new UserListFilter('starred', user, isStarredEntity), - [user, isStarredEntity], + () => new UserListFilter('starred', isOwnedEntity, isStarredEntity), + [isOwnedEntity, isStarredEntity], ); useEffect(() => { @@ -157,12 +157,12 @@ export const UserListPicker = ({ user: selectedUserFilter ? new UserListFilter( selectedUserFilter as UserListFilterKind, - user, + isOwnedEntity, isStarredEntity, ) : undefined, }); - }, [selectedUserFilter, user, isStarredEntity, updateFilters]); + }, [selectedUserFilter, isOwnedEntity, isStarredEntity, updateFilters]); // To show proper counts for each section, apply all other frontend filters _except_ the user // filter that's controlled by this picker. diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 98c66e0c26..46fb708f93 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -14,14 +14,10 @@ * limitations under the License. */ -import { - Entity, - UserEntity, - RELATION_OWNED_BY, -} from '@backstage/catalog-model'; -import { EntityFilter, UserListFilterKind } from './types'; -import { getEntityRelations, isOwnerOf } from './utils'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { formatEntityRefTitle } from './components/EntityRefLink'; +import { EntityFilter, UserListFilterKind } from './types'; +import { getEntityRelations } from './utils'; export class EntityKindFilter implements EntityFilter { constructor(readonly value: string) {} @@ -116,14 +112,14 @@ export class EntityLifecycleFilter implements EntityFilter { export class UserListFilter implements EntityFilter { constructor( readonly value: UserListFilterKind, - readonly user: UserEntity | undefined, + readonly isOwnedEntity: (entity: Entity) => boolean, readonly isStarredEntity: (entity: Entity) => boolean, ) {} filterEntity(entity: Entity): boolean { switch (this.value) { case 'owned': - return this.user !== undefined && isOwnerOf(this.user, entity); + return this.isOwnedEntity(entity); case 'starred': return this.isStarredEntity(entity); default: diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 483f1ab787..868e50c852 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -25,3 +25,4 @@ export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useOwnUser } from './useOwnUser'; export { useRelatedEntities } from './useRelatedEntities'; export { useStarredEntities } from './useStarredEntities'; +export { useEntityOwnership } from './useEntityOwnership'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index c66af03da0..815c9e289e 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -20,7 +20,7 @@ import { MemoryRouter as Router } from 'react-router-dom'; import { act, renderHook } from '@testing-library/react-hooks'; import { MockStorageApi } from '@backstage/test-utils'; import { CatalogApi } from '@backstage/catalog-client'; -import { Entity, UserEntity } from '@backstage/catalog-model'; +import { Entity } from '@backstage/catalog-model'; import { EntityListProvider, useEntityListProvider, @@ -39,17 +39,6 @@ import { storageApiRef, } from '@backstage/core-plugin-api'; -const mockUser: UserEntity = { - apiVersion: 'backstage.io/v1beta1', - kind: 'User', - metadata: { - name: 'guest', - }, - spec: { - memberOf: [], - }, -}; - const entities: Entity[] = [ { apiVersion: '1', @@ -81,13 +70,12 @@ const mockConfigApi = { getOptionalString: () => '', } as Partial; const mockIdentityApi: Partial = { - getUserId: () => 'guest@example.com', + getUserId: () => 'guest', + getIdToken: async () => undefined, }; const mockCatalogApi: Partial = { - getEntities: jest - .fn() - .mockImplementation(() => Promise.resolve({ items: entities })), - getEntityByName: () => Promise.resolve(mockUser), + getEntities: jest.fn().mockImplementation(async () => ({ items: entities })), + getEntityByName: async () => undefined, }; const apis = ApiRegistry.from([ [configApiRef, mockConfigApi], @@ -117,7 +105,7 @@ const wrapper = ({ ); }; -describe('', () => { +describe('', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -171,18 +159,26 @@ describe('', () => { const { result, waitFor } = renderHook(() => useEntityListProvider(), { wrapper, }); - await waitFor(() => !!result.current.entities.length); - expect(result.current.entities.length).toBe(2); - expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current.entities.length).toBe(2); + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + }); act(() => result.current.updateFilters({ - user: new UserListFilter('owned', mockUser, () => true), + user: new UserListFilter( + 'owned', + entity => entity.metadata.name === 'component-1', + () => true, + ), }), ); - await waitFor(() => result.current.entities.length !== 2); - expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); - expect(result.current.entities.length).toBe(1); + + await waitFor(() => { + expect(result.current.entities.length).toBe(1); + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + }); }); it('debounces multiple filter changes', async () => { @@ -205,7 +201,7 @@ describe('', () => { }); it('returns an error on catalogApi failure', async () => { - const { result, waitForNextUpdate, waitForValueToChange } = renderHook( + const { result, waitForValueToChange, waitFor } = renderHook( () => useEntityListProvider(), { wrapper, @@ -218,7 +214,8 @@ describe('', () => { act(() => { result.current.updateFilters({ kind: new EntityKindFilter('api') }); }); - await waitForNextUpdate(); - expect(result.current.error).toBeDefined(); + await waitFor(() => { + expect(result.current.error).toBeDefined(); + }); }); }); diff --git a/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx b/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx new file mode 100644 index 0000000000..f617565e81 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 2021 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 { CatalogApi } from '@backstage/catalog-client'; +import { + ComponentEntity, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, + UserEntity, +} from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { catalogApiRef } from '../api'; +import { + loadCatalogOwnerRefs, + loadIdentityOwnerRefs, + useEntityOwnership, +} from './useEntityOwnership'; + +describe('useEntityOwnership', () => { + type MockIdentityApi = jest.Mocked< + Pick + >; + type MockCatalogApi = jest.Mocked>; + + const mockIdentityApi: MockIdentityApi = { + getUserId: jest.fn(), + getIdToken: jest.fn(), + }; + const mockCatalogApi: MockCatalogApi = { + getEntityByName: jest.fn(), + }; + + const identityApi = (mockIdentityApi as unknown) as IdentityApi; + const catalogApi = (mockCatalogApi as unknown) as CatalogApi; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + const ownedEntity: ComponentEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'component1', + namespace: 'default', + }, + spec: { + /* should not be accessed */ + } as any, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'User', namespace: 'default', name: 'user1' }, + }, + { + type: RELATION_OWNED_BY, + target: { kind: 'Group', namespace: 'default', name: 'group1' }, + }, + ], + }; + + const user2Entity: UserEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { + name: 'user2', + namespace: 'default', + }, + spec: { + /* should not be accessed */ + } as any, + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'Group', namespace: 'default', name: 'group1' }, + }, + ], + }; + + // these were generated on https://jwt.io, based off of its default example token + // no ent at all + const tokenNoEnt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + // "ent": [] + const tokenEmptyEnt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbnQiOltdfQ.Khyza2whczkoC4wSCLBhBaBB9-ktIkk7gpXEgQPHhtY'; + // "ent": ["user:default/user1"] + const tokenUserEnt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbnQiOlsidXNlcjpkZWZhdWx0L3VzZXIxIl19.CMCxjwI4rj_TD3uUoBNgFjkZI23LwRTbQnSPBxzncoY'; + // "ent": ["user:default/user1", "group:default/group1"] + const tokenUserAndGroupEnt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbnQiOlsidXNlcjpkZWZhdWx0L3VzZXIxIiwiZ3JvdXA6ZGVmYXVsdC9ncm91cDEiXX0.ZZmZrogbQKx0hnForw63ETkyAhUyeoBE8Hgloi45rdg'; + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('loadIdentityOwnerRefs', () => { + it('returns the user id when there is no relevant token info', async () => { + mockIdentityApi.getUserId.mockReturnValueOnce('foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(undefined); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:default/foo', + ]); + + mockIdentityApi.getUserId.mockReturnValueOnce('ns/foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(undefined); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:ns/foo', + ]); + + mockIdentityApi.getUserId.mockReturnValueOnce('user:ns/foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(undefined); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:ns/foo', + ]); + + mockIdentityApi.getUserId.mockReturnValueOnce('foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenNoEnt); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:default/foo', + ]); + + mockIdentityApi.getUserId.mockReturnValueOnce('foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenEmptyEnt); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:default/foo', + ]); + }); + + it('returns both the user id and the token parts', async () => { + mockIdentityApi.getUserId.mockReturnValueOnce('foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenUserEnt); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:default/foo', + 'user:default/user1', + ]); + + mockIdentityApi.getUserId.mockReturnValueOnce('foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenUserAndGroupEnt); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:default/foo', + 'user:default/user1', + 'group:default/group1', + ]); + }); + + it('gracefully ignores broken token', async () => { + mockIdentityApi.getUserId.mockReturnValueOnce('foo'); + mockIdentityApi.getIdToken.mockResolvedValueOnce('not a jwt'); + await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([ + 'user:default/foo', + ]); + }); + }); + + describe('loadCatalogOwnerRefs', () => { + it('loads the first user from the catalog', async () => { + mockCatalogApi.getEntityByName.mockResolvedValueOnce(user2Entity); + await expect( + loadCatalogOwnerRefs(catalogApi, ['user:default/user2']), + ).resolves.toEqual(['group:default/group1']); + expect(mockCatalogApi.getEntityByName).toBeCalledWith({ + kind: 'user', + namespace: 'default', + name: 'user2', + }); + }); + + it('gracefully handles missing user', async () => { + mockCatalogApi.getEntityByName.mockResolvedValueOnce(undefined); + await expect( + loadCatalogOwnerRefs(catalogApi, ['user:default/user2']), + ).resolves.toEqual([]); + expect(mockCatalogApi.getEntityByName).toBeCalledWith({ + kind: 'user', + namespace: 'default', + name: 'user2', + }); + }); + }); + + describe('useEntityOwnership', () => { + it('matches ownership via token claims', async () => { + mockIdentityApi.getUserId.mockReturnValue('foo'); + mockIdentityApi.getIdToken.mockResolvedValue(tokenUserAndGroupEnt); + mockCatalogApi.getEntityByName.mockResolvedValue(undefined); + + const { result, waitForValueToChange } = renderHook( + () => useEntityOwnership(), + { + wrapper: Wrapper, + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.isOwnedEntity(ownedEntity)).toBe(false); + + await waitForValueToChange(() => result.current.loading); + + expect(result.current.loading).toBe(false); + expect(result.current.isOwnedEntity(ownedEntity)).toBe(true); + }); + + it('matches ownership via catalog user entity', async () => { + mockIdentityApi.getUserId.mockReturnValue('user2'); + mockIdentityApi.getIdToken.mockResolvedValue(undefined); + mockCatalogApi.getEntityByName.mockResolvedValue(user2Entity); + + const { result, waitForValueToChange } = renderHook( + () => useEntityOwnership(), + { + wrapper: Wrapper, + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.isOwnedEntity(ownedEntity)).toBe(false); + + await waitForValueToChange(() => result.current.loading); + + expect(result.current.loading).toBe(false); + expect(result.current.isOwnedEntity(ownedEntity)).toBe(true); + + expect(mockCatalogApi.getEntityByName).toBeCalledWith({ + kind: 'user', + namespace: 'default', + name: 'user2', + }); + }); + }); +}); diff --git a/plugins/catalog-react/src/hooks/useEntityOwnership.ts b/plugins/catalog-react/src/hooks/useEntityOwnership.ts new file mode 100644 index 0000000000..93c79de207 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityOwnership.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2021 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 { CatalogApi } from '@backstage/catalog-client'; +import { + Entity, + EntityName, + parseEntityRef, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { + IdentityApi, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import jwtDecoder from 'jwt-decode'; +import { useMemo } from 'react'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../api'; +import { getEntityRelations } from '../utils/getEntityRelations'; + +// Takes a user ID from the identity, which can be on basically any form, and +// returns an entity ref. E.g. if the input is "foo", it returns +// "user:default/foo" to make sure it's a full ref. +function extendUserId(id: string): string { + try { + const ref = parseEntityRef(id, { + defaultKind: 'User', + defaultNamespace: 'default', + }); + return stringifyEntityRef(ref); + } catch { + return id; + } +} + +// Takes the relevant parts of the Backstage identity, and translates them into +// a list of entity refs on string form that represent the user's ownership +// connections. +export async function loadIdentityOwnerRefs( + identityApi: IdentityApi, +): Promise { + const id = identityApi.getUserId(); + const token = await identityApi.getIdToken(); + const result: string[] = []; + + if (id) { + result.push(extendUserId(id)); + } + + if (token) { + try { + const decoded = jwtDecoder(token) as any; + if (decoded?.ent) { + [decoded.ent] + .flat() + .filter(x => typeof x === 'string') + .map(x => x.toLocaleLowerCase('en-US')) + .forEach(x => result.push(x)); + } + } catch { + // ignore + } + } + + return result; +} + +// Takes the relevant parts of the User entity corresponding to the Backstage +// identity, and translates them into a list of entity refs on string form that +// represent the user's ownership connections. +export async function loadCatalogOwnerRefs( + catalogApi: CatalogApi, + identityOwnerRefs: string[], +): Promise { + const result = new Array(); + + const primaryUserRef = identityOwnerRefs.find(ref => ref.startsWith('user:')); + if (primaryUserRef) { + const entity = await catalogApi.getEntityByName( + parseEntityRef(primaryUserRef), + ); + if (entity) { + const memberOf = getEntityRelations(entity, RELATION_MEMBER_OF, { + kind: 'Group', + }); + for (const group of memberOf) { + result.push(stringifyEntityRef(group)); + } + } + } + + return result; +} + +/** + * Returns a function that checks whether the currently signed-in user is an + * owner of a given entity. When the hook is initially mounted, the loading + * flag will be true and the results returned from the function will always be + * false. + */ +export function useEntityOwnership(): { + loading: boolean; + isOwnedEntity: (entity: Entity | EntityName) => boolean; +} { + const identityApi = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + + // Trigger load only on mount + const { loading, value: refs } = useAsync(async () => { + const identityRefs = await loadIdentityOwnerRefs(identityApi); + const catalogRefs = await loadCatalogOwnerRefs(catalogApi, identityRefs); + return new Set([...identityRefs, ...catalogRefs]); + }, []); + + const isOwnedEntity = useMemo(() => { + const myOwnerRefs = new Set(refs ?? []); + return (entity: Entity | EntityName) => { + const entityOwnerRefs = ('metadata' in entity + ? getEntityRelations(entity, RELATION_OWNED_BY) + : [entity] + ).map(stringifyEntityRef); + for (const ref of entityOwnerRefs) { + if (myOwnerRefs.has(ref)) { + return true; + } + } + return false; + }; + }, [refs]); + + return useMemo(() => ({ loading, isOwnedEntity }), [loading, isOwnedEntity]); +} diff --git a/plugins/catalog-react/src/utils/isOwnerOf.ts b/plugins/catalog-react/src/utils/isOwnerOf.ts index e62a0db1ba..38ad90e4a6 100644 --- a/plugins/catalog-react/src/utils/isOwnerOf.ts +++ b/plugins/catalog-react/src/utils/isOwnerOf.ts @@ -16,10 +16,10 @@ import { Entity, - EntityName, getEntityName, RELATION_MEMBER_OF, RELATION_OWNED_BY, + stringifyEntityRef, } from '@backstage/catalog-model'; import { getEntityRelations } from './getEntityRelations'; @@ -27,22 +27,19 @@ import { getEntityRelations } from './getEntityRelations'; * Get the related entity references. */ export function isOwnerOf(owner: Entity, owned: Entity) { - const possibleOwners: EntityName[] = [ - ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), - ...(owner ? [getEntityName(owner)] : []), - ]; + const possibleOwners = new Set( + [ + ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), + ...(owner ? [getEntityName(owner)] : []), + ].map(stringifyEntityRef), + ); - const owners = getEntityRelations(owned, RELATION_OWNED_BY); + const owners = getEntityRelations(owned, RELATION_OWNED_BY).map( + stringifyEntityRef, + ); for (const ownerItem of owners) { - if ( - possibleOwners.find( - o => - ownerItem.kind.toLowerCase() === o.kind.toLowerCase() && - ownerItem.namespace.toLowerCase() === o.namespace.toLowerCase() && - ownerItem.name.toLowerCase() === o.name.toLowerCase(), - ) !== undefined - ) { + if (possibleOwners.has(ownerItem)) { return true; } } diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index e09aec55e7..8e3c59ffa3 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -55,7 +55,7 @@ describe('CatalogPage', () => { name: 'Entity1', }, spec: { - owner: 'tools@example.com', + owner: 'tools', type: 'service', }, relations: [ @@ -72,7 +72,7 @@ describe('CatalogPage', () => { name: 'Entity2', }, spec: { - owner: 'not-tools@example.com', + owner: 'not-tools', type: 'service', }, relations: [ @@ -108,7 +108,8 @@ describe('CatalogPage', () => { displayName: 'Display Name', }; const identityApi: Partial = { - getUserId: () => 'tools@example.com', + getUserId: () => 'tools', + getIdToken: async () => undefined, getProfile: () => testProfile, }; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 7867ad6f46..efbd6989e4 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -73,7 +73,11 @@ describe('CatalogTable component', () => { value={{ entities, filters: { - user: new UserListFilter('owned', undefined, () => false), + user: new UserListFilter( + 'owned', + () => false, + () => false, + ), }, }} >