Sync FieldLens authorization isolation
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 02:10:49 +00:00
parent 756603bae1
commit fc6dcaf87a
2 changed files with 40 additions and 16 deletions

View File

@ -1,15 +1,35 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { rolesFromPayload } from './auth.js' import { hasAccess, rolesFromPayload } from './auth.js'
describe('role extraction', () => { describe('FieldLens authorization isolation', () => {
it('collects FieldLens resource and realm roles without treating authentication as authorization', () => { it('extracts FieldLens client roles and ignores realm/platform roles', () => {
const roles = rolesFromPayload({ const roles = rolesFromPayload({
resource_access: { resource_access: {
'fieldlens-api': { roles: ['fieldlens-operator'] }, 'fieldlens-api': { roles: ['fieldlens-operator'] },
}, },
realm_access: { roles: ['offline_access'] }, realm_access: { roles: ['operator', 'admin', 'owner', 'offline_access'] },
}) })
expect(roles).toContain('fieldlens-operator')
expect(roles).toContain('offline_access') expect(roles).toEqual(['fieldlens-operator'])
})
it('does not grant FieldLens access from generic Arrowstack roles', () => {
const platformIdentity = {
roles: ['operator', 'admin', 'owner'],
clientId: 'some-arrowstack-client',
}
expect(hasAccess('operator', platformIdentity)).toBe(false)
expect(hasAccess('supervisor', platformIdentity)).toBe(false)
expect(hasAccess('admin', platformIdentity)).toBe(false)
expect(hasAccess('integration', platformIdentity)).toBe(false)
})
it('preserves the FieldLens role hierarchy', () => {
expect(hasAccess('operator', { roles: ['fieldlens-operator'] })).toBe(true)
expect(hasAccess('operator', { roles: ['fieldlens-supervisor'] })).toBe(true)
expect(hasAccess('supervisor', { roles: ['fieldlens-admin'] })).toBe(true)
expect(hasAccess('admin', { roles: ['fieldlens-admin'] })).toBe(true)
expect(hasAccess('admin', { roles: ['fieldlens-supervisor'] })).toBe(false)
}) })
}) })

View File

@ -27,12 +27,10 @@ function strings(value: unknown) {
export function rolesFromPayload(payload: JWTPayload) { export function rolesFromPayload(payload: JWTPayload) {
const resourceAccess = payload.resource_access as Record<string, { roles?: unknown }> | undefined const resourceAccess = payload.resource_access as Record<string, { roles?: unknown }> | undefined
const realmAccess = payload.realm_access as { roles?: unknown } | undefined
return [...new Set([ return [...new Set([
...strings(resourceAccess?.[config.audience]?.roles), ...strings(resourceAccess?.[config.audience]?.roles),
...strings(resourceAccess?.[config.browserClientId]?.roles), ...strings(resourceAccess?.[config.browserClientId]?.roles),
...strings(resourceAccess?.[config.machineClientId]?.roles), ...strings(resourceAccess?.[config.machineClientId]?.roles),
...strings(realmAccess?.roles),
])] ])]
} }
@ -70,18 +68,24 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
} }
} }
const access: Record<string, string[]> = { const access = {
operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin', 'operator', 'admin', 'owner'], operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin'],
supervisor: ['fieldlens-supervisor', 'fieldlens-admin', 'admin', 'owner'], supervisor: ['fieldlens-supervisor', 'fieldlens-admin'],
admin: ['fieldlens-admin', 'admin', 'owner'], admin: ['fieldlens-admin'],
integration: ['fieldlens-integration', 'fieldlens-admin', 'admin', 'owner'], integration: ['fieldlens-integration', 'fieldlens-admin'],
} as const
export function hasAccess(
level: keyof typeof access,
identity: Pick<AuthIdentity, 'roles' | 'clientId'> | undefined,
) {
const exactMachineIdentity = level === 'integration' && identity?.clientId === config.machineClientId
return exactMachineIdentity || access[level].some((role) => identity?.roles.includes(role) ?? false)
} }
export function requireAccess(level: keyof typeof access) { export function requireAccess(level: keyof typeof access) {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
const roles = req.identity?.roles ?? [] if (!hasAccess(level, req.identity)) {
const exactMachineIdentity = level === 'integration' && req.identity?.clientId === config.machineClientId
if (!exactMachineIdentity && !access[level].some((role) => roles.includes(role))) {
res.status(403).json({ error: 'forbidden' }) res.status(403).json({ error: 'forbidden' })
return return
} }