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:
261
backend/app.py
261
backend/app.py
File diff suppressed because it is too large
Load Diff
@@ -127,9 +127,15 @@ export const reportsApi = {
|
|||||||
summary: (rid: string) =>
|
summary: (rid: string) =>
|
||||||
client.get(`/reports/${rid}/summary`) as unknown as Promise<ReportSummary>,
|
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) =>
|
generateComplianceReport: (rid: string) =>
|
||||||
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
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 { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useAppStore } from '@/stores/app';
|
import { useAppStore } from '@/stores/app';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useI18n } from '@/i18n';
|
import { useI18n } from '@/i18n';
|
||||||
import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb';
|
import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,7 @@ function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
|||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
export default function AdbConfigPage() {
|
export default function AdbConfigPage() {
|
||||||
const { genaiCfg, embModels } = useAppStore();
|
const { genaiCfg, embModels } = useAppStore();
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [configs, setConfigs] = useState<AdbConfigFull[]>([]);
|
const [configs, setConfigs] = useState<AdbConfigFull[]>([]);
|
||||||
@@ -321,7 +323,12 @@ export default function AdbConfigPage() {
|
|||||||
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<strong className="text-xs" style={{ color: 'var(--t1)' }}>{c.config_name}</strong>
|
<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>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)' }}>{c.dsn}</code>
|
<code className="text-[.68rem]" style={{ fontFamily: 'var(--fm)', color: 'var(--t4)' }}>{c.dsn}</code>
|
||||||
@@ -422,13 +429,16 @@ export default function AdbConfigPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{(isAdmin || !(c as any).is_global) && (
|
||||||
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
<button onClick={() => startEdit(c)} className="btn btn-secondary btn-sm">
|
||||||
<Pencil size={10} /> {t('adb.edit')}
|
<Pencil size={10} /> {t('adb.edit')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={() => handleTest(c.id)} disabled={testingMap[c.id]} className="btn btn-secondary btn-sm">
|
<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')}
|
{testingMap[c.id] ? <Loader2 size={10} className="animate-spin" /> : <FlaskConical size={10} />} {t('adb.test')}
|
||||||
</button>
|
</button>
|
||||||
{confirmDelete === c.id ? (
|
{(isAdmin || !(c as any).is_global) && (
|
||||||
|
confirmDelete === c.id ? (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
<button onClick={() => handleDelete(c.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||||
<Check size={10} /> {t('adb.yes')}
|
<Check size={10} /> {t('adb.yes')}
|
||||||
@@ -441,6 +451,7 @@ export default function AdbConfigPage() {
|
|||||||
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
<button onClick={() => setConfirmDelete(c.id)} className="btn btn-danger btn-sm">
|
||||||
<Trash2 size={10} /> {t('adb.delete')}
|
<Trash2 size={10} /> {t('adb.delete')}
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{testResults[c.id] && (
|
{testResults[c.id] && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useAppStore } from '@/stores/app';
|
import { useAppStore } from '@/stores/app';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useI18n } from '@/i18n';
|
import { useI18n } from '@/i18n';
|
||||||
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,7 @@ function TypeBadge({ type }: { type: string }) {
|
|||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
export default function McpServersPage() {
|
export default function McpServersPage() {
|
||||||
const { adbCfg } = useAppStore();
|
const { adbCfg } = useAppStore();
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [servers, setServers] = useState<McpServerFull[]>([]);
|
const [servers, setServers] = useState<McpServerFull[]>([]);
|
||||||
@@ -271,6 +273,9 @@ export default function McpServersPage() {
|
|||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
||||||
<TypeBadge type={srv.server_type} />
|
<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
|
<span
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
||||||
style={{
|
style={{
|
||||||
@@ -283,6 +288,8 @@ export default function McpServersPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
|
{(isAdmin || !(srv as any).is_global) && (
|
||||||
|
<>
|
||||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||||
<Pencil size={10} /> {t('mcp.edit')}
|
<Pencil size={10} /> {t('mcp.edit')}
|
||||||
</button>
|
</button>
|
||||||
@@ -308,6 +315,8 @@ export default function McpServersPage() {
|
|||||||
<Trash2 size={10} /> {t('mcp.delete')}
|
<Trash2 size={10} /> {t('mcp.delete')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user