Sync FieldLens access request approvals
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 02:20:39 +00:00
parent fc6dcaf87a
commit 9152cccb0a
8 changed files with 351 additions and 18 deletions

110
apps/api/src/access.ts Normal file
View File

@ -0,0 +1,110 @@
import { pool } from './db.js'
export type AccessStatus = 'pending' | 'approved' | 'rejected'
export type AppRole = 'fieldlens-operator' | 'fieldlens-supervisor' | 'fieldlens-admin'
export type AccessRequest = {
subject: string
username: string | null
email: string | null
status: AccessStatus
approvedRole: AppRole | null
requestedAt: string
reviewedAt: string | null
reviewedBy: string | null
}
const APP_ROLES = new Set<AppRole>(['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin'])
function role(value: unknown): AppRole | null {
return typeof value === 'string' && APP_ROLES.has(value as AppRole) ? value as AppRole : null
}
export async function approvedRoleForSubject(subject: string): Promise<AppRole | null> {
const result = await pool.query<{ approved_role: string | null }>(
"SELECT approved_role FROM fieldlens_access_requests WHERE subject = $1 AND status = 'approved'",
[subject],
)
return role(result.rows[0]?.approved_role)
}
export async function getAccessRequest(subject: string): Promise<AccessRequest | null> {
const result = await pool.query<AccessRequest>(`
SELECT subject, username, email, status,
approved_role AS "approvedRole",
requested_at AS "requestedAt",
reviewed_at AS "reviewedAt",
reviewed_by AS "reviewedBy"
FROM fieldlens_access_requests
WHERE subject = $1
`, [subject])
return result.rows[0] ?? null
}
export async function requestAccess(identity: { subject: string; username?: string; email?: string }) {
const result = await pool.query<AccessRequest>(`
INSERT INTO fieldlens_access_requests (subject, username, email, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (subject) DO UPDATE SET
username = EXCLUDED.username,
email = EXCLUDED.email,
status = CASE
WHEN fieldlens_access_requests.status = 'approved' THEN 'approved'
ELSE 'pending'
END,
requested_at = CASE
WHEN fieldlens_access_requests.status = 'approved' THEN fieldlens_access_requests.requested_at
ELSE now()
END,
reviewed_at = CASE
WHEN fieldlens_access_requests.status = 'approved' THEN fieldlens_access_requests.reviewed_at
ELSE NULL
END,
reviewed_by = CASE
WHEN fieldlens_access_requests.status = 'approved' THEN fieldlens_access_requests.reviewed_by
ELSE NULL
END
RETURNING subject, username, email, status,
approved_role AS "approvedRole",
requested_at AS "requestedAt",
reviewed_at AS "reviewedAt",
reviewed_by AS "reviewedBy"
`, [identity.subject, identity.username ?? null, identity.email ?? null])
return result.rows[0]!
}
export async function listAccessRequests(status: AccessStatus = 'pending') {
const result = await pool.query<AccessRequest>(`
SELECT subject, username, email, status,
approved_role AS "approvedRole",
requested_at AS "requestedAt",
reviewed_at AS "reviewedAt",
reviewed_by AS "reviewedBy"
FROM fieldlens_access_requests
WHERE status = $1
ORDER BY requested_at ASC
LIMIT 100
`, [status])
return result.rows
}
export async function reviewAccessRequest(
subject: string,
decision: 'approved' | 'rejected',
reviewer: string,
) {
const result = await pool.query<AccessRequest>(`
UPDATE fieldlens_access_requests
SET status = $2,
approved_role = CASE WHEN $2 = 'approved' THEN 'fieldlens-operator' ELSE NULL END,
reviewed_at = now(),
reviewed_by = $3
WHERE subject = $1
RETURNING subject, username, email, status,
approved_role AS "approvedRole",
requested_at AS "requestedAt",
reviewed_at AS "reviewedAt",
reviewed_by AS "reviewedBy"
`, [subject, decision, reviewer])
return result.rows[0] ?? null
}

View File

