From c901ccc0dcf0d16022ac9ab2c02cec110f7cccca Mon Sep 17 00:00:00 2001 From: Thomas Tupper Date: Sat, 14 Mar 2026 03:47:02 -0500 Subject: [PATCH] feat: add RBAC middleware and role-aware UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - server/db.ts: adds tenant_memberships CRUD helpers - server/index.ts: adds requireRole() middleware with convenience wrappers (requireAdmin, requireOperator, requireOwner); applies RBAC to mutation routes (prompt/model changes need admin, session kill needs operator); adds GET /api/auth/roles and tenant membership management endpoints Frontend: - useRbac() hook: derives canEdit/canOperate from user's memberships - FoxMemoryAgentsView: disables prompt editing for viewers - ModelConfigSection: disables model switching for viewers - App.tsx: wires RBAC and passes canEdit to components Backward compatible — if no tenant_memberships rows exist, all access is allowed. Co-Authored-By: Claude Opus 4.6 --- server/db.ts | 54 +++++++++++++ server/index.ts | 81 +++++++++++++++++-- web/src/App.tsx | 5 +- .../FoxMemoryAgentsView.tsx | 9 ++- .../FoxMemoryAgentsView.types.ts | 2 + .../FoxMemorySection/FoxMemorySection.tsx | 3 +- .../FoxMemorySection.types.ts | 2 + .../ModelConfigSection/ModelConfigSection.tsx | 22 ++--- .../ModelConfigSection.types.ts | 5 +- web/src/hooks/useRbac.ts | 31 +++++++ web/src/services/dashboardApi.ts | 9 ++- web/src/types.ts | 10 +++ 12 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 web/src/hooks/useRbac.ts diff --git a/server/db.ts b/server/db.ts index 9cdcbdd..4c5aa14 100644 --- a/server/db.ts +++ b/server/db.ts @@ -32,6 +32,15 @@ db.exec(` status TEXT NOT NULL, note TEXT ); + + CREATE TABLE IF NOT EXISTS tenant_memberships ( + user_id INTEGER NOT NULL, + tenant_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'operator', 'viewer')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, tenant_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); `); export interface DbUser { @@ -110,3 +119,48 @@ export const seedAdminUser = (email: string, password: string): void => { createUser({ email, password, username: 'admin' }); console.log(`[foxops] Admin user seeded: ${email}`); }; + +// ── Tenant membership helpers ────────────────────────────────────────────── + +export interface TenantMembership { + user_id: number; + tenant_id: string; + role: 'owner' | 'admin' | 'operator' | 'viewer'; + created_at: string; +} + +const membershipStmt = { + byUserId: db.prepare<[number], TenantMembership>('SELECT * FROM tenant_memberships WHERE user_id = ?'), + byTenant: db.prepare<[string], TenantMembership & { email: string; username: string | null }>( + `SELECT tm.*, u.email, u.username FROM tenant_memberships tm + JOIN users u ON u.id = tm.user_id + WHERE tm.tenant_id = ? + ORDER BY tm.created_at`, + ), + insert: db.prepare<[number, string, string]>( + 'INSERT INTO tenant_memberships (user_id, tenant_id, role) VALUES (?, ?, ?)', + ), + update: db.prepare<[string, number, string]>( + 'UPDATE tenant_memberships SET role = ? WHERE user_id = ? AND tenant_id = ?', + ), + remove: db.prepare<[number, string]>( + 'DELETE FROM tenant_memberships WHERE user_id = ? AND tenant_id = ?', + ), +}; + +export const getMembershipsByUserId = (userId: number): TenantMembership[] => membershipStmt.byUserId.all(userId); + +export const getMembershipsByTenant = (tenantId: string): (TenantMembership & { email: string; username: string | null })[] => + membershipStmt.byTenant.all(tenantId); + +export const addTenantMembership = (userId: number, tenantId: string, role: string): void => { + membershipStmt.insert.run(userId, tenantId, role); +}; + +export const updateTenantMembership = (userId: number, tenantId: string, role: string): void => { + membershipStmt.update.run(role, userId, tenantId); +}; + +export const removeTenantMembership = (userId: number, tenantId: string): void => { + membershipStmt.remove.run(userId, tenantId); +}; diff --git a/server/index.ts b/server/index.ts index 45ccc35..eee1f28 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,7 @@ import QRCode from 'qrcode'; import { findUserById, findUserByEmail, findUserByGoogleId, createUser, linkGoogleId, setMfaSecret, verifyPassword, seedAdminUser, insertKillLog, + getMembershipsByUserId, getMembershipsByTenant, addTenantMembership, updateTenantMembership, removeTenantMembership, } from './db.js'; // ── Session type augmentation ─────────────────────────────────────────────── @@ -449,6 +450,13 @@ app.post('/api/auth/mfa/verify', (req: Request, unknown, { return res.json({ ok: true }); }); +app.get('/api/auth/roles', (req: Request, res: Response) => { + if (!req.isAuthenticated()) return res.status(401).json({ ok: false, error: 'Unauthenticated' }); + const user = req.user!; + const memberships = getMembershipsByUserId(user.id); + return res.json({ ok: true, data: memberships.map((m) => ({ tenant_id: m.tenant_id, role: m.role })) }); +}); + // ── Auth guard — all /api/* routes below require authentication ───────────── const isAuthenticated = (req: Request, res: Response, next: NextFunction) => { if (req.isAuthenticated()) return next(); @@ -460,6 +468,31 @@ const requireMfa = (req: Request, res: Response, next: NextFunction) => { return res.status(403).json({ ok: false, error: 'MFA verification required' }); }; +const requireRole = (...roles: string[]) => async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as Express.User | undefined; + if (!user) return res.status(401).json({ error: 'Not authenticated' }); + + // Check if user has any of the required roles in any tenant + const memberships = getMembershipsByUserId(user.id); + + // If no memberships exist (pre-migration / fresh install), allow access for backward compat + if (memberships.length === 0) return next(); + + const hasRole = memberships.some((m) => roles.includes(m.role)); + if (!hasRole) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + return next(); +}; + +// Convenience: require operator or above (owner, admin, operator) +const requireOperator = requireRole('owner', 'admin', 'operator'); +// Convenience: require admin or above (owner, admin) +const requireAdmin = requireRole('owner', 'admin'); +// Convenience: require owner +const requireOwner = requireRole('owner'); + app.use('/api', isAuthenticated); app.get('/api/health', (_req: Request, res: Response) => { @@ -537,7 +570,7 @@ app.get('/api/foxmemory/prompts', async (_req: Request, res: Response) => { } }); -app.put('/api/foxmemory/config/prompt', async (req: Request, unknown, { prompt: string | null }>, res: Response) => { +app.put('/api/foxmemory/config/prompt', requireAdmin, async (req: Request, unknown, { prompt: string | null }>, res: Response) => { try { const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/prompt`, { method: 'PUT', @@ -551,7 +584,7 @@ app.put('/api/foxmemory/config/prompt', async (req: Request, unknown, { prompt: string | null }>, res: Response) => { +app.put('/api/foxmemory/config/update-prompt', requireAdmin, async (req: Request, unknown, { prompt: string | null }>, res: Response) => { try { const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/update-prompt`, { method: 'PUT', @@ -565,7 +598,7 @@ app.put('/api/foxmemory/config/update-prompt', async (req: Request, unknown, { prompt: string | null }>, res: Response) => { +app.put('/api/foxmemory/config/graph-prompt', requireAdmin, async (req: Request, unknown, { prompt: string | null }>, res: Response) => { try { const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/graph-prompt`, { method: 'PUT', @@ -671,7 +704,7 @@ app.get('/api/foxmemory/config/models', async (_req: Request, res: Response) => } }); -app.put('/api/foxmemory/config/model', requireMfa, async (req: Request, unknown, { key: string; value: string }>, res: Response) => { +app.put('/api/foxmemory/config/model', requireMfa, requireAdmin, async (req: Request, unknown, { key: string; value: string }>, res: Response) => { try { const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/model`, { method: 'PUT', @@ -685,7 +718,7 @@ app.put('/api/foxmemory/config/model', requireMfa, async (req: Request, res: Response) => { +app.delete('/api/foxmemory/config/model/:key', requireMfa, requireAdmin, async (req: Request<{ key: string }>, res: Response) => { try { const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/model/${encodeURIComponent(req.params.key)}`, { method: 'DELETE', @@ -760,7 +793,7 @@ app.get('/api/foxmemory/overview', async (_req: Request, res: Response) => { } }); -app.post('/api/sessions/kill', (req: Request, unknown, KillRequestBody>, res: Response) => { +app.post('/api/sessions/kill', requireOperator, (req: Request, unknown, KillRequestBody>, res: Response) => { const { sessionKey, sessionId, reason } = req.body || {}; if (!sessionKey || !sessionId) return res.status(400).json({ ok: false, error: 'sessionKey and sessionId are required' }); @@ -788,6 +821,42 @@ app.post('/api/sessions/delete', async (req: Request, unkn } }); +// ── Tenant membership management (require owner) ─────────────────────────── + +const VALID_ROLES = ['owner', 'admin', 'operator', 'viewer']; + +app.get('/api/tenants/:tenantId/members', requireOwner, (req: Request<{ tenantId: string }>, res: Response) => { + const members = getMembershipsByTenant(req.params.tenantId); + return res.json({ ok: true, data: members }); +}); + +app.post('/api/tenants/:tenantId/members', requireOwner, (req: Request<{ tenantId: string }, unknown, { userId: number; role: string }>, res: Response) => { + const { userId, role } = req.body; + if (!userId || !role) return res.status(400).json({ ok: false, error: 'userId and role are required' }); + if (!VALID_ROLES.includes(role)) return res.status(400).json({ ok: false, error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }); + const user = findUserById(userId); + if (!user) return res.status(404).json({ ok: false, error: 'User not found' }); + try { + addTenantMembership(userId, req.params.tenantId, role); + return res.json({ ok: true }); + } catch (error: unknown) { + return res.status(409).json({ ok: false, error: 'Membership already exists' }); + } +}); + +app.put('/api/tenants/:tenantId/members/:userId', requireOwner, (req: Request<{ tenantId: string; userId: string }, unknown, { role: string }>, res: Response) => { + const { role } = req.body; + if (!role) return res.status(400).json({ ok: false, error: 'role is required' }); + if (!VALID_ROLES.includes(role)) return res.status(400).json({ ok: false, error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }); + updateTenantMembership(Number(req.params.userId), req.params.tenantId, role); + return res.json({ ok: true }); +}); + +app.delete('/api/tenants/:tenantId/members/:userId', requireOwner, (req: Request<{ tenantId: string; userId: string }>, res: Response) => { + removeTenantMembership(Number(req.params.userId), req.params.tenantId); + return res.json({ ok: true }); +}); + // Serve built web app in production const distPath = path.resolve(__dirname, '..', 'dist'); if (process.env.NODE_ENV === 'production' && fs.existsSync(distPath)) { diff --git a/web/src/App.tsx b/web/src/App.tsx index faecf5c..2bd2b3d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,6 +21,7 @@ import ModelConfigSection from './components/ModelConfigSection/ModelConfigSecti import LoginView from './components/LoginView/LoginView'; import MfaView from './components/MfaView/MfaView'; import SeatedFoxIcon from './components/shared/SeatedFoxIcon'; +import { useRbac } from './hooks/useRbac'; const AppShell = styled(Box, { shouldForwardProp: (p) => p !== 'mode' })<{ mode: 'light' | 'dark' }>(({ mode }) => ({ position: 'relative', @@ -115,6 +116,7 @@ const App = () => { const [killSession, { isLoading: killLoading }] = useKillSessionMutation(); const [deleteSession, { isLoading: deleteLoading }] = useDeleteSessionMutation(); + const { canEdit } = useRbac(); const cardShadow = mode === 'dark' ? '0 2px 8px rgba(0,0,0,0.3), 0 10px 28px rgba(0,0,0,0.4)' @@ -267,13 +269,14 @@ const App = () => { onDelete={onDelete} /> ) : section === 'config' ? ( - + ) : ( dispatch(setChartRange(r))} tabHealth={{ performance: foxIsError ? 'error' : foxmemory ? 'ok' : undefined }} + canEdit={canEdit} /> )} diff --git a/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.tsx b/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.tsx index c397cb1..e8acd5c 100644 --- a/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.tsx +++ b/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.tsx @@ -18,6 +18,7 @@ const PromptEditor = ({ effectivePrompt, source, onSave, + readOnly = false, }: { label: string; description: string; @@ -25,6 +26,7 @@ const PromptEditor = ({ effectivePrompt: string | null; source: string; onSave: (prompt: string | null) => Promise; + readOnly?: boolean; }) => { const [editing, setEditing] = useState(false); const [value, setValue] = useState(currentPrompt ?? effectivePrompt ?? ''); @@ -76,7 +78,7 @@ const PromptEditor = ({ /> {!editing ? ( - + ) : ( <>