From fc6dcaf87a9f2ec4b6f4ef149c5743acfade82eb Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 02:10:49 +0000 Subject: [PATCH] Sync FieldLens authorization isolation --- apps/api/src/auth.test.ts | 32 ++++++++++++++++++++++++++------ apps/api/src/auth.ts | 24 ++++++++++++++---------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index 9fc1859..414db31 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -1,15 +1,35 @@ import { describe, expect, it } from 'vitest' -import { rolesFromPayload } from './auth.js' +import { hasAccess, rolesFromPayload } from './auth.js' -describe('role extraction', () => { - it('collects FieldLens resource and realm roles without treating authentication as authorization', () => { +describe('FieldLens authorization isolation', () => { + it('extracts FieldLens client roles and ignores realm/platform roles', () => { const roles = rolesFromPayload({ resource_access: { '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) }) }) diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 32e7b0f..e3421e4 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -27,12 +27,10 @@ function strings(value: unknown) { export function rolesFromPayload(payload: JWTPayload) { const resourceAccess = payload.resource_access as Record | undefined - const realmAccess = payload.realm_access as { roles?: unknown } | undefined return [...new Set([ ...strings(resourceAccess?.[config.audience]?.roles), ...strings(resourceAccess?.[config.browserClientId]?.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 = { - operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin', 'operator', 'admin', 'owner'], - supervisor: ['fieldlens-supervisor', 'fieldlens-admin', 'admin', 'owner'], - admin: ['fieldlens-admin', 'admin', 'owner'], - integration: ['fieldlens-integration', 'fieldlens-admin', 'admin', 'owner'], +const access = { + operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin'], + supervisor: ['fieldlens-supervisor', 'fieldlens-admin'], + admin: ['fieldlens-admin'], + integration: ['fieldlens-integration', 'fieldlens-admin'], +} as const + +export function hasAccess( + level: keyof typeof access, + identity: Pick | 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) { return (req: Request, res: Response, next: NextFunction) => { - const roles = req.identity?.roles ?? [] - const exactMachineIdentity = level === 'integration' && req.identity?.clientId === config.machineClientId - if (!exactMachineIdentity && !access[level].some((role) => roles.includes(role))) { + if (!hasAccess(level, req.identity)) { res.status(403).json({ error: 'forbidden' }) return }