@ -1,21 +1,22 @@
import { describe, expect, it } from 'vitest'
import { hasAccess, rolesFromPayload } from './auth.js'
import { canApproveAccess, hasAccess, realmRolesFromPayload, rolesFromPayload } from './auth.js'
describe('FieldLens authorization isolation', () => {
it('extracts FieldLens client roles and ignores realm/platform roles', () => {
const roles = rolesFromPayload({
it('extracts FieldLens client roles and ignores realm/platform roles for app access', () => {
const payload = {
resource_access: {
'fieldlens-api': { roles: ['fieldlens-operator'] },
},
realm_access: { roles: ['operator', 'admin', 'owner', 'offline_access'] },
}
expect(rolesFromPayload(payload)).toEqual(['fieldlens-operator'])
expect(realmRolesFromPayload(payload)).toEqual(['operator', 'admin', 'owner', 'offline_access'])
})
expect(roles).toEqual(['fieldlens-operator'])
})
it('does not grant FieldLens access from generic Arrowstack roles', () => {
it('does not grant FieldLens operational access from generic Arrowstack roles', () => {
const platformIdentity = {
roles: ['operator', 'admin', 'owner'],
realmRoles: ['operator', 'admin', 'owner'],
clientId: 'some-arrowstack-client',
}
@ -25,6 +26,13 @@ describe('FieldLens authorization isolation', () => {
expect(hasAccess('integration', platformIdentity)).toBe(false)
})
it('allows only admin/owner realm identities to approve access without granting app access', () => {
expect(canApproveAccess({ roles: [], realmRoles: ['admin'] })).toBe(true)
expect(canApproveAccess({ roles: [], realmRoles: ['owner'] })).toBe(true)
expect(canApproveAccess({ roles: [], realmRoles: ['operator'] })).toBe(false)
expect(hasAccess('operator', { roles: [], clientId: 'fieldlens' })).toBe(false)
})
it('preserves the FieldLens role hierarchy', () => {
expect(hasAccess('operator', { roles: ['fieldlens-operator'] })).toBe(true)
expect(hasAccess('operator', { roles: ['fieldlens-supervisor'] })).toBe(true)

View File

@ -1,11 +1,14 @@
import type { NextFunction, Request, Response } from 'express'
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { approvedRoleForSubject } from './access.js'
import { config } from './config.js'
export type AuthIdentity = {
subject: string
username?: string
email?: string
roles: string[]
realmRoles: string[]
clientId?: string
}
@ -34,6 +37,11 @@ export function rolesFromPayload(payload: JWTPayload) {
])]
}
export function realmRolesFromPayload(payload: JWTPayload) {
const realmAccess = payload.realm_access as { roles?: unknown } | undefined
return [...new Set(strings(realmAccess?.roles))]
}
function intendedForFieldLens(payload: JWTPayload) {
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []
const azp = typeof payload.azp === 'string' ? payload.azp : ''
@ -56,10 +64,15 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
res.status(401).json({ error: 'invalid_token_target' })
return
}
const tokenRoles = rolesFromPayload(payload)
const approvedRole = await approvedRoleForSubject(payload.sub)
req.identity = {
subject: payload.sub,
username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined,
roles: rolesFromPayload(payload),
email: typeof payload.email === 'string' ? payload.email : undefined,
roles: [...new Set([...tokenRoles, ...(approvedRole ? [approvedRole] : [])])],
realmRoles: realmRolesFromPayload(payload),
clientId: typeof payload.azp === 'string' ? payload.azp : undefined,
}
next()
@ -83,6 +96,16 @@ export function hasAccess(
return exactMachineIdentity || access[level].some((role) => identity?.roles.includes(role) ?? false)
}
export function canApproveAccess(
identity: Pick<AuthIdentity, 'roles' | 'realmRoles'> | undefined,
) {
return Boolean(
identity?.roles.includes('fieldlens-admin')
|| identity?.realmRoles.includes('admin')
|| identity?.realmRoles.includes('owner'),
)
}
export function requireAccess(level: keyof typeof access) {
return (req: Request, res: Response, next: NextFunction) => {
if (!hasAccess(level, req.identity)) {
@ -92,3 +115,11 @@ export function requireAccess(level: keyof typeof access) {
next()
}
}
export function requireAccessApprover(req: Request, res: Response, next: NextFunction) {
if (!canApproveAccess(req.identity)) {
res.status(403).json({ error: 'forbidden' })
return
}
next()
}

View File

@ -3,7 +3,8 @@ import cors from 'cors'
import express from 'express'
import helmet from 'helmet'
import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts'
import { authenticate, requireAccess } from './auth.js'
import { getAccessRequest, listAccessRequests, requestAccess, reviewAccessRequest } from './access.js'
import { authenticate, canApproveAccess, requireAccess, requireAccessApprover } from './auth.js'
import { config } from './config.js'
import { loadDeviceContext } from './context.js'
import { databaseReady, pool } from './db.js'
@ -72,6 +73,55 @@ app.get('/api/health/ready', async (_req, res) => {
app.use('/api/v1', authenticate)
app.get('/api/v1/access/me', async (req, res) => {
const request = await getAccessRequest(req.identity!.subject)
const roles = req.identity!.roles.filter((role) => role.startsWith('fieldlens-'))
res.json({
status: roles.length > 0 ? 'approved' : request?.status ?? 'none',
role: roles[0] ?? request?.approvedRole ?? null,
canApprove: canApproveAccess(req.identity),
username: req.identity!.username ?? null,
email: req.identity!.email ?? null,
})
})
app.post('/api/v1/access/request', async (req, res) => {
const request = await requestAccess(req.identity!)
log('info', 'access_requested', { subject: req.identity!.subject, status: request.status })
res.status(request.status === 'approved' ? 200 : 202).json({
status: request.status,
role: request.approvedRole,
})
})
app.get('/api/v1/access/requests', requireAccessApprover, async (req, res) => {
const status = req.query.status === 'approved' || req.query.status === 'rejected'
? req.query.status
: 'pending'
const requests = await listAccessRequests(status)
res.json({ requests })
})
app.post('/api/v1/access/requests/:subject/approve', requireAccessApprover, async (req, res) => {
const request = await reviewAccessRequest(req.params.subject, 'approved', req.identity!.subject)
if (!request) {
res.status(404).json({ error: 'access_request_not_found' })
return
}
log('info', 'access_approved', { subject: request.subject, reviewer: req.identity!.subject })
res.json({ status: request.status, role: request.approvedRole })
})
app.post('/api/v1/access/requests/:subject/reject', requireAccessApprover, async (req, res) => {
const request = await reviewAccessRequest(req.params.subject, 'rejected', req.identity!.subject)
if (!request) {
res.status(404).json({ error: 'access_request_not_found' })
return
}
log('info', 'access_rejected', { subject: request.subject, reviewer: req.identity!.subject })
res.json({ status: request.status })
})
app.get('/api/v1/me', requireAccess('operator'), (req, res) => {
res.json({
subject: req.identity!.subject,

View File

@ -2,17 +2,73 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect, useState } from 'react'
import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database'
import { initializeAuth, keycloak, login, logout } from './auth'
import { getAccessToken, initializeAuth, keycloak, login, logout, register } from './auth'
import type { RegisteredDevice } from './sync'
type AccessState = {
status: 'none' | 'pending' | 'approved' | 'rejected'
role: string | null
canApprove: boolean
username: string | null
email: string | null
}
type AccessRequest = {
subject: string
username: string | null
email: string | null
status: string
requestedAt: string
}
async function accessFetch(path: string, init: RequestInit = {}) {
const token = await getAccessToken()
if (!token) throw new Error('Authentication unavailable')
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
if (init.body) headers.set('Content-Type', 'application/json')
return fetch(path, { ...init, headers })
}
export function HostStatus() {
const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0
const errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
const [station, setStation] = useState('Local / unassigned')
const [access, setAccess] = useState<AccessState | null>(null)
const [requests, setRequests] = useState<AccessRequest[]>([])
const [showRequests, setShowRequests] = useState(false)
async function refreshAccess() {
const response = await accessFetch('/api/v1/access/me')
if (!response.ok) return
const next = await response.json() as AccessState
setAccess(next)
if (next.canApprove && showRequests) await refreshRequests()
}
async function refreshRequests() {
const response = await accessFetch('/api/v1/access/requests')
if (!response.ok) return
const body = await response.json() as { requests: AccessRequest[] }
setRequests(body.requests)
}
async function submitRequest() {
const response = await accessFetch('/api/v1/access/request', { method: 'POST' })
if (response.ok) await refreshAccess()
}
async function review(subject: string, decision: 'approve' | 'reject') {
const response = await accessFetch(`/api/v1/access/requests/${encodeURIComponent(subject)}/${decision}`, { method: 'POST' })
if (response.ok) await refreshRequests()
}
useEffect(() => {
void initializeAuth().then((ok) => setAuthenticated(ok))
void initializeAuth().then((ok) => {
setAuthenticated(ok)
if (ok) void refreshAccess()
})
const refreshStation = () => {
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
if (device?.station?.name) setStation(device.station.name)
@ -23,12 +79,65 @@ export function HostStatus() {
return () => window.clearInterval(timer)
}, [])
async function toggleRequests() {
const next = !showRequests
setShowRequests(next)
if (next) await refreshRequests()
}
return (
<>
<aside className="host-status" aria-label="Arrowstack synchronization status">
<span><strong>{station}</strong><small>{pending} pending{errors ? ` · ${errors} retrying` : ''}</small></span>
<button type="button" onClick={() => void (authenticated ? logout() : login())}>
{authenticated ? 'Sign out' : 'Sign in to sync'}
<span>
<strong>{station}</strong>
<small>
{authenticated && access?.status !== 'approved'
? access?.status === 'pending' ? 'Access pending approval' : access?.status === 'rejected' ? 'Access request rejected' : 'No FieldLens access'
: `${pending} pending${errors ? ` · ${errors} retrying` : ''}`}
</small>
</span>
{!authenticated ? (
<>
<button type="button" onClick={() => void login()}>Sign in</button>
<button type="button" className="secondary" onClick={() => void register()}>Create account</button>
</>
) : (
<>
{(access?.status === 'none' || access?.status === 'rejected') && (
<button type="button" onClick={() => void submitRequest()}>Request access</button>
)}
{access?.canApprove && (
<button type="button" className="secondary" onClick={() => void toggleRequests()}>
Requests
</button>
)}
<button type="button" className="secondary" onClick={() => void logout()}>Sign out</button>
</>
)}
</aside>
{showRequests && access?.canApprove && (
<section className="access-panel" aria-label="FieldLens access requests">
<header>
<strong>Access requests</strong>
<button type="button" onClick={() => setShowRequests(false)}>×</button>
</header>
{requests.length === 0 ? (
<p>No pending requests.</p>
) : requests.map((request) => (
<article key={request.subject}>
<span>
<strong>{request.username || request.email || request.subject}</strong>
<small>{request.email || 'Keycloak account'} · {new Date(request.requestedAt).toLocaleString()}</small>
</span>
<div>
<button type="button" onClick={() => void review(request.subject, 'approve')}>Approve</button>
<button type="button" className="secondary" onClick={() => void review(request.subject, 'reject')}>Reject</button>
</div>
</article>
))}
</section>
)}
</>
)
}

View File

@ -48,6 +48,12 @@ export async function login() {
await keycloak.login({ redirectUri: window.location.href })
}
export async function register() {
if (!keycloak) return
await initializeAuth()
await keycloak.register({ redirectUri: window.location.href })
}
export async function logout() {
if (!keycloak) return
await keycloak.logout({ redirectUri: window.location.origin })

View File

@ -1 +1 @@
.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:10px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));max-width:75vw}.host-status strong{max-width:100px}.host-status button{padding:6px 8px;font-size:9px}}
.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:8px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button,.access-panel button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}.host-status button.secondary,.access-panel button.secondary{background:#314039;color:#fff}.access-panel{position:fixed;z-index:81;top:58px;right:max(10px,env(safe-area-inset-right));width:min(430px,calc(100vw - 20px));max-height:70vh;overflow:auto;padding:12px;border:1px solid #314039;border-radius:12px;background:#17211ff7;color:#fff;box-shadow:0 12px 36px #0006;font:12px/1.35 system-ui,sans-serif}.access-panel header,.access-panel article{display:flex;align-items:center;justify-content:space-between;gap:12px}.access-panel header{padding-bottom:10px;border-bottom:1px solid #314039}.access-panel header button{font-size:18px;line-height:1;padding:4px 8px}.access-panel article{padding:11px 0;border-bottom:1px solid #26332e}.access-panel article:last-child{border-bottom:0}.access-panel article span{display:grid;gap:3px;min-width:0}.access-panel article small{color:#aebbb6;overflow-wrap:anywhere}.access-panel article div{display:flex;gap:6px;flex-shrink:0}.access-panel p{color:#aebbb6}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));left:max(7px,env(safe-area-inset-left));max-width:none;flex-wrap:wrap}.host-status span{margin-right:auto}.host-status strong{max-width:120px}.host-status button{padding:6px 8px;font-size:9px}.access-panel{top:94px;right:7px;width:calc(100vw - 14px)}}

View File

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS fieldlens_access_requests (
subject text PRIMARY KEY,
username text,
email text,
status text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
approved_role text CHECK (
approved_role IS NULL OR approved_role IN (
'fieldlens-operator',
'fieldlens-supervisor',
'fieldlens-admin'
)
),
requested_at timestamptz NOT NULL DEFAULT now(),
reviewed_at timestamptz,
reviewed_by text
);
CREATE INDEX IF NOT EXISTS idx_fieldlens_access_requests_status_requested
ON fieldlens_access_requests(status, requested_at);