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:
nogueiraguh
2026-04-01 01:05:54 -03:00
parent 1b3c02d10b
commit bc7024c95d
4 changed files with 282 additions and 85 deletions

View File

@@ -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 }>,

View File

@@ -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] && (

View File

@@ -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>