Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save brandonbryant12/b476a12140a2159ac7dcc3fc5fe6f252 to your computer and use it in GitHub Desktop.
Save brandonbryant12/b476a12140a2159ac7dcc3fc5fe6f252 to your computer and use it in GitHub Desktop.
// package.json
{
"name": "use-entity-client-access-check",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "jest"
},
"dependencies": {
"jwt-decode": "^3.1.2",
"react": "^18.2.0",
"@backstage/core-plugin-api": "^1.0.0",
"@backstage/plugin-catalog-react": "^1.0.0"
},
"devDependencies": {
"@types/jwt-decode": "^3.1.0",
"@types/react": "^18.0.26",
"typescript": "^4.9.5",
"jest": "^29.5.0",
"@types/jest": "^29.5.1",
"ts-jest": "^29.0.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/react": "^14.0.0",
"identity-obj-proxy": "^3.0.0"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["dom", "es2019"],
"jsx": "react",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
moduleNameMapper: {
'\\.(css|less)$': 'identity-obj-proxy'
}
};
// src/types/entity.ts
export interface Entity {
metadata: {
name: string;
annotations?: Record<string, string>;
};
relations?: Array<{
type: string;
targetRef: string;
}>;
kind: string;
apiVersion: string;
}
// src/hooks/accessCheckers.ts
import { Entity } from '../types/entity';
import jwtDecode from 'jwt-decode';
export type AccessCondition = 'owner' | 'serviceNowContact' | 'ADGroup';
export interface JwtToken {
sub: string;
groups?: string[];
exp?: number;
}
export interface CheckerContext {
isOwnedEntity: (entity: Entity) => boolean;
}
export interface AccessChecker {
condition: AccessCondition;
check(
entity: Entity,
userData: JwtToken,
context: CheckerContext
): Promise<boolean> | boolean;
}
export const ownerAccessChecker: AccessChecker = {
condition: 'owner',
check: (entity, _userData, context) => {
return context.isOwnedEntity(entity);
},
};
export const adGroupAccessChecker: AccessChecker = {
condition: 'ADGroup',
check: (entity, userData) => {
const annotationValue = entity.metadata.annotations?.['MicrosoftADGroups'];
if (!annotationValue) {
return false;
}
const entityGroups = annotationValue.split(',').map(g => g.trim()).filter(Boolean);
const userGroups = userData.groups ?? [];
return entityGroups.some(group => userGroups.includes(group));
},
};
export const serviceNowContactAccessChecker: AccessChecker = {
condition: 'serviceNowContact',
check: (entity, userData) => {
const serviceNowRelations = entity.relations?.filter(
relation => relation.type === 'serviceNowContact'
);
if (!serviceNowRelations || serviceNowRelations.length === 0) {
return false;
}
return serviceNowRelations.some(relation => {
const parts = relation.targetRef.split('/');
const targetUser = parts[parts.length - 1];
return targetUser === userData.sub;
});
},
};
export const accessCheckerRegistry: Record<AccessCondition, AccessChecker> = {
owner: ownerAccessChecker,
ADGroup: adGroupAccessChecker,
serviceNowContact: serviceNowContactAccessChecker,
};
// src/hooks/useEntityClientAccessCheck.ts
import { useState, useEffect } from 'react';
import { useEntity, useEntityOwnership } from '@backstage/plugin-catalog-react';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import jwtDecode from 'jwt-decode';
import {
AccessCondition,
accessCheckerRegistry,
JwtToken,
CheckerContext,
} from './accessCheckers';
import { Entity } from '../types/entity';
interface AccessState {
loading: boolean;
hasAccess: boolean;
}
/**
* useEntityClientAccessCheck checks if the current user has access
* based on one or more conditions: 'owner', 'serviceNowContact', or 'ADGroup'.
* This hook is intended solely for client-side UI checks and does not protect BE endpoints.
*/
export function useEntityClientAccessCheck(
conditions: AccessCondition[] = ['owner']
): AccessState {
const { entity } = useEntity();
const { loading: ownershipLoading, isOwnedEntity } = useEntityOwnership();
const identityApi = useApi(identityApiRef);
const [state, setState] = useState<AccessState>({ loading: true, hasAccess: false });
useEffect(() => {
if (!entity) {
setState({ loading: false, hasAccess: false });
return;
}
identityApi
.getCredentials()
.then(({ token }) => {
if (!token) {
setState({ loading: false, hasAccess: false });
return;
}
const decoded = jwtDecode<JwtToken>(token);
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
setState({ loading: false, hasAccess: false });
return;
}
const context: CheckerContext = { isOwnedEntity };
const checks = conditions.map(cond => {
const checker = accessCheckerRegistry[cond];
if (!checker) {
return Promise.resolve(false);
}
return Promise.resolve(checker.check(entity, decoded, context));
});
Promise.all(checks)
.then(results => {
const hasAccess = results.some(result => result === true);
setState({ loading: false, hasAccess });
})
.catch(() => {
setState({ loading: false, hasAccess: false });
});
})
.catch(() => {
setState({ loading: false, hasAccess: false });
});
}, [entity, conditions, identityApi, isOwnedEntity]);
return {
loading: ownershipLoading || state.loading,
hasAccess: state.hasAccess,
};
}
// src/__tests__/useEntityClientAccessCheck.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { TestApiProvider } from '@backstage/test-utils';
import { useEntityClientAccessCheck } from '../hooks/useEntityClientAccessCheck';
import { useEntity, useEntityOwnership } from '@backstage/plugin-catalog-react';
import { identityApiRef } from '@backstage/core-plugin-api';
import jwtDecode from 'jwt-decode';
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: jest.fn(),
useEntityOwnership: jest.fn(),
}));
jest.mock('jwt-decode', () => jest.fn());
describe('useEntityClientAccessCheck', () => {
const mockIdentityApi = {
getCredentials: jest.fn()
};
const mockEntity = {
metadata: {
name: 'test-entity',
annotations: {
MicrosoftADGroups: 'group1, group2, group3'
}
},
kind: 'Component',
apiVersion: 'backstage.io/v1alpha1',
relations: [
{
type: 'serviceNowContact',
targetRef: 'user:default/jdoe'
}
]
};
const renderHookWithProviders = (hookConditions?: any) =>
renderHook(() => useEntityClientAccessCheck(hookConditions), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<TestApiProvider apis={[[identityApiRef, mockIdentityApi]]}>
{children}
</TestApiProvider>
)
});
beforeEach(() => {
jest.resetAllMocks();
(useEntityOwnership as jest.Mock).mockReturnValue({
loading: false,
isOwnedEntity: (_entity: any) => false
});
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
});
it('returns true if the user is the owner', async () => {
(useEntityOwnership as jest.Mock).mockReturnValue({
loading: false,
isOwnedEntity: () => true
});
const { result } = renderHookWithProviders(['owner']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: true });
});
expect(mockIdentityApi.getCredentials).not.toHaveBeenCalled();
});
it('grants access for ADGroup if groups match', async () => {
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
mockIdentityApi.getCredentials.mockResolvedValue({ token: 'valid-token' });
(jwtDecode as jest.Mock).mockReturnValue({
groups: ['group1', 'otherGroup'],
sub: 'someuser',
exp: Math.floor(Date.now() / 1000) + 3600
});
const { result } = renderHookWithProviders(['ADGroup']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: true });
});
});
it('denies access for ADGroup if no groups match', async () => {
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
mockIdentityApi.getCredentials.mockResolvedValue({ token: 'valid-token' });
(jwtDecode as jest.Mock).mockReturnValue({
groups: ['otherGroup1', 'otherGroup2'],
sub: 'someuser',
exp: Math.floor(Date.now() / 1000) + 3600
});
const { result } = renderHookWithProviders(['ADGroup']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: false });
});
});
it('grants access for serviceNowContact if relation matches', async () => {
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
mockIdentityApi.getCredentials.mockResolvedValue({ token: 'valid-token' });
(jwtDecode as jest.Mock).mockReturnValue({
groups: [],
sub: 'jdoe',
exp: Math.floor(Date.now() / 1000) + 3600
});
const { result } = renderHookWithProviders(['serviceNowContact']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: true });
});
});
it('denies access for serviceNowContact if relation does not match', async () => {
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
mockIdentityApi.getCredentials.mockResolvedValue({ token: 'valid-token' });
(jwtDecode as jest.Mock).mockReturnValue({
groups: [],
sub: 'anotherUser',
exp: Math.floor(Date.now() / 1000) + 3600
});
const { result } = renderHookWithProviders(['serviceNowContact']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: false });
});
});
it('grants access if one of multiple conditions passes', async () => {
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
mockIdentityApi.getCredentials.mockResolvedValue({ token: 'valid-token' });
(jwtDecode as jest.Mock).mockReturnValue({
groups: ['nonMatchingGroup'],
sub: 'jdoe',
exp: Math.floor(Date.now() / 1000) + 3600
});
const { result } = renderHookWithProviders(['ADGroup', 'serviceNowContact']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: true });
});
});
it('returns false when getCredentials fails', async () => {
(useEntity as jest.Mock).mockReturnValue({ entity: mockEntity });
mockIdentityApi.getCredentials.mockRejectedValue(new Error('API Error'));
const { result } = renderHookWithProviders(['ADGroup']);
await waitFor(() => {
expect(result.current).toEqual({ loading: false, hasAccess: false });
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment