Created
February 5, 2025 12:32
-
-
Save brandonbryant12/b476a12140a2159ac7dcc3fc5fe6f252 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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