diff --git a/apps/api/src/access.ts b/apps/api/src/access.ts new file mode 100644 index 0000000..8aa8ae0 --- /dev/null +++ b/apps/api/src/access.ts @@ -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(['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 { + 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 { + const result = await pool.query(` + 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(` + 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(` + 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(` + 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 +} diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index 414db31..e87235d 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -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(roles).toEqual(['fieldlens-operator']) + } + expect(rolesFromPayload(payload)).toEqual(['fieldlens-operator']) + expect(realmRolesFromPayload(payload)).toEqual(['operator', 'admin', 'owner', 'offline_access']) }) - 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) diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index e3421e4..4c9a043 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -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 | 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() +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c5afd98..9fc5298 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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, diff --git a/apps/web/src/HostStatus.tsx b/apps/web/src/HostStatus.tsx index 90482d0..f5bf0ea 100644 --- a/apps/web/src/HostStatus.tsx +++ b/apps/web/src/HostStatus.tsx @@ -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(null) + const [requests, setRequests] = useState([]) + 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').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 ( - + <> + + + {showRequests && access?.canApprove && ( +
+
+ Access requests + +
+ {requests.length === 0 ? ( +

No pending requests.

+ ) : requests.map((request) => ( +
+ + {request.username || request.email || request.subject} + {request.email || 'Keycloak account'} · {new Date(request.requestedAt).toLocaleString()} + +
+ + +
+
+ ))} +
+ )} + ) } diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts index 60e8714..4b55966 100644 --- a/apps/web/src/auth.ts +++ b/apps/web/src/auth.ts @@ -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 }) diff --git a/apps/web/src/host.css b/apps/web/src/host.css index eb7b478..d920069 100644 --- a/apps/web/src/host.css +++ b/apps/web/src/host.css @@ -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)}} diff --git a/migrations/003_access_requests.sql b/migrations/003_access_requests.sql new file mode 100644 index 0000000..b4a55ab --- /dev/null +++ b/migrations/003_access_requests.sql @@ -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);