Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
};
81 changes: 75 additions & 6 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down Expand Up @@ -449,6 +450,13 @@ app.post('/api/auth/mfa/verify', (req: Request<Record<string, never>, 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();
Expand All @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deny role-gated routes for users with no memberships

The RBAC middleware currently grants access when the current user has zero membership rows, which turns every unassigned account into an implicit owner/admin/operator. This check is intended for backward compatibility when RBAC is globally uninitialized, but as written it bypasses authorization per-user, so in any deployment where some users are assigned roles and others are not, unassigned users can still call protected mutation endpoints.

Useful? React with 👍 / 👎.


const hasRole = memberships.some((m) => roles.includes(m.role));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope role checks to requested tenant before allowing access

Authorization is computed from memberships.some(...) across all tenant memberships, with no tenant scoping, so any user who is owner in one tenant also satisfies requireOwner for every /:tenantId/... route. In multi-tenant setups, this lets an owner/admin/operator from tenant A perform protected actions against tenant B by passing B’s tenantId in the URL.

Useful? React with 👍 / 👎.

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) => {
Expand Down Expand Up @@ -537,7 +570,7 @@ app.get('/api/foxmemory/prompts', async (_req: Request, res: Response) => {
}
});

app.put('/api/foxmemory/config/prompt', async (req: Request<Record<string, never>, unknown, { prompt: string | null }>, res: Response) => {
app.put('/api/foxmemory/config/prompt', requireAdmin, async (req: Request<Record<string, never>, unknown, { prompt: string | null }>, res: Response) => {
try {
const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/prompt`, {
method: 'PUT',
Expand All @@ -551,7 +584,7 @@ app.put('/api/foxmemory/config/prompt', async (req: Request<Record<string, never
}
});

app.put('/api/foxmemory/config/update-prompt', async (req: Request<Record<string, never>, unknown, { prompt: string | null }>, res: Response) => {
app.put('/api/foxmemory/config/update-prompt', requireAdmin, async (req: Request<Record<string, never>, unknown, { prompt: string | null }>, res: Response) => {
try {
const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/update-prompt`, {
method: 'PUT',
Expand All @@ -565,7 +598,7 @@ app.put('/api/foxmemory/config/update-prompt', async (req: Request<Record<string
}
});

app.put('/api/foxmemory/config/graph-prompt', async (req: Request<Record<string, never>, unknown, { prompt: string | null }>, res: Response) => {
app.put('/api/foxmemory/config/graph-prompt', requireAdmin, async (req: Request<Record<string, never>, unknown, { prompt: string | null }>, res: Response) => {
try {
const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/graph-prompt`, {
method: 'PUT',
Expand Down Expand Up @@ -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<Record<string, never>, unknown, { key: string; value: string }>, res: Response) => {
app.put('/api/foxmemory/config/model', requireMfa, requireAdmin, async (req: Request<Record<string, never>, unknown, { key: string; value: string }>, res: Response) => {
try {
const upstream = await fetch(`${foxmemoryBaseUrl}/v2/config/model`, {
method: 'PUT',
Expand All @@ -685,7 +718,7 @@ app.put('/api/foxmemory/config/model', requireMfa, async (req: Request<Record<st
}
});

app.delete('/api/foxmemory/config/model/:key', requireMfa, async (req: Request<{ key: string }>, 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',
Expand Down Expand Up @@ -760,7 +793,7 @@ app.get('/api/foxmemory/overview', async (_req: Request, res: Response) => {
}
});

app.post('/api/sessions/kill', (req: Request<Record<string, never>, unknown, KillRequestBody>, res: Response) => {
app.post('/api/sessions/kill', requireOperator, (req: Request<Record<string, never>, 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' });

Expand Down Expand Up @@ -788,6 +821,42 @@ app.post('/api/sessions/delete', async (req: Request<Record<string, never>, 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)) {
Expand Down
5 changes: 4 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)'
Expand Down Expand Up @@ -267,13 +269,14 @@ const App = () => {
onDelete={onDelete}
/>
) : section === 'config' ? (
<ModelConfigSection />
<ModelConfigSection canEdit={canEdit} />
) : (
<FoxMemorySection
foxmemory={foxmemory}
chartRange={chartRange}
onChartRangeChange={(r) => dispatch(setChartRange(r))}
tabHealth={{ performance: foxIsError ? 'error' : foxmemory ? 'ok' : undefined }}
canEdit={canEdit}
/>
)}
</Container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ const PromptEditor = ({
effectivePrompt,
source,
onSave,
readOnly = false,
}: {
label: string;
description: string;
currentPrompt: string | null;
effectivePrompt: string | null;
source: string;
onSave: (prompt: string | null) => Promise<void>;
readOnly?: boolean;
}) => {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(currentPrompt ?? effectivePrompt ?? '');
Expand Down Expand Up @@ -76,7 +78,7 @@ const PromptEditor = ({
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{!editing ? (
<Button variant="outlined" size="small" onClick={handleEdit}>Edit</Button>
<Button variant="outlined" size="small" onClick={handleEdit} disabled={readOnly}>Edit</Button>
) : (
<>
<Button
Expand All @@ -103,7 +105,7 @@ const PromptEditor = ({
);
};

const FoxMemoryAgentsView = ({ foxmemory, prompts, promptsLoading, onSaveExtractionPrompt, onSaveUpdatePrompt, onSaveGraphPrompt }: FoxMemoryAgentsViewProps) => (
const FoxMemoryAgentsView = ({ foxmemory, prompts, promptsLoading, onSaveExtractionPrompt, onSaveUpdatePrompt, onSaveGraphPrompt, canEdit = true }: FoxMemoryAgentsViewProps) => (
<>
<Grid container spacing={2} mb={2}>
<Grid item xs={12} md={4}>
Expand Down Expand Up @@ -176,6 +178,7 @@ const FoxMemoryAgentsView = ({ foxmemory, prompts, promptsLoading, onSaveExtract
effectivePrompt={prompts?.extractionPrompt.effective_prompt ?? null}
source={prompts?.extractionPrompt.source ?? '—'}
onSave={onSaveExtractionPrompt}
readOnly={!canEdit}
/>
<PromptEditor
label="Call 2 — Update Decision"
Expand All @@ -184,6 +187,7 @@ const FoxMemoryAgentsView = ({ foxmemory, prompts, promptsLoading, onSaveExtract
effectivePrompt={prompts?.updatePrompt.effective_prompt ?? null}
source={prompts?.updatePrompt.source ?? '—'}
onSave={onSaveUpdatePrompt}
readOnly={!canEdit}
/>
<PromptEditor
label="Call 3 — Graph Entity Extraction"
Expand All @@ -192,6 +196,7 @@ const FoxMemoryAgentsView = ({ foxmemory, prompts, promptsLoading, onSaveExtract
effectivePrompt={prompts?.graphPrompt.effective_prompt ?? null}
source={prompts?.graphPrompt.source ?? '—'}
onSave={onSaveGraphPrompt}
readOnly={!canEdit}
/>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ export interface FoxMemoryAgentsViewProps {
onSaveExtractionPrompt: (prompt: string | null) => Promise<void>;
onSaveUpdatePrompt: (prompt: string | null) => Promise<void>;
onSaveGraphPrompt: (prompt: string | null) => Promise<void>;
/** When false, prompt editing is disabled (viewer role) */
canEdit?: boolean;
}
3 changes: 2 additions & 1 deletion web/src/components/FoxMemorySection/FoxMemorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const TabLabel = ({ label, health }: { label: string; health?: import('../../typ
</Box>
);

const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth }: FoxMemorySectionProps) => {
const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth, canEdit = true }: FoxMemorySectionProps) => {
const [subView, setSubView] = useState<SubView>('performance');
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const toggleRow = (i: number) => setExpandedRows((prev) => {
Expand Down Expand Up @@ -120,6 +120,7 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth
onSaveExtractionPrompt={onSaveExtractionPrompt}
onSaveUpdatePrompt={onSaveUpdatePrompt}
onSaveGraphPrompt={onSaveGraphPrompt}
canEdit={canEdit}
/>
) : (
<>
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/FoxMemorySection/FoxMemorySection.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export interface FoxMemorySectionProps {
chartRange: ChartRange;
onChartRangeChange: (range: ChartRange) => void;
tabHealth?: { performance?: SectionHealth; agents?: SectionHealth; graph?: SectionHealth };
/** When false, mutation controls (prompt editing, etc.) are disabled */
canEdit?: boolean;
}
Loading