From ebf37bbae89bda9e079a04949bbe393d4f408b0d Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 30 Nov 2020 14:46:22 +0100 Subject: [PATCH 1/4] Derive the owned entities in the catalog from group memberships --- .changeset/twenty-trees-travel.md | 5 ++ .../CatalogPage/CatalogPage.test.tsx | 35 ++++++++++- .../components/CatalogPage/CatalogPage.tsx | 19 ++++-- .../catalog/src/components/useUserGroups.ts | 59 +++++++++++++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 .changeset/twenty-trees-travel.md create mode 100644 plugins/catalog/src/components/useUserGroups.ts diff --git a/.changeset/twenty-trees-travel.md b/.changeset/twenty-trees-travel.md new file mode 100644 index 0000000000..7f65084320 --- /dev/null +++ b/.changeset/twenty-trees-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Use the OWNED_BY relation and compare it to the users MEMBER_OF relation. The user entity is searched by name, based on the userId of the identity. diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 83bf4a6f6e..28fb1b08c7 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -15,7 +15,11 @@ */ import { CatalogApi } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; +import { + Entity, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, @@ -46,6 +50,12 @@ describe('CatalogPage', () => { owner: 'tools@example.com', type: 'service', }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'Group', name: 'tools', namespace: 'default' }, + }, + ], }, { apiVersion: 'backstage.io/v1alpha1', @@ -57,11 +67,34 @@ describe('CatalogPage', () => { owner: 'not-tools@example.com', type: 'service', }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'not-tools', + namespace: 'default', + }, + }, + ], }, ] as Entity[], }), getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), + getEntityByName: async entityName => { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: entityName.name }, + relations: [ + { + type: RELATION_MEMBER_OF, + target: { namespace: 'default', kind: 'Group', name: 'tools' }, + }, + ], + }; + }, }; const testProfile: Partial = { displayName: 'Display Name', diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index e73a789380..5b728895b8 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -19,7 +19,6 @@ import { Content, ContentHeader, errorApiRef, - identityApiRef, SupportButton, useApi, } from '@backstage/core'; @@ -38,6 +37,8 @@ import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; import { WelcomeBanner } from './WelcomeBanner'; +import { useUserGroups } from '../useUserGroups'; +import { RELATION_OWNED_BY } from '@backstage/catalog-model'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -65,7 +66,6 @@ const CatalogPageContents = () => { const catalogApi = useApi(catalogApiRef); const errorApi = useApi(errorApiRef); const { isStarredEntity } = useStarredEntities(); - const userId = useApi(identityApiRef).getUserId(); const [selectedTab, setSelectedTab] = useState(); const [selectedSidebarItem, setSelectedSidebarItem] = useState(); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; @@ -112,6 +112,8 @@ const CatalogPageContents = () => { [], ); + const { groups } = useUserGroups(); + const filterGroups = useMemo( () => [ { @@ -121,7 +123,16 @@ const CatalogPageContents = () => { id: 'owned', label: 'Owned', icon: SettingsIcon, - filterFn: entity => entity.spec?.owner === userId, + filterFn: entity => { + const ownerRelation = entity.relations?.find( + r => + r.type === RELATION_OWNED_BY && + r.target.kind.toLowerCase() === 'group', + ); + return ( + !!ownerRelation && groups.includes(ownerRelation.target.name) + ); + }, }, { id: 'starred', @@ -142,7 +153,7 @@ const CatalogPageContents = () => { ], }, ], - [isStarredEntity, userId, orgName], + [isStarredEntity, orgName, groups], ); const showAddExampleEntities = diff --git a/plugins/catalog/src/components/useUserGroups.ts b/plugins/catalog/src/components/useUserGroups.ts new file mode 100644 index 0000000000..c91a449837 --- /dev/null +++ b/plugins/catalog/src/components/useUserGroups.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useAsync } from 'react-use'; +import { useMemo } from 'react'; +import { RELATION_MEMBER_OF } from '@backstage/catalog-model'; +import { identityApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '../plugin'; + +/** + * Get the group memberships of the logged-in user. + */ +export const useUserGroups: () => { + groups: string[]; + loading: boolean; + error?: Error; +} = () => { + const catalogApi = useApi(catalogApiRef); + const userId = useApi(identityApiRef).getUserId(); + + // TODO: should the identityApiRef already include the entity? or at least a full EntityName? + const { value: user, loading, error } = useAsync(async () => { + return await catalogApi.getEntityByName({ + kind: 'User', + namespace: 'default', + name: userId, + }); + }, [catalogApi, userId]); + + // calculate the group memberships + const groups = useMemo(() => { + if (user && user.relations) { + return user.relations + .filter( + r => + r.type === RELATION_MEMBER_OF && + r.target.kind.toLowerCase() === 'group', + ) + .map(r => r.target.name); + } + + return []; + }, [user]); + + return { groups, loading, error }; +}; From 6e2290a630eda1ec0c1327cb3694df5a32afeb42 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 30 Nov 2020 18:29:16 +0100 Subject: [PATCH 2/4] Refactor the hooks and also support users owning components --- .../components/CatalogPage/CatalogPage.tsx | 19 ++---- .../src/components/getEntityRelations.test.ts | 66 +++++++++++++++++++ .../src/components/getEntityRelations.ts | 39 +++++++++++ .../catalog/src/components/isOwnerOf.test.ts | 64 ++++++++++++++++++ plugins/catalog/src/components/isOwnerOf.ts | 51 ++++++++++++++ .../{useUserGroups.ts => useOwnUser.ts} | 42 ++++-------- 6 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 plugins/catalog/src/components/getEntityRelations.test.ts create mode 100644 plugins/catalog/src/components/getEntityRelations.ts create mode 100644 plugins/catalog/src/components/isOwnerOf.test.ts create mode 100644 plugins/catalog/src/components/isOwnerOf.ts rename plugins/catalog/src/components/{useUserGroups.ts => useOwnUser.ts} (51%) diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 5b728895b8..077e1f092f 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -37,8 +37,8 @@ import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; import { WelcomeBanner } from './WelcomeBanner'; -import { useUserGroups } from '../useUserGroups'; -import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { useOwnUser } from '../useOwnUser'; +import { isOwnerOf } from '../isOwnerOf'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -112,7 +112,7 @@ const CatalogPageContents = () => { [], ); - const { groups } = useUserGroups(); + const { value: user } = useOwnUser(); const filterGroups = useMemo( () => [ @@ -123,16 +123,7 @@ const CatalogPageContents = () => { id: 'owned', label: 'Owned', icon: SettingsIcon, - filterFn: entity => { - const ownerRelation = entity.relations?.find( - r => - r.type === RELATION_OWNED_BY && - r.target.kind.toLowerCase() === 'group', - ); - return ( - !!ownerRelation && groups.includes(ownerRelation.target.name) - ); - }, + filterFn: entity => user !== undefined && isOwnerOf(user, entity), }, { id: 'starred', @@ -153,7 +144,7 @@ const CatalogPageContents = () => { ], }, ], - [isStarredEntity, orgName, groups], + [isStarredEntity, orgName, user], ); const showAddExampleEntities = diff --git a/plugins/catalog/src/components/getEntityRelations.test.ts b/plugins/catalog/src/components/getEntityRelations.test.ts new file mode 100644 index 0000000000..062c6b3064 --- /dev/null +++ b/plugins/catalog/src/components/getEntityRelations.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { getEntityRelations } from './getEntityRelations'; +import { + Entity, + RELATION_CHILD_OF, + RELATION_MEMBER_OF, +} from '@backstage/catalog-model'; + +describe('getEntityRelations', () => { + it('should handle undefined value', () => { + expect(getEntityRelations(undefined, RELATION_MEMBER_OF)).toEqual([]); + }); + + it('should extract correct relation', () => { + const entity = { + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'group', name: 'member' }, + }, + { + type: RELATION_CHILD_OF, + target: { kind: 'group', name: 'child' }, + }, + ], + } as Entity; + + expect(getEntityRelations(entity, RELATION_MEMBER_OF)).toEqual([ + { kind: 'group', name: 'member' }, + ]); + }); + + it('should filter relation by type', () => { + const entity = { + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'group', name: 'member' }, + }, + { + type: RELATION_MEMBER_OF, + target: { kind: 'user', name: 'child' }, + }, + ], + } as Entity; + + expect( + getEntityRelations(entity, RELATION_MEMBER_OF, { kind: 'group' }), + ).toEqual([{ kind: 'group', name: 'member' }]); + }); +}); diff --git a/plugins/catalog/src/components/getEntityRelations.ts b/plugins/catalog/src/components/getEntityRelations.ts new file mode 100644 index 0000000000..5c5531f0dd --- /dev/null +++ b/plugins/catalog/src/components/getEntityRelations.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Entity, EntityName } from '@backstage/catalog-model'; + +/** + * Get the related entity references. + */ +export function getEntityRelations( + entity: Entity | undefined, + relationType: string, + filter?: { kind: string }, +): EntityName[] { + let entityNames = + entity?.relations + ?.filter(r => r.type === relationType) + ?.map(r => r.target) || []; + + if (filter?.kind) { + entityNames = entityNames?.filter( + e => e.kind.toLowerCase() === filter.kind, + ); + } + + return entityNames; +} diff --git a/plugins/catalog/src/components/isOwnerOf.test.ts b/plugins/catalog/src/components/isOwnerOf.test.ts new file mode 100644 index 0000000000..1cae1eb44a --- /dev/null +++ b/plugins/catalog/src/components/isOwnerOf.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + Entity, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +import { isOwnerOf } from './isOwnerOf'; + +describe('isOwnerOf', () => { + it('should be owned by user', () => { + const ownerEntity = { + kind: 'User', + metadata: { name: 'user' }, + } as Entity; + const ownedEntity = { + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'user', namespace: 'default', name: 'user' }, + }, + ], + } as Entity; + + expect(isOwnerOf(ownerEntity, ownedEntity)).toBe(true); + }); + + it('should be owned by group', () => { + const ownerEntity = { + kind: 'User', + metadata: { name: 'user' }, + relations: [ + { + type: RELATION_MEMBER_OF, + target: { kind: 'group', namespace: 'default', name: 'group' }, + }, + ], + } as Entity; + const ownedEntity = { + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'group', namespace: 'default', name: 'group' }, + }, + ], + } as Entity; + + expect(isOwnerOf(ownerEntity, ownedEntity)).toBe(true); + }); +}); diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog/src/components/isOwnerOf.ts new file mode 100644 index 0000000000..d095a77d91 --- /dev/null +++ b/plugins/catalog/src/components/isOwnerOf.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + Entity, + EntityName, + getEntityName, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +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 owners = getEntityRelations(owned, RELATION_OWNED_BY); + + for (const owner of owners) { + if ( + possibleOwners.find( + o => + owner.kind.toLowerCase() === o.kind.toLowerCase() && + owner.namespace === o.namespace && + owner.name === o.name, + ) !== undefined + ) { + return true; + } + } + + return false; +} diff --git a/plugins/catalog/src/components/useUserGroups.ts b/plugins/catalog/src/components/useOwnUser.ts similarity index 51% rename from plugins/catalog/src/components/useUserGroups.ts rename to plugins/catalog/src/components/useOwnUser.ts index c91a449837..201366b4e8 100644 --- a/plugins/catalog/src/components/useUserGroups.ts +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -14,46 +14,28 @@ * limitations under the License. */ +import { UserEntity } from '@backstage/catalog-model'; import { useAsync } from 'react-use'; -import { useMemo } from 'react'; -import { RELATION_MEMBER_OF } from '@backstage/catalog-model'; import { identityApiRef, useApi } from '@backstage/core'; import { catalogApiRef } from '../plugin'; /** * Get the group memberships of the logged-in user. */ -export const useUserGroups: () => { - groups: string[]; +export function useOwnUser(): { + value?: UserEntity; loading: boolean; error?: Error; -} = () => { +} { const catalogApi = useApi(catalogApiRef); - const userId = useApi(identityApiRef).getUserId(); + const identityApi = useApi(identityApiRef); - // TODO: should the identityApiRef already include the entity? or at least a full EntityName? - const { value: user, loading, error } = useAsync(async () => { - return await catalogApi.getEntityByName({ + // TODO: get the full entity (or at least the full entity name) from the identityApi + return useAsync(async () => { + return (await catalogApi.getEntityByName({ kind: 'User', namespace: 'default', - name: userId, - }); - }, [catalogApi, userId]); - - // calculate the group memberships - const groups = useMemo(() => { - if (user && user.relations) { - return user.relations - .filter( - r => - r.type === RELATION_MEMBER_OF && - r.target.kind.toLowerCase() === 'group', - ) - .map(r => r.target.name); - } - - return []; - }, [user]); - - return { groups, loading, error }; -}; + name: identityApi.getUserId(), + })) as UserEntity; + }, [catalogApi, identityApi]); +} From 811ca4bbb282ebb6debfe81b9f816f2a12193281 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 30 Nov 2020 21:54:55 +0100 Subject: [PATCH 3/4] Fix review comments --- .../catalog/src/components/isOwnerOf.test.ts | 2 +- plugins/catalog/src/components/isOwnerOf.ts | 4 ++-- plugins/catalog/src/components/useOwnUser.ts | 23 +++++++++---------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/plugins/catalog/src/components/isOwnerOf.test.ts b/plugins/catalog/src/components/isOwnerOf.test.ts index 1cae1eb44a..dc1f2ab1a8 100644 --- a/plugins/catalog/src/components/isOwnerOf.test.ts +++ b/plugins/catalog/src/components/isOwnerOf.test.ts @@ -25,7 +25,7 @@ describe('isOwnerOf', () => { it('should be owned by user', () => { const ownerEntity = { kind: 'User', - metadata: { name: 'user' }, + metadata: { name: 'User', namespace: 'Default' }, } as Entity; const ownedEntity = { relations: [ diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog/src/components/isOwnerOf.ts index d095a77d91..455e06dc13 100644 --- a/plugins/catalog/src/components/isOwnerOf.ts +++ b/plugins/catalog/src/components/isOwnerOf.ts @@ -39,8 +39,8 @@ export function isOwnerOf(owner: Entity, owned: Entity) { possibleOwners.find( o => owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace === o.namespace && - owner.name === o.name, + owner.namespace.toLowerCase() === o.namespace.toLowerCase() && + owner.name.toLowerCase() === o.name.toLowerCase(), ) !== undefined ) { return true; diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog/src/components/useOwnUser.ts index 201366b4e8..a74b60481e 100644 --- a/plugins/catalog/src/components/useOwnUser.ts +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -16,26 +16,25 @@ import { UserEntity } from '@backstage/catalog-model'; import { useAsync } from 'react-use'; +import { AsyncState } from 'react-use/lib/useAsync'; import { identityApiRef, useApi } from '@backstage/core'; import { catalogApiRef } from '../plugin'; /** * Get the group memberships of the logged-in user. */ -export function useOwnUser(): { - value?: UserEntity; - loading: boolean; - error?: Error; -} { +export function useOwnUser(): AsyncState { const catalogApi = useApi(catalogApiRef); const identityApi = useApi(identityApiRef); // TODO: get the full entity (or at least the full entity name) from the identityApi - return useAsync(async () => { - return (await catalogApi.getEntityByName({ - kind: 'User', - namespace: 'default', - name: identityApi.getUserId(), - })) as UserEntity; - }, [catalogApi, identityApi]); + return useAsync( + () => + catalogApi.getEntityByName({ + kind: 'User', + namespace: 'default', + name: identityApi.getUserId(), + }) as Promise, + [catalogApi, identityApi], + ); } From f346688237dbda1361d7807de469b8759e9bf5f7 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Tue, 1 Dec 2020 07:55:42 +0100 Subject: [PATCH 4/4] Use case-insensitive filters --- plugins/catalog/src/components/getEntityRelations.test.ts | 2 +- plugins/catalog/src/components/getEntityRelations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/src/components/getEntityRelations.test.ts b/plugins/catalog/src/components/getEntityRelations.test.ts index 062c6b3064..5b997d646b 100644 --- a/plugins/catalog/src/components/getEntityRelations.test.ts +++ b/plugins/catalog/src/components/getEntityRelations.test.ts @@ -60,7 +60,7 @@ describe('getEntityRelations', () => { } as Entity; expect( - getEntityRelations(entity, RELATION_MEMBER_OF, { kind: 'group' }), + getEntityRelations(entity, RELATION_MEMBER_OF, { kind: 'Group' }), ).toEqual([{ kind: 'group', name: 'member' }]); }); }); diff --git a/plugins/catalog/src/components/getEntityRelations.ts b/plugins/catalog/src/components/getEntityRelations.ts index 5c5531f0dd..f9f526698f 100644 --- a/plugins/catalog/src/components/getEntityRelations.ts +++ b/plugins/catalog/src/components/getEntityRelations.ts @@ -31,7 +31,7 @@ export function getEntityRelations( if (filter?.kind) { entityNames = entityNames?.filter( - e => e.kind.toLowerCase() === filter.kind, + e => e.kind.toLowerCase() === filter.kind.toLowerCase(), ); }