Merge pull request #6572 from backstage/freben/owner-token

Introduce the `useEntityOwnership` hook, which implements the full new ownership model
This commit is contained in:
Ben Lambert
2021-07-29 10:31:11 +02:00
committed by GitHub
13 changed files with 540 additions and 95 deletions
+11 -3
View File
@@ -724,6 +724,14 @@ export function useEntityListProvider<
EntityFilters extends DefaultEntityFilters = DefaultEntityFilters
>(): EntityListContextProps<EntityFilters>;
// 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;
}
+2
View File
@@ -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",
@@ -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<CatalogApi>;
const mockIdentityApi = {
getUserId: () => '',
getUserId: () => 'testUser',
getIdToken: async () => undefined,
} as Partial<IdentityApi>;
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('<UserListPicker />', () => {
).toEqual(['Owned', 'Starred', 'All']);
});
it('includes counts alongside each filter', () => {
it('includes counts alongside each filter', async () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
@@ -172,14 +178,16 @@ describe('<UserListPicker />', () => {
// 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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
@@ -193,11 +201,13 @@ describe('<UserListPicker />', () => {
</ApiProvider>,
);
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('<UserListPicker />', () => {
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockUser, mockIsStarredEntity),
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
});
});
@@ -233,7 +243,11 @@ describe('<UserListPicker />', () => {
fireEvent.click(getByText('Starred'));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('starred', mockUser, mockIsStarredEntity),
user: new UserListFilter(
'starred',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
});
});
@@ -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>(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.
+5 -9
View File
@@ -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:
+1
View File
@@ -25,3 +25,4 @@ export { useEntityTypeFilter } from './useEntityTypeFilter';
export { useOwnUser } from './useOwnUser';
export { useRelatedEntities } from './useRelatedEntities';
export { useStarredEntities } from './useStarredEntities';
export { useEntityOwnership } from './useEntityOwnership';
@@ -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<ConfigApi>;
const mockIdentityApi: Partial<IdentityApi> = {
getUserId: () => 'guest@example.com',
getUserId: () => 'guest',
getIdToken: async () => undefined,
};
const mockCatalogApi: Partial<CatalogApi> = {
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('<EntityListProvider/>', () => {
describe('<EntityListProvider />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -171,18 +159,26 @@ describe('<EntityListProvider/>', () => {
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('<EntityListProvider/>', () => {
});
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('<EntityListProvider/>', () => {
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
});
await waitForNextUpdate();
expect(result.current.error).toBeDefined();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
});
});
@@ -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<IdentityApi, 'getUserId' | 'getIdToken'>
>;
type MockCatalogApi = jest.Mocked<Pick<CatalogApi, 'getEntityByName'>>;
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 }) => (
<ApiProvider
apis={ApiRegistry.with(identityApiRef, identityApi).with(
catalogApiRef,
catalogApi,
)}
>
{children}
</ApiProvider>
);
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',
});
});
});
});
@@ -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<string[]> {
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<string[]> {
const result = new Array<string>();
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]);
}
+11 -14
View File
@@ -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;
}
}
@@ -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<IdentityApi> = {
getUserId: () => 'tools@example.com',
getUserId: () => 'tools',
getIdToken: async () => undefined,
getProfile: () => testProfile,
};
@@ -73,7 +73,11 @@ describe('CatalogTable component', () => {
value={{
entities,
filters: {
user: new UserListFilter('owned', undefined, () => false),
user: new UserListFilter(
'owned',
() => false,
() => false,
),
},
}}
>