feat: user isolation — ownership checks, is_global, per-user embeddings, private reports
- Add is_global column to adb_vector_configs and mcp_servers (schema + migration) - Add _verify_config_access() and _verify_report_access() helpers - Apply ownership checks to ~70 endpoints (OCI explore, actions, GenAI, MCP, ADB, reports, embeddings, terraform) - Reports are 100% private (even admin can't see others') - Add user_id to embedding metadata + filter in _vector_search/_vector_search_multi - Remove cross-user ADB fallback in _get_active_adb_configs - Add auth (token query param) to report HTML and compliance report iframes - Audit log: admin sees all, user/viewer sees only own - Embedding task status mapped to user - Frontend: GLOBAL badge on ADB/MCP configs, hide edit/delete for non-admin on global resources - Export/import includes is_global field
This commit is contained in:
@@ -127,9 +127,15 @@ export const reportsApi = {
|
||||
summary: (rid: string) =>
|
||||
client.get(`/reports/${rid}/summary`) as unknown as Promise<ReportSummary>,
|
||||
|
||||
htmlUrl: (rid: string) => `/api/reports/${rid}/html?_t=${Date.now()}`,
|
||||
htmlUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/html?token=${encodeURIComponent(token)}&_t=${Date.now()}`;
|
||||
},
|
||||
|
||||
complianceReportUrl: (rid: string) => `/api/reports/${rid}/compliance-report?_t=${Date.now()}`,
|
||||
complianceReportUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report?token=${encodeURIComponent(token)}&_t=${Date.now()}`;
|
||||
},
|
||||
|
||||
generateComplianceReport: (rid: string) =>
|
||||
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
/* ── Main ── */
|
||||
export default function AdbConfigPage() {
|
||||
const { genaiCfg, embModels } = useAppStore();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [configs, setConfigs] = useState<AdbConfigFull[]>([]);
|
||||
@@ -321,7 +323,12 @@ export default function AdbConfigPage() {
|
||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
||||
{(c as any).is_global === 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)' }}>{c.dsn}</code>
|
||||
@@ -422,25 +429,29 @@ export default function AdbConfigPage() {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('adb.edit')}
|
||||
</button>
|
||||
{(isAdmin || !(c as any).is_global) && (
|
||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('adb.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleTest(c.id)} disabled={testingMap[c.id]} className="btn btn-secondary btn-sm">
|
||||
{testingMap[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('adb.test')}
|
||||
</button>
|
||||
{confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('adb.yes')}
|
||||
{(isAdmin || !(c as any).is_global) && (
|
||||
confirmDelete === c.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('adb.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('adb.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('adb.delete')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('adb.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('adb.delete')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{testResults[c.id] && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
||||
import {
|
||||
@@ -39,6 +40,7 @@ function TypeBadge({ type }: { type: string }) {
|
||||
/* ── Main ── */
|
||||
export default function McpServersPage() {
|
||||
const { adbCfg } = useAppStore();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [servers, setServers] = useState<McpServerFull[]>([]);
|
||||
@@ -271,6 +273,9 @@ export default function McpServersPage() {
|
||||
<div className="flex items-center gap-2.5">
|
||||
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
||||
<TypeBadge type={srv.server_type} />
|
||||
{(srv as any).is_global === 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>GLOBAL</span>
|
||||
)}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
||||
style={{
|
||||
@@ -283,30 +288,34 @@ export default function McpServersPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('mcp.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(srv.id)}
|
||||
disabled={toggling[srv.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
||||
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
||||
</button>
|
||||
{confirmDelete === srv.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
{(isAdmin || !(srv as any).is_global) && (
|
||||
<>
|
||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('mcp.edit')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
<button
|
||||
onClick={() => handleToggle(srv.id)}
|
||||
disabled={toggling[srv.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
||||
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('mcp.delete')}
|
||||
</button>
|
||||
{confirmDelete === srv.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('mcp.delete')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user