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..077e1f092f 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 { useOwnUser } from '../useOwnUser'; +import { isOwnerOf } from '../isOwnerOf'; 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 { value: user } = useOwnUser(); + const filterGroups = useMemo( () => [ { @@ -121,7 +123,7 @@ const CatalogPageContents = () => { id: 'owned', label: 'Owned', icon: SettingsIcon, - filterFn: entity => entity.spec?.owner === userId, + filterFn: entity => user !== undefined && isOwnerOf(user, entity), }, { id: 'starred', @@ -142,7 +144,7 @@ const CatalogPageContents = () => { ], }, ], - [isStarredEntity, userId, orgName], + [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..5b997d646b --- /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..f9f526698f --- /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.toLowerCase(), + ); + } + + 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..dc1f2ab1a8 --- /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', namespace: 'Default' }, + } 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..455e06dc13 --- /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.toLowerCase() === o.namespace.toLowerCase() && + owner.name.toLowerCase() === o.name.toLowerCase(), + ) !== undefined + ) { + return true; + } + } + + return false; +} diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog/src/components/useOwnUser.ts new file mode 100644 index 0000000000..a74b60481e --- /dev/null +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -0,0 +1,40 @@ +/* + * 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 { 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(): 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( + () => + catalogApi.getEntityByName({ + kind: 'User', + namespace: 'default', + name: identityApi.getUserId(), + }) as Promise, + [catalogApi, identityApi], + ); +}