This commit is contained in:
David 2026-03-13 12:43:20 +01:00
parent 3051f71edd
commit 279e8f88c3
17 changed files with 1120 additions and 318 deletions

View File

@ -36,6 +36,7 @@ Go Proxy [cmd/proxy] — chi router, zap logger, viper config
├── internal/proxy/ Core request handler (PII → upstream → audit → response) ├── internal/proxy/ Core request handler (PII → upstream → audit → response)
├── internal/apierror/ OpenAI-format error helpers (WriteError, WriteErrorWithRequestID) ├── internal/apierror/ OpenAI-format error helpers (WriteError, WriteErrorWithRequestID)
├── internal/health/ /healthz, /docs, /playground, /playground/analyze handlers ├── internal/health/ /healthz, /docs, /playground, /playground/analyze handlers
├── internal/notifications/ SMTP email notifications — budget alert emails (POST /v1/notifications/send)
└── internal/config/ Viper-based config loader (VEYLANT_* env var overrides) └── internal/config/ Viper-based config loader (VEYLANT_* env var overrides)
│ gRPC (<2ms) to localhost:50051 │ gRPC (<2ms) to localhost:50051
PII Detection Service [services/pii] — FastAPI + grpc.aio PII Detection Service [services/pii] — FastAPI + grpc.aio
@ -136,8 +137,20 @@ pytest services/pii/tests/test_file.py::test_function
**Provider configs:** LLM provider API keys are stored encrypted (AES-256-GCM) in the `provider_configs` table (migration 000011). CRUD via `GET|POST /v1/admin/providers`, `PUT|DELETE|POST-test /v1/admin/providers/{id}`. Adapters hot-reload on save/update without proxy restart (`router.UpdateAdapter()` / `RemoveAdapter()`). **Provider configs:** LLM provider API keys are stored encrypted (AES-256-GCM) in the `provider_configs` table (migration 000011). CRUD via `GET|POST /v1/admin/providers`, `PUT|DELETE|POST-test /v1/admin/providers/{id}`. Adapters hot-reload on save/update without proxy restart (`router.UpdateAdapter()` / `RemoveAdapter()`).
**Email notifications:** Budget alert emails via SMTP. Configured with `notifications.smtp.host/port/username/password/from/from_name`. Endpoint `POST /v1/notifications/send` (JWT required) — called by the frontend when a budget threshold is crossed. Disabled gracefully if SMTP config is absent. Email body is French HTML.
**`admin.Handler` builder pattern:** The handler is wired via method chaining — `adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)`. All `With*` methods are optional; missing dependencies disable their feature. **`admin.Handler` builder pattern:** The handler is wired via method chaining — `adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)`. All `With*` methods are optional; missing dependencies disable their feature.
**`compliance.Handler` builder pattern:** `compliance.New(compStore, logger).WithAudit(auditLogger).WithDB(db).WithTenantName(cfg.Server.TenantName)`. `WithAudit` is required for GDPR access/erase and CSV export; `WithDB` is required for Art.17 erasure log; `WithTenantName` sets the name shown in PDF headers (config key: `server.tenant_name`). PDF reports use the `go-pdf/fpdf` library. Compliance endpoints are mounted at `/v1/admin/compliance/`:
- `GET/POST /entries`, `GET/PUT/DELETE /entries/{id}` — GDPR Art.30 processing registry
- `POST /entries/{id}/classify` — AI Act risk classification (answers map → `ScoreRisk()`)
- `GET /report/article30`, `GET /report/aiact`, `GET /dpia/{id}` — PDF reports (`?format=json` returns JSON instead)
- `GET /gdpr/access/{user_id}` — GDPR Art.15 (returns audit log for that user)
- `DELETE /gdpr/erase/{user_id}` — GDPR Art.17 soft-delete (`users.is_active=FALSE`); logged to `gdpr_erasure_log` table
- `GET /export/logs` — CSV export (up to 10,000 rows)
**Audit log date filtering:** The `GET /v1/admin/logs` and `/export/logs` endpoints accept both `RFC3339Nano` (JavaScript `toISOString()` format, e.g. `2026-03-10T11:30:00.000Z`) and `RFC3339` for `start`/`end` query params. The export endpoint additionally accepts `YYYY-MM-DD` date-only strings (date input HTML element); when end is date-only, the full day (23:59:59) is included automatically.
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`. **Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
**Tenant onboarding** (after `make dev`): **Tenant onboarding** (after `make dev`):

View File

@ -334,12 +334,12 @@ func main() {
// Public — CORS applied, no auth required. // Public — CORS applied, no auth required.
r.Post("/auth/login", loginHandler.ServeHTTP) r.Post("/auth/login", loginHandler.ServeHTTP)
// Protected — JWT auth + tenant rate limit. // Protected — JWT auth required for all routes below.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.Auth(jwtVerifier)) r.Use(middleware.Auth(jwtVerifier))
r.Use(middleware.RateLimit(rateLimiter))
r.Post("/chat/completions", proxyHandler.ServeHTTP) // Rate limit applied only to the LLM proxy — not to admin/dashboard API calls.
r.With(middleware.RateLimit(rateLimiter)).Post("/chat/completions", proxyHandler.ServeHTTP)
// Email notification delivery — called by the frontend when a budget threshold is crossed. // Email notification delivery — called by the frontend when a budget threshold is crossed.
if notifHandler != nil { if notifHandler != nil {

View File

@ -342,13 +342,23 @@ func (h *Handler) getLogs(w http.ResponseWriter, r *http.Request) {
Limit: parseIntParam(r, "limit", 50), Limit: parseIntParam(r, "limit", 50),
Offset: parseIntParam(r, "offset", 0), Offset: parseIntParam(r, "offset", 0),
} }
// Accept both RFC3339Nano (JavaScript toISOString: "2026-03-10T11:30:00.000Z")
// and RFC3339 (API clients without sub-second precision).
parseTime := func(s string) time.Time {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
if s := r.URL.Query().Get("start"); s != "" { if s := r.URL.Query().Get("start"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t := parseTime(s); !t.IsZero() {
q.StartTime = t q.StartTime = t
} }
} }
if s := r.URL.Query().Get("end"); s != "" { if s := r.URL.Query().Get("end"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t := parseTime(s); !t.IsZero() {
q.EndTime = t q.EndTime = t
} }
} }
@ -379,13 +389,21 @@ func (h *Handler) getCosts(w http.ResponseWriter, r *http.Request) {
TenantID: tenantID, TenantID: tenantID,
GroupBy: r.URL.Query().Get("group_by"), GroupBy: r.URL.Query().Get("group_by"),
} }
parseTime := func(s string) time.Time {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
if s := r.URL.Query().Get("start"); s != "" { if s := r.URL.Query().Get("start"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t := parseTime(s); !t.IsZero() {
q.StartTime = t q.StartTime = t
} }
} }
if s := r.URL.Query().Get("end"); s != "" { if s := r.URL.Query().Get("end"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t := parseTime(s); !t.IsZero() {
q.EndTime = t q.EndTime = t
} }
} }

View File

@ -84,8 +84,10 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error)
filtered = make([]AuditEntry, 0) filtered = make([]AuditEntry, 0)
} }
limit := q.Limit limit := q.Limit
if limit <= 0 || limit > 200 { if limit <= 0 {
limit = 50 limit = 50
} else if limit > 10000 {
limit = 10000
} }
if len(filtered) > limit { if len(filtered) > limit {
filtered = filtered[:limit] filtered = filtered[:limit]

View File

@ -245,6 +245,14 @@ func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
req.Processors = []string{} req.Processors = []string{}
} }
// Fetch existing entry to preserve AI Act classification (risk_level + ai_act_answers).
// Without this, every Registre edit would null out a previously saved classification.
existing, fetchErr := h.store.Get(r.Context(), id, tenantID)
if fetchErr != nil {
writeStoreError(w, fetchErr)
return
}
entry := ProcessingEntry{ entry := ProcessingEntry{
ID: id, ID: id,
TenantID: tenantID, TenantID: tenantID,
@ -258,6 +266,8 @@ func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
SecurityMeasures: req.SecurityMeasures, SecurityMeasures: req.SecurityMeasures,
ControllerName: req.ControllerName, ControllerName: req.ControllerName,
IsActive: true, IsActive: true,
RiskLevel: existing.RiskLevel,
AiActAnswers: existing.AiActAnswers,
} }
updated, err := h.store.Update(r.Context(), entry) updated, err := h.store.Update(r.Context(), entry)
if err != nil { if err != nil {
@ -455,11 +465,22 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
return return
} }
targetUser := chi.URLParam(r, "user_id") targetUser := chi.URLParam(r, "user_id")
reason := r.URL.Query().Get("reason")
requestedBy := userFrom(r) requestedBy := userFrom(r)
// reason is sent as JSON body by the frontend; fall back to query param for API clients.
var reason string
var bodyReq struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&bodyReq); err == nil && bodyReq.Reason != "" {
reason = bodyReq.Reason
} else {
reason = r.URL.Query().Get("reason")
}
// Soft-delete user in users table // Soft-delete user in users table
recordsDeleted := 0 recordsDeleted := 0
erasureID := ""
if h.db != nil { if h.db != nil {
res, err := h.db.ExecContext(r.Context(), res, err := h.db.ExecContext(r.Context(),
`UPDATE users SET is_active=FALSE, updated_at=NOW() WHERE email=$1 AND tenant_id=$2`, `UPDATE users SET is_active=FALSE, updated_at=NOW() WHERE email=$1 AND tenant_id=$2`,
@ -472,12 +493,12 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
recordsDeleted = int(n) recordsDeleted = int(n)
} }
// Log erasure (immutable) // Log erasure (immutable) — read back generated UUID for the response.
_, logErr := h.db.ExecContext(r.Context(), logErr := h.db.QueryRowContext(r.Context(),
`INSERT INTO gdpr_erasure_log (tenant_id, target_user, requested_by, reason, records_deleted) `INSERT INTO gdpr_erasure_log (tenant_id, target_user, requested_by, reason, records_deleted)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5) RETURNING id`,
tenantID, targetUser, requestedBy, reason, recordsDeleted, tenantID, targetUser, requestedBy, reason, recordsDeleted,
) ).Scan(&erasureID)
if logErr != nil { if logErr != nil {
h.logger.Error("GDPR erase: failed to write erasure log", zap.Error(logErr)) h.logger.Error("GDPR erase: failed to write erasure log", zap.Error(logErr))
} }
@ -490,6 +511,7 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
) )
writeJSON(w, http.StatusOK, ErasureRecord{ writeJSON(w, http.StatusOK, ErasureRecord{
ID: erasureID,
TenantID: tenantID, TenantID: tenantID,
TargetUser: targetUser, TargetUser: targetUser,
RequestedBy: requestedBy, RequestedBy: requestedBy,
@ -519,13 +541,26 @@ func (h *Handler) exportLogsCSV(w http.ResponseWriter, r *http.Request) {
Provider: r.URL.Query().Get("provider"), Provider: r.URL.Query().Get("provider"),
Limit: 10000, Limit: 10000,
} }
// Accept both RFC3339 (API clients) and date-only YYYY-MM-DD (HTML date input from frontend).
parseDate := func(s string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
if s := r.URL.Query().Get("start"); s != "" { if s := r.URL.Query().Get("start"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t := parseDate(s); !t.IsZero() {
q.StartTime = t q.StartTime = t
} }
} }
if s := r.URL.Query().Get("end"); s != "" { if s := r.URL.Query().Get("end"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t := parseDate(s); !t.IsZero() {
// For date-only end, include the full day (end at 23:59:59).
if len(s) == 10 {
t = t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
q.EndTime = t q.EndTime = t
} }
} }

View File

@ -191,14 +191,15 @@ func GenerateArticle30(entries []ProcessingEntry, tenantName string, w io.Writer
} }
} }
if len(allProcessors) == 0 { if len(allProcessors) == 0 {
allProcessors["OpenAI (GPT-4o)"] = true setFont(pdf, "I", 9, colGray)
allProcessors["Anthropic (Claude)"] = true pdf.CellFormat(0, 6, "Aucun sous-traitant déclaré.", "", 1, "L", false, 0, "")
} } else {
for proc := range allProcessors { for proc := range allProcessors {
setFont(pdf, "", 9, colBlack) setFont(pdf, "", 9, colBlack)
pdf.CellFormat(5, 6, "•", "", 0, "L", false, 0, "") pdf.CellFormat(5, 6, "•", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, proc+" — fournisseur LLM (sous-traitant au sens de l'Art. 28 RGPD)", "", 1, "L", false, 0, "") pdf.CellFormat(0, 6, proc+" — fournisseur LLM (sous-traitant au sens de l'Art. 28 RGPD)", "", 1, "L", false, 0, "")
} }
}
// Section 4 — Durées de conservation // Section 4 — Durées de conservation
sectionHeader(pdf, "4. Durées de Conservation") sectionHeader(pdf, "4. Durées de Conservation")

67
test_smtp.go Normal file
View File

@ -0,0 +1,67 @@
//go:build ignore
// Quick SMTP diagnostic — run with:
// SMTP_USER=dharnaud77@gmail.com SMTP_PASS=xsmtpsib-... go run test_smtp.go
package main
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"os"
)
func main() {
host := "smtp-relay.brevo.com"
port := 587
user := os.Getenv("SMTP_USER")
pass := os.Getenv("SMTP_PASS")
if user == "" || pass == "" {
fmt.Fprintln(os.Stderr, "Usage: SMTP_USER=... SMTP_PASS=... go run test_smtp.go")
os.Exit(1)
}
addr := fmt.Sprintf("%s:%d", host, port)
fmt.Printf("Dialing %s ...\n", addr)
conn, err := net.Dial("tcp", addr)
if err != nil {
fmt.Printf("FAIL dial: %v\n", err)
os.Exit(1)
}
client, err := smtp.NewClient(conn, host)
if err != nil {
fmt.Printf("FAIL smtp.NewClient: %v\n", err)
os.Exit(1)
}
defer client.Close() //nolint:errcheck
if ok, _ := client.Extension("STARTTLS"); ok {
fmt.Println("OK STARTTLS advertised")
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
fmt.Printf("FAIL StartTLS: %v\n", err)
os.Exit(1)
}
fmt.Println("OK STARTTLS negotiated")
} else {
fmt.Println("WARN STARTTLS not advertised — credentials will be sent in clear")
}
if ok, params := client.Extension("AUTH"); ok {
fmt.Printf("OK AUTH methods advertised: %s\n", params)
} else {
fmt.Println("WARN no AUTH advertised in EHLO")
}
auth := smtp.PlainAuth("", user, pass, host)
if err = client.Auth(auth); err != nil {
fmt.Printf("FAIL AUTH PLAIN: %v\n\n", err)
fmt.Println("→ Le SMTP key est invalide ou révoqué.")
fmt.Println(" Génère-en un nouveau sur app.brevo.com → Settings → SMTP & API → SMTP Keys")
os.Exit(1)
}
fmt.Println("OK AUTH PLAIN success — credentials valides !")
_ = client.Quit()
}

View File

@ -21,11 +21,14 @@ function buildQueryString(params: Record<string, string | number | undefined>):
} }
export function useAuditLogs(params: AuditQueryParams = {}, refetchInterval?: number) { export function useAuditLogs(params: AuditQueryParams = {}, refetchInterval?: number) {
// Use the serialized query string as the key — stable across re-renders as long
// as the param values don't change (avoids a new request on every millisecond).
const qs = buildQueryString(params as Record<string, string | number | undefined>); const qs = buildQueryString(params as Record<string, string | number | undefined>);
return useQuery({ return useQuery({
queryKey: ["logs", params], queryKey: ["logs", qs],
queryFn: () => apiFetch<AuditResult>(`/v1/admin/logs${qs}`), queryFn: () => apiFetch<AuditResult>(`/v1/admin/logs${qs}`),
refetchInterval, refetchInterval,
staleTime: 30_000, // don't refetch if data is less than 30s old
}); });
} }
@ -35,6 +38,7 @@ export function useRequestCount() {
queryKey: ["logs", "count"], queryKey: ["logs", "count"],
queryFn: () => apiFetch<AuditResult>("/v1/admin/logs?limit=1&offset=0"), queryFn: () => apiFetch<AuditResult>("/v1/admin/logs?limit=1&offset=0"),
select: (d) => d.total, select: (d) => d.total,
refetchInterval: 30_000, refetchInterval: 60_000,
staleTime: 30_000,
}); });
} }

View File

@ -18,35 +18,41 @@ import { fr } from "date-fns/locale";
type Range = "7d" | "30d"; type Range = "7d" | "30d";
// Rounds `end` UP to the start of the NEXT minute so:
// • all data from the current minute is included (no recent entries cut off)
// • the key string is identical for every render within the same 60-second window
// → React Query reuses the cached result, no per-render HTTP floods
function buildDateRange(range: Range) { function buildDateRange(range: Range) {
const days = range === "7d" ? 7 : 30; const days = range === "7d" ? 7 : 30;
const end = new Date(); const now = new Date();
// setSeconds(60, 0) rolls the clock forward to the next whole minute in JS.
// e.g. 18:48:34.713 → 18:49:00.000
now.setSeconds(60, 0);
const end = now;
const start = subDays(end, days); const start = subDays(end, days);
return { start: start.toISOString(), end: end.toISOString() }; return { start: start.toISOString(), end: end.toISOString() };
} }
export function VolumeChart() { export function VolumeChart() {
const [range, setRange] = useState<Range>("7d"); const [range, setRange] = useState<Range>("7d");
const { start, end } = buildDateRange(range);
const days = range === "7d" ? 7 : 30; const days = range === "7d" ? 7 : 30;
const { data, isLoading } = useAuditLogs( const { start, end } = buildDateRange(range);
const { data, isLoading, isError } = useAuditLogs(
{ start, end, limit: 1000 }, { start, end, limit: 1000 },
30_000 60_000 // 60s — halves request frequency vs previous 30s
); );
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!data?.data) return []; // Pre-fill all days in range with zeros regardless of API result.
// Group by day
const map = new Map<string, { requests: number; errors: number }>(); const map = new Map<string, { requests: number; errors: number }>();
// Pre-fill all days in range
for (let i = days - 1; i >= 0; i--) { for (let i = days - 1; i >= 0; i--) {
const d = format(subDays(new Date(), i), "yyyy-MM-dd"); const d = format(subDays(new Date(), i), "yyyy-MM-dd");
map.set(d, { requests: 0, errors: 0 }); map.set(d, { requests: 0, errors: 0 });
} }
if (data?.data) {
for (const entry of data.data) { for (const entry of data.data) {
const day = format(startOfDay(parseISO(entry.timestamp)), "yyyy-MM-dd"); const day = format(startOfDay(parseISO(entry.timestamp)), "yyyy-MM-dd");
const existing = map.get(day) ?? { requests: 0, errors: 0 }; const existing = map.get(day) ?? { requests: 0, errors: 0 };
@ -54,6 +60,7 @@ export function VolumeChart() {
if (entry.status === "error") existing.errors++; if (entry.status === "error") existing.errors++;
map.set(day, existing); map.set(day, existing);
} }
}
return Array.from(map.entries()).map(([date, stats]) => ({ return Array.from(map.entries()).map(([date, stats]) => ({
date: format(parseISO(date), "dd/MM", { locale: fr }), date: format(parseISO(date), "dd/MM", { locale: fr }),
@ -62,6 +69,8 @@ export function VolumeChart() {
})); }));
}, [data, days]); }, [data, days]);
const hasActivity = chartData.some((d) => d.Requêtes > 0 || d.Erreurs > 0);
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@ -86,6 +95,15 @@ export function VolumeChart() {
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-64 w-full" /> <Skeleton className="h-64 w-full" />
) : isError ? (
<div className="h-64 flex items-center justify-center text-sm text-muted-foreground">
Impossible de charger les données.
</div>
) : !hasActivity ? (
<div className="h-64 flex flex-col items-center justify-center gap-2 text-muted-foreground">
<p className="text-sm">Aucune requête sur les {days} derniers jours.</p>
<p className="text-xs">Les données apparaîtront après le premier appel via le proxy.</p>
</div>
) : ( ) : (
<ResponsiveContainer width="100%" height={256}> <ResponsiveContainer width="100%" height={256}>
<LineChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}> <LineChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
@ -95,22 +113,24 @@ export function VolumeChart() {
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
className="text-muted-foreground" className="text-muted-foreground"
/> />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" /> <YAxis tick={{ fontSize: 12 }} allowDecimals={false} className="text-muted-foreground" />
<Tooltip /> <Tooltip />
<Legend /> <Legend />
<Line <Line
type="monotone" type="monotone"
dataKey="Requêtes" dataKey="Requêtes"
stroke="hsl(222.2 47.4% 11.2%)" stroke="#1e3a5f"
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
activeDot={{ r: 4 }}
/> />
<Line <Line
type="monotone" type="monotone"
dataKey="Erreurs" dataKey="Erreurs"
stroke="hsl(0 84.2% 60.2%)" stroke="#ef4444"
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
activeDot={{ r: 4 }}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@ -8,7 +8,7 @@ import { fr } from "date-fns/locale";
export function OverviewPage() { export function OverviewPage() {
const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount(); const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount();
const { data: costData, isLoading: costsLoading } = useCosts({}, 30_000); const { data: costData, isLoading: costsLoading } = useCosts({}, 60_000);
const totalCost = costData?.data?.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0; const totalCost = costData?.data?.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0;
const totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0; const totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;

View File

@ -8,103 +8,146 @@ export function AdminCompliancePage() {
<div> <div>
<h1 id="admin-compliance">Admin Compliance</h1> <h1 id="admin-compliance">Admin Compliance</h1>
<p> <p>
GDPR Article 30 processing registry, EU AI Act risk classification, and data subject GDPR Article 30 processing registry, EU AI Act risk classification, DPIA generation, PDF
rights (access and erasure). reports, CSV export, and GDPR subject rights (access and erasure). All endpoints are mounted
under <code>/v1/admin/compliance</code> and require a valid JWT.
</p> </p>
<Callout type="info" title="Required role"> <Callout type="info" title="Required role">
Compliance endpoints require <code>admin</code> role. Auditors can read but not modify. Read endpoints (<code>GET</code>) are accessible to <code>admin</code> and{" "}
<code>auditor</code> roles. Write endpoints (<code>POST</code>, <code>PUT</code>,{" "}
<code>DELETE</code>) require <code>admin</code>.
</Callout> </Callout>
<h2 id="processing-registry">GDPR Article 30 Processing Registry</h2> <h2 id="processing-registry">GDPR Article 30 Processing Registry</h2>
<p>
Every AI use case that may process personal data must be documented in the registry. The
compliance module generates the legally required Article 30 register directly from these
entries.
</p>
<ApiEndpoint method="GET" path="/v1/admin/compliance/entries" description="List all processing activities in the GDPR Art. 30 registry." /> <ApiEndpoint method="GET" path="/v1/admin/compliance/entries" description="List all processing activities for the authenticated tenant." />
<ApiEndpoint method="POST" path="/v1/admin/compliance/entries" description="Create a new processing activity entry." /> <ApiEndpoint method="POST" path="/v1/admin/compliance/entries" description="Create a new processing activity entry." />
<ApiEndpoint method="GET" path="/v1/admin/compliance/entries/{id}" description="Get a specific processing entry." /> <ApiEndpoint method="GET" path="/v1/admin/compliance/entries/{id}" description="Get a specific processing entry by ID." />
<ApiEndpoint method="PUT" path="/v1/admin/compliance/entries/{id}" description="Update a processing activity entry." /> <ApiEndpoint method="PUT" path="/v1/admin/compliance/entries/{id}" description="Update a processing activity. AI Act classification (risk_level, ai_act_answers) is preserved automatically." />
<ApiEndpoint method="DELETE" path="/v1/admin/compliance/entries/{id}" description="Soft-delete a processing entry (is_active = false)." />
<ParamTable <ParamTable
title="Processing Entry Fields" title="Processing Entry Fields"
params={[ params={[
{ name: "use_case_name", type: "string", required: true, description: "Name of the AI use case (e.g. 'Legal contract analysis')." }, { name: "use_case_name", type: "string", required: true, description: "Name of the AI use case (e.g. 'Legal contract analysis')." },
{ name: "purpose", type: "string", required: true, description: "Processing purpose as required by GDPR Art. 5." }, { name: "purpose", type: "string", required: true, description: "Processing purpose as required by GDPR Art. 5(1)(b)." },
{ name: "legal_basis", type: "string", required: true, description: "Legal basis: legitimate_interest | contract | legal_obligation | consent | vital_interests | public_task" }, { name: "legal_basis", type: "string", required: true, description: "Legal basis: consent | contract | legal_obligation | vital_interests | public_task | legitimate_interest" },
{ name: "data_categories", type: "[]string", required: true, description: "Categories of personal data: name, email, phone, id_number, financial, health, etc." }, { name: "data_categories", type: "[]string", required: true, description: "Categories of personal data: name, email, phone, id_number, financial, health, biometric, etc." },
{ name: "retention_period", type: "string", required: true, description: "Data retention period (e.g. '3 years', '90 days')." }, { name: "retention_period", type: "string", required: true, description: "Data retention period (e.g. '3 years', '90 days', '6 months after contract end')." },
{ name: "security_measures", type: "string", required: true, description: "Technical and organizational security measures." }, { name: "security_measures", type: "string", required: false, description: "Technical and organizational security measures in place." },
{ name: "controller_name", type: "string", required: true, description: "Data controller name and contact." }, { name: "controller_name", type: "string", required: false, description: "Data controller name and DPO contact." },
{ name: "recipients", type: "[]string", required: false, description: "Third parties receiving the data." }, { name: "recipients", type: "[]string", required: false, description: "Third parties receiving the data (e.g. 'OpenAI via Veylant IA proxy')." },
{ name: "processors", type: "[]string", required: false, description: "Data processors (LLM providers, cloud services)." }, { name: "processors", type: "[]string", required: false, description: "Sub-processors involved in the processing." },
{ name: "dpia_required", type: "boolean", required: false, default: "false", description: "Whether a DPIA (Art. 35) is required." },
]} ]}
/> />
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl -X POST http://localhost:8090/v1/admin/compliance/entries \\ code={`# Create a processing entry
curl -X POST http://localhost:8090/v1/admin/compliance/entries \\
-H "Authorization: Bearer $TOKEN" \\ -H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{ -d '{
"use_case_name": "Legal contract analysis", "use_case_name": "Analyse de contrats fournisseurs",
"purpose": "Automated review of supplier contracts for risk identification", "purpose": "Identification automatique des risques dans les contrats fournisseurs",
"legal_basis": "legitimate_interest", "legal_basis": "legitimate_interest",
"data_categories": ["name", "financial", "company_data"], "data_categories": ["name", "financial", "company_data"],
"retention_period": "3 years", "retention_period": "3 ans",
"security_measures": "AES-256-GCM encryption, PII anonymization, audit logs", "security_measures": "Chiffrement AES-256-GCM, anonymisation PII, logs d'\''audit immuables",
"controller_name": "Acme Corp — dpo@acme.com", "controller_name": "Acme Corp — dpo@acme.com",
"processors": ["Anthropic (Claude via Veylant IA proxy)"] "processors": ["Anthropic (Claude via Veylant IA proxy)"],
"recipients": []
}'`} }'`}
/> />
<h2 id="ai-act">EU AI Act Classification</h2>
<ApiEndpoint method="POST" path="/v1/admin/compliance/entries/{id}/classify" description="Run the AI Act risk questionnaire for a processing entry and classify the risk level." />
<CodeBlock <CodeBlock
language="bash" language="json"
code={`curl -X POST http://localhost:8090/v1/admin/compliance/entries/entry-uuid/classify \\ code={`// 201 — Entry created
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"autonomous_decisions": false,
"biometric_data": false,
"critical_infrastructure": false,
"sensitive_data": true,
"transparency_required": true
}'
# Response:
{ {
"risk_level": "limited", "id": "ce4f1234-0000-0000-0000-000000000001",
"score": 2, "tenant_id": "dev-tenant",
"description": "Limited risk — transparency obligations apply. Users must be informed they are interacting with an AI system.", "use_case_name": "Analyse de contrats fournisseurs",
"actions_required": [ "purpose": "Identification automatique des risques dans les contrats fournisseurs",
"Display AI disclosure in the user interface", "legal_basis": "legitimate_interest",
"Document in GDPR Art. 30 registry", "data_categories": ["name", "financial", "company_data"],
"Annual review recommended" "retention_period": "3 ans",
] "security_measures": "Chiffrement AES-256-GCM, anonymisation PII, logs d'audit immuables",
"controller_name": "Acme Corp — dpo@acme.com",
"processors": ["Anthropic (Claude via Veylant IA proxy)"],
"recipients": [],
"risk_level": "",
"ai_act_answers": null,
"is_active": true,
"created_at": "2026-03-12T09:00:00Z",
"updated_at": "2026-03-12T09:00:00Z"
}`} }`}
/> />
<p>Risk level mapping:</p> <Callout type="tip" title="AI Act classification is preserved on update">
When you <code>PUT</code> an entry, the <code>risk_level</code> and{" "}
<code>ai_act_answers</code> fields are automatically carried over from the existing record.
Editing the registry never resets a previously computed classification.
</Callout>
<h2 id="ai-act">EU AI Act Classification</h2>
<ApiEndpoint
method="POST"
path="/v1/admin/compliance/entries/{id}/classify"
description="Run the 5-question AI Act risk questionnaire. Updates risk_level and ai_act_answers on the entry."
/>
<p>The questionnaire uses 5 boolean keys (<code>q1</code><code>q5</code>):</p>
<div className="overflow-x-auto my-4"> <div className="overflow-x-auto my-4">
<table className="w-full text-sm border rounded-lg overflow-hidden"> <table className="w-full text-sm border rounded-lg overflow-hidden">
<thead> <thead>
<tr className="bg-muted/50 border-b"> <tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-2.5 font-semibold">Score</th> <th className="text-left px-4 py-2.5 font-semibold">Key</th>
<th className="text-left px-4 py-2.5 font-semibold">Level</th> <th className="text-left px-4 py-2.5 font-semibold">Question</th>
<th className="text-left px-4 py-2.5 font-semibold">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[ {[
{ score: "5", level: "Forbidden", desc: "System must not be deployed. Example: social scoring, real-time biometric surveillance in public spaces.", color: "text-red-600" }, { key: "q1", q: "Le système prend-il des décisions autonomes affectant des droits légaux ou des situations similaires ?" },
{ score: "34", level: "High", desc: "Strict conformity assessment required before deployment. DPIA mandatory.", color: "text-orange-600" }, { key: "q2", q: "Implique-t-il une identification biométrique ou une reconnaissance des émotions ?" },
{ score: "12", level: "Limited", desc: "Transparency obligations: users must be informed they interact with AI.", color: "text-amber-600" }, { key: "q3", q: "Est-il utilisé dans des décisions critiques (médical, justice, emploi, crédit) ?" },
{ score: "0", level: "Minimal", desc: "Minimal risk. Voluntary code of conduct recommended.", color: "text-green-600" }, { key: "q4", q: "Traite-t-il des catégories spéciales de données (santé, biométrie, origine raciale) ?" },
{ key: "q5", q: "La transparence sur l'utilisation de l'IA est-elle indispensable au consentement éclairé ?" },
].map((row) => (
<tr key={row.key} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs font-bold">{row.key}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.q}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>Scoring: count the number of <code>true</code> answers.</p>
<div className="overflow-x-auto my-4">
<table className="w-full text-sm border rounded-lg overflow-hidden">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-2.5 font-semibold">Yes answers</th>
<th className="text-left px-4 py-2.5 font-semibold">risk_level</th>
<th className="text-left px-4 py-2.5 font-semibold">Implication</th>
</tr>
</thead>
<tbody>
{[
{ score: "5", level: "forbidden", color: "text-red-600", desc: "Déploiement interdit (ex: notation sociale, surveillance biométrique en temps réel dans l'espace public)." },
{ score: "34", level: "high", color: "text-orange-600", desc: "Évaluation de conformité obligatoire. AIPD/DPIA requise avant déploiement." },
{ score: "12", level: "limited", color: "text-amber-600", desc: "Obligations de transparence : les utilisateurs doivent être informés qu'ils interagissent avec une IA." },
{ score: "0", level: "minimal", color: "text-green-600", desc: "Risque minimal. Code de conduite volontaire recommandé." },
].map((row) => ( ].map((row) => (
<tr key={row.level} className="border-b last:border-0"> <tr key={row.level} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.score}</td> <td className="px-4 py-2.5 font-mono text-xs">{row.score}</td>
<td className={`px-4 py-2.5 font-semibold text-xs ${row.color}`}>{row.level}</td> <td className={`px-4 py-2.5 font-semibold text-xs font-mono ${row.color}`}>{row.level}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td> <td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
</tr> </tr>
))} ))}
@ -112,31 +155,168 @@ export function AdminCompliancePage() {
</table> </table>
</div> </div>
<CodeBlock
language="bash"
code={`# Classify an entry — chatbot answering FAQ (limited risk)
curl -X POST http://localhost:8090/v1/admin/compliance/entries/ce4f1234-0000-0000-0000-000000000001/classify \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"answers": {
"q1": false,
"q2": false,
"q3": false,
"q4": false,
"q5": true
}
}'`}
/>
<CodeBlock
language="json"
code={`// 200 — Returns the updated ProcessingEntry
{
"id": "ce4f1234-0000-0000-0000-000000000001",
"use_case_name": "Analyse de contrats fournisseurs",
"risk_level": "limited",
"ai_act_answers": {
"q1": false,
"q2": false,
"q3": false,
"q4": false,
"q5": true
},
"is_active": true,
...
}`}
/>
<h2 id="reports">PDF & JSON Reports</h2>
<p>
Reports stream directly as PDF (default) or JSON (<code>?format=json</code>). No polling
required the response is the file.
</p>
<ApiEndpoint method="GET" path="/v1/admin/compliance/report/article30" description="GDPR Art. 30 register for the tenant. PDF by default, JSON with ?format=json." />
<ApiEndpoint method="GET" path="/v1/admin/compliance/report/aiact" description="EU AI Act risk classification report for all entries. PDF by default, JSON with ?format=json." />
<ApiEndpoint method="GET" path="/v1/admin/compliance/dpia/{id}" description="DPIA template PDF for a specific processing entry." />
<CodeBlock
language="bash"
code={`# Download GDPR Art. 30 register as PDF
curl "http://localhost:8090/v1/admin/compliance/report/article30" \\
-H "Authorization: Bearer $TOKEN" \\
--output article30_register.pdf
# Download as JSON (for custom reporting tools)
curl "http://localhost:8090/v1/admin/compliance/report/article30?format=json" \\
-H "Authorization: Bearer $TOKEN"
# Download AI Act risk classification report as PDF
curl "http://localhost:8090/v1/admin/compliance/report/aiact" \\
-H "Authorization: Bearer $TOKEN" \\
--output aiact_report.pdf
# Generate DPIA for a specific high-risk entry
curl "http://localhost:8090/v1/admin/compliance/dpia/ce4f1234-0000-0000-0000-000000000001" \\
-H "Authorization: Bearer $TOKEN" \\
--output dpia_contract_analysis.pdf`}
/>
<Callout type="tip" title="PDF headers use server.tenant_name">
PDF reports display the organisation name configured in <code>server.tenant_name</code>{" "}
(config.yaml). Set this to your legal entity name for compliance documentation.
</Callout>
<h2 id="csv-export">Audit Log CSV Export</h2>
<ApiEndpoint
method="GET"
path="/v1/admin/compliance/export/logs"
description="Export audit logs as CSV (max 10 000 rows). Accepts RFC3339 or YYYY-MM-DD date format for start/end."
/>
<CodeBlock
language="bash"
code={`# Export all audit logs
curl "http://localhost:8090/v1/admin/compliance/export/logs" \\
-H "Authorization: Bearer $TOKEN" \\
--output audit_export.csv
# Export for Q1 2026 using simple date format
curl "http://localhost:8090/v1/admin/compliance/export/logs?start=2026-01-01&end=2026-03-31" \\
-H "Authorization: Bearer $TOKEN" \\
--output q1_2026_audit.csv`}
/>
<h2 id="gdpr-rights">GDPR Subject Rights</h2> <h2 id="gdpr-rights">GDPR Subject Rights</h2>
<h3 id="access">Article 15 Right of Access</h3> <h3 id="access">Article 15 Right of Access</h3>
<ApiEndpoint method="GET" path="/v1/admin/compliance/gdpr/access/{user_id}" description="Return all personal data stored for a specific user (prompt history, audit entries, pseudonymization mappings)." /> <ApiEndpoint
method="GET"
path="/v1/admin/compliance/gdpr/access/{user_id}"
description="Return all audit log entries for a specific user (up to 1 000 records). Used to respond to GDPR Art. 15 subject access requests."
/>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl http://localhost:8090/v1/admin/compliance/gdpr/access/user-uuid \\ code={`curl "http://localhost:8090/v1/admin/compliance/gdpr/access/a1b2c3d4-0000-0000-0000-000000000001" \\
-H "Authorization: Bearer $TOKEN" -H "Authorization: Bearer $TOKEN"`}
/>
# Returns: audit entries, PII mapping references, user profile data`} <CodeBlock
language="json"
code={`// 200 — All audit entries for the user
{
"user_id": "a1b2c3d4-0000-0000-0000-000000000001",
"generated_at": "2026-03-12T10:00:00Z",
"total": 47,
"records": [
{
"request_id": "req_01HV...",
"timestamp": "2026-03-10T14:32:11Z",
"provider": "anthropic",
"model_used": "claude-3-5-sonnet-20241022",
"sensitivity_level": "high",
"pii_entity_count": 3,
"status": "ok"
}
]
}`}
/> />
<h3 id="erasure">Article 17 Right to Erasure</h3> <h3 id="erasure">Article 17 Right to Erasure</h3>
<ApiEndpoint method="POST" path="/v1/admin/compliance/gdpr/erase/{user_id}" description="Pseudonymize or delete all personal data for a user. Audit log metadata is retained but PII is scrubbed." /> <ApiEndpoint
method="DELETE"
path="/v1/admin/compliance/gdpr/erase/{user_id}"
description="Soft-delete the user account (is_active = false) and log the erasure request to the gdpr_erasure_log table."
/>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl -X POST http://localhost:8090/v1/admin/compliance/gdpr/erase/user-uuid \\ code={`curl -X DELETE "http://localhost:8090/v1/admin/compliance/gdpr/erase/a1b2c3d4-0000-0000-0000-000000000001" \\
-H "Authorization: Bearer $TOKEN" \\ -H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{"reason": "User request under GDPR Art. 17"}'`} -d '{"reason": "Demande utilisateur conformément au RGPD Art. 17"}'`}
/> />
<Callout type="warning" title="ClickHouse is append-only">
ClickHouse audit logs cannot be deleted. The erasure endpoint scrubs PII from prompt <CodeBlock
content and pseudonymizes user identifiers, but the request metadata (token counts, cost, language="json"
timestamps) is retained for compliance reporting. code={`// 200 — Erasure confirmed
{
"erasure_id": "er9a0000-0000-0000-0000-000000000042",
"tenant_id": "dev-tenant",
"user_id": "a1b2c3d4-0000-0000-0000-000000000001",
"requested_by": "admin@veylant.dev",
"reason": "Demande utilisateur conformément au RGPD Art. 17",
"records_deleted": 1,
"status": "completed",
"timestamp": "2026-03-12T10:05:00Z"
}`}
/>
<Callout type="warning" title="Soft-delete — ClickHouse logs are append-only">
The erasure endpoint sets <code>users.is_active = false</code> in PostgreSQL. ClickHouse
audit logs are append-only and cannot be deleted; request metadata (token counts, cost,
timestamps) is retained for compliance reporting. The <code>gdpr_erasure_log</code> table
provides an immutable audit trail of all erasure actions.
</Callout> </Callout>
</div> </div>
); );

View File

@ -8,125 +8,302 @@ export function AdminLogsPage() {
<div> <div>
<h1 id="admin-logs">Admin Audit Logs & Costs</h1> <h1 id="admin-logs">Admin Audit Logs & Costs</h1>
<p> <p>
Query the immutable audit trail and cost breakdown for AI requests. All data is stored in Query the immutable audit trail and cost breakdown for all AI requests. Data is stored in
ClickHouse (append-only no DELETE operations). ClickHouse (append-only no DELETE operations). In development without ClickHouse, an
in-memory fallback is used (data is lost on restart).
</p> </p>
<h2 id="audit-logs">Audit Logs</h2> <h2 id="audit-logs">Audit Logs</h2>
<ApiEndpoint method="GET" path="/v1/admin/logs" description="Query audit log entries with optional filters." /> <ApiEndpoint
method="GET"
path="/v1/admin/logs"
description="Query audit log entries with optional filters. Returns paginated results."
/>
<ParamTable <ParamTable
title="Query Parameters" title="Query Parameters"
params={[ params={[
{ name: "user_id", type: "string", required: false, description: "Filter by user UUID." },
{ name: "provider", type: "string", required: false, description: "Filter by provider: openai | anthropic | azure | mistral | ollama" }, { name: "provider", type: "string", required: false, description: "Filter by provider: openai | anthropic | azure | mistral | ollama" },
{ name: "model", type: "string", required: false, description: "Filter by model used." }, { name: "min_sensitivity", type: "string", required: false, description: "Minimum PII sensitivity level: none | low | medium | high | critical" },
{ name: "from", type: "string (ISO 8601)", required: false, description: "Start of time range, e.g. 2026-01-01T00:00:00Z" }, { name: "start", type: "string (RFC3339 or RFC3339Nano)", required: false, description: "Start of time range. Accepts JavaScript toISOString() format (e.g. 2026-03-01T00:00:00.000Z) or standard RFC3339." },
{ name: "to", type: "string (ISO 8601)", required: false, description: "End of time range." }, { name: "end", type: "string (RFC3339 or RFC3339Nano)", required: false, description: "End of time range. Same format as start." },
{ name: "has_pii", type: "boolean", required: false, description: "Filter entries where PII was detected." }, { name: "limit", type: "integer", required: false, default: "50", description: "Max entries to return (max 10 000)." },
{ name: "limit", type: "integer", required: false, default: "50", description: "Max entries to return (max 1000)." },
{ name: "offset", type: "integer", required: false, default: "0", description: "Pagination offset." }, { name: "offset", type: "integer", required: false, default: "0", description: "Pagination offset." },
]} ]}
/> />
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl "http://localhost:8090/v1/admin/logs?from=2026-01-01T00:00:00Z&has_pii=true&limit=10" \\ code={`# All logs for the authenticated tenant (default: last 50)
curl "http://localhost:8090/v1/admin/logs" \\
-H "Authorization: Bearer $TOKEN" -H "Authorization: Bearer $TOKEN"
# Response: # Filter by provider and sensitivity
curl "http://localhost:8090/v1/admin/logs?provider=anthropic&min_sensitivity=medium" \\
-H "Authorization: Bearer $TOKEN"
# Date range RFC3339 (API clients)
curl "http://localhost:8090/v1/admin/logs?start=2026-03-01T00:00:00Z&end=2026-03-31T23:59:59Z&limit=100" \\
-H "Authorization: Bearer $TOKEN"
# Date range RFC3339Nano (JavaScript frontend)
curl "http://localhost:8090/v1/admin/logs?start=2026-03-01T00:00:00.000Z&end=2026-03-31T23:59:59.999Z" \\
-H "Authorization: Bearer $TOKEN"`}
/>
<CodeBlock
language="json"
code={`// 200 — Success
{ {
"data": [ "data": [
{ {
"id": "log-uuid", "request_id": "req_01HV...",
"tenant_id": "tenant-uuid", "tenant_id": "dev-tenant",
"user_id": "user-uuid", "user_id": "a1b2c3d4-0000-0000-0000-000000000001",
"user_email": "alice@acme.com", "timestamp": "2026-03-10T14:32:11Z",
"provider": "anthropic", "provider": "anthropic",
"model_requested": "gpt-4o", "model_requested": "gpt-4o",
"model_used": "claude-3-5-sonnet-20241022", "model_used": "claude-3-5-sonnet-20241022",
"prompt_tokens": 128, "department": "Legal",
"completion_tokens": 345, "user_role": "user",
"total_tokens": 473, "sensitivity_level": "high",
"token_input": 128,
"token_output": 345,
"token_total": 473,
"cost_usd": 0.003412, "cost_usd": 0.003412,
"latency_ms": 1423, "latency_ms": 1423,
"pii_detected": true, "status": "ok",
"pii_entities": ["PERSON", "EMAIL_ADDRESS"], "error_type": "",
"policy_matched": "Legal → Anthropic", "pii_entity_count": 3,
"status_code": 200, "stream": false,
"timestamp": "2026-01-15T14:32:11Z" "prompt_hash": "sha256:a3f...",
"response_hash": "sha256:b7c..."
} }
], ],
"total": 142, "total": 142
"limit": 10,
"offset": 0
}`} }`}
/> />
<Callout type="info" title="Audit-of-the-audit"> <Callout type="info" title="Audit-of-the-audit">
All accesses to audit logs are themselves logged. This satisfies the "audit-of-the-audit" All accesses to audit logs are themselves logged. This satisfies the meta-logging
requirement for sensitive compliance use cases. requirement for data protection authorities.
</Callout> </Callout>
<Callout type="warning" title="prompt_anonymized is never returned">
The <code>prompt_anonymized</code> field (AES-256-GCM encrypted) is stored in ClickHouse
but is excluded from all API responses. Use the CSV export or GDPR access endpoint for
compliance data requests.
</Callout>
<h3 id="audit-entry-fields">Audit Entry Fields</h3>
<div className="overflow-x-auto my-4">
<table className="w-full text-sm border rounded-lg overflow-hidden">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-2.5 font-semibold">Field</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
<th className="text-left px-4 py-2.5 font-semibold">Description</th>
</tr>
</thead>
<tbody>
{[
{ field: "request_id", type: "string", desc: "Unique request identifier (X-Request-ID header)." },
{ field: "tenant_id", type: "string", desc: "Tenant UUID — always scoped to the authenticated tenant." },
{ field: "user_id", type: "string", desc: "User UUID from the JWT sub claim." },
{ field: "timestamp", type: "RFC3339", desc: "UTC timestamp when the request was processed." },
{ field: "provider", type: "string", desc: "LLM provider that served the request: openai | anthropic | azure | mistral | ollama" },
{ field: "model_requested", type: "string", desc: "Model requested by the client (may differ from model_used after routing)." },
{ field: "model_used", type: "string", desc: "Actual model used after routing rule evaluation." },
{ field: "department", type: "string", desc: "User department from JWT claims (used in routing conditions)." },
{ field: "user_role", type: "string", desc: "RBAC role: admin | manager | user | auditor" },
{ field: "sensitivity_level", type: "string", desc: "Highest PII sensitivity detected: none | low | medium | high | critical" },
{ field: "token_input", type: "integer", desc: "Prompt tokens consumed." },
{ field: "token_output", type: "integer", desc: "Completion tokens generated." },
{ field: "token_total", type: "integer", desc: "token_input + token_output." },
{ field: "cost_usd", type: "float", desc: "Estimated cost in USD (per-provider pricing table)." },
{ field: "latency_ms", type: "integer", desc: "End-to-end latency in milliseconds (proxy entry → last byte)." },
{ field: "status", type: "string", desc: "\"ok\" or \"error\"." },
{ field: "error_type", type: "string", desc: "Error category if status=error (empty otherwise)." },
{ field: "pii_entity_count", type: "integer", desc: "Number of PII entities detected in the prompt." },
{ field: "stream", type: "boolean", desc: "Whether the request used Server-Sent Events streaming." },
{ field: "prompt_hash", type: "string", desc: "SHA-256 of the original prompt (for deduplication/integrity)." },
{ field: "response_hash", type: "string", desc: "SHA-256 of the response content." },
].map((row) => (
<tr key={row.field} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.field}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.type}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 id="costs">Cost Breakdown</h2> <h2 id="costs">Cost Breakdown</h2>
<ApiEndpoint method="GET" path="/v1/admin/costs" description="Aggregate cost breakdown by provider, model, or department." /> <ApiEndpoint
method="GET"
path="/v1/admin/costs"
description="Aggregate token consumption and cost, grouped by provider, model, or department."
/>
<ParamTable <ParamTable
title="Query Parameters" title="Query Parameters"
params={[ params={[
{ name: "group_by", type: "string", required: false, default: "provider", description: "Grouping dimension: provider | model | department | user" }, { name: "group_by", type: "string", required: false, default: "provider", description: "Grouping dimension: provider | model | department" },
{ name: "from", type: "string (ISO 8601)", required: false, description: "Start of billing period." }, { name: "start", type: "string (RFC3339)", required: false, description: "Start of billing period." },
{ name: "to", type: "string (ISO 8601)", required: false, description: "End of billing period." }, { name: "end", type: "string (RFC3339)", required: false, description: "End of billing period." },
]} ]}
/> />
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl "http://localhost:8090/v1/admin/costs?group_by=provider&from=2026-01-01T00:00:00Z" \\ code={`# Costs by provider for March 2026
curl "http://localhost:8090/v1/admin/costs?group_by=provider&start=2026-03-01T00:00:00Z&end=2026-03-31T23:59:59Z" \\
-H "Authorization: Bearer $TOKEN" -H "Authorization: Bearer $TOKEN"
# Response: # Costs by department (for chargeback)
curl "http://localhost:8090/v1/admin/costs?group_by=department" \\
-H "Authorization: Bearer $TOKEN"
# Costs by model
curl "http://localhost:8090/v1/admin/costs?group_by=model&start=2026-03-01T00:00:00Z" \\
-H "Authorization: Bearer $TOKEN"`}
/>
<CodeBlock
language="json"
code={`// 200 — Costs grouped by provider
{ {
"data": [ "data": [
{ {
"key": "openai", "key": "openai",
"request_count": 1423,
"total_tokens": 2840000, "total_tokens": 2840000,
"prompt_tokens": 1200000, "total_cost_usd": 28.40,
"completion_tokens": 1640000, "request_count": 1423
"total_cost_usd": 28.40
}, },
{ {
"key": "anthropic", "key": "anthropic",
"request_count": 231,
"total_tokens": 462000, "total_tokens": 462000,
"prompt_tokens": 230000, "total_cost_usd": 6.93,
"completion_tokens": 232000, "request_count": 231
"total_cost_usd": 6.93 },
{
"key": "mistral",
"total_tokens": 180000,
"total_cost_usd": 0.54,
"request_count": 89
} }
], ]
"period_from": "2026-01-01T00:00:00Z", }
"period_to": "2026-01-31T23:59:59Z"
// 200 — Costs grouped by department
{
"data": [
{ "key": "Legal", "total_tokens": 1200000, "total_cost_usd": 18.00, "request_count": 650 },
{ "key": "Finance", "total_tokens": 900000, "total_cost_usd": 9.00, "request_count": 410 },
{ "key": "IT", "total_tokens": 540000, "total_cost_usd": 8.10, "request_count": 280 }
]
}`} }`}
/> />
<h2 id="rate-limits">Rate Limit Overrides</h2> <h2 id="csv-export">CSV Export</h2>
<ApiEndpoint method="GET" path="/v1/admin/rate-limits" description="List per-tenant rate limit overrides." /> <ApiEndpoint
<ApiEndpoint method="GET" path="/v1/admin/rate-limits/{tenant_id}" description="Get rate limit configuration for a specific tenant." /> method="GET"
path="/v1/admin/compliance/export/logs"
description="Export audit logs as a CSV file (up to 10 000 rows). For bulk compliance downloads or GDPR documentation."
/>
<ParamTable
title="Query Parameters"
params={[
{ name: "provider", type: "string", required: false, description: "Filter by provider." },
{ name: "start", type: "string (RFC3339 or YYYY-MM-DD)", required: false, description: "Start date. Accepts RFC3339 or date-only format (e.g. 2026-03-01)." },
{ name: "end", type: "string (RFC3339 or YYYY-MM-DD)", required: false, description: "End date. When date-only, the full day (23:59:59) is included automatically." },
]}
/>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`# Get rate limit for a tenant code={`# Export all logs as CSV
curl http://localhost:8090/v1/admin/rate-limits/tenant-uuid \\ curl "http://localhost:8090/v1/admin/compliance/export/logs" \\
-H "Authorization: Bearer $TOKEN" \\
--output audit_logs.csv
# Export for a specific date range using simple date format
curl "http://localhost:8090/v1/admin/compliance/export/logs?start=2026-03-01&end=2026-03-31" \\
-H "Authorization: Bearer $TOKEN" \\
--output march_2026_logs.csv
# Export only high-sensitivity entries from OpenAI
curl "http://localhost:8090/v1/admin/compliance/export/logs?provider=openai" \\
-H "Authorization: Bearer $TOKEN" \\
--output openai_logs.csv`}
/>
<CodeBlock
language="text"
code={`# CSV columns (header row):
request_id,timestamp,user_id,tenant_id,provider,model_requested,model_used,department,user_role,sensitivity_level,token_input,token_output,token_total,cost_usd,latency_ms,status,error_type,pii_entity_count,stream
# Example rows:
req_01HV...,2026-03-10T14:32:11Z,a1b2c3d4-...,dev-tenant,anthropic,gpt-4o,claude-3-5-sonnet-20241022,Legal,user,high,128,345,473,0.003412,1423,ok,,3,false
req_01HW...,2026-03-10T15:01:44Z,b2c3d4e5-...,dev-tenant,openai,gpt-4o-mini,gpt-4o-mini,Finance,user,none,64,120,184,0.000074,342,ok,,0,false`}
/>
<h2 id="rate-limits">Rate Limit Configuration</h2>
<p>
Per-tenant rate limits override the global defaults configured in{" "}
<code>rate_limit.default_tenant_rpm</code>.
</p>
<ApiEndpoint method="GET" path="/v1/admin/rate-limits" description="List all per-tenant rate limit overrides." />
<ApiEndpoint method="GET" path="/v1/admin/rate-limits/{tenant_id}" description="Get rate limit configuration for a specific tenant (returns default if no override exists)." />
<ApiEndpoint method="PUT" path="/v1/admin/rate-limits/{tenant_id}" description="Create or update a per-tenant rate limit. Applied immediately — no restart required." />
<ApiEndpoint method="DELETE" path="/v1/admin/rate-limits/{tenant_id}" description="Remove the per-tenant override. The tenant reverts to global defaults." />
<CodeBlock
language="bash"
code={`# Set a rate limit override for a tenant
curl -X PUT http://localhost:8090/v1/admin/rate-limits/acme-corp-tenant-id \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"requests_per_min": 500,
"burst_size": 50,
"user_rpm": 60,
"user_burst": 10,
"is_enabled": true
}'
# Get current config (returns default if no override)
curl http://localhost:8090/v1/admin/rate-limits/acme-corp-tenant-id \\
-H "Authorization: Bearer $TOKEN" -H "Authorization: Bearer $TOKEN"
# Response: # Remove override (reverts to global defaults)
curl -X DELETE http://localhost:8090/v1/admin/rate-limits/acme-corp-tenant-id \\
-H "Authorization: Bearer $TOKEN"`}
/>
<CodeBlock
language="json"
code={`// PUT/GET response:
{ {
"tenant_id": "tenant-uuid", "tenant_id": "acme-corp-tenant-id",
"rpm": 500, # requests per minute "requests_per_min": 500,
"tpm": 50000, # tokens per minute "burst_size": 50,
"daily_token_limit": 1000000 "user_rpm": 60,
}`} "user_burst": 10,
"is_enabled": true
}
// 429 response when rate limit is exceeded:
{
"error": {
"type": "rate_limit_error",
"message": "rate limit exceeded",
"code": "rate_limit_exceeded"
}
}
// The response also includes: Retry-After: 1`}
/> />
</div> </div>
); );

View File

@ -1,89 +1,140 @@
import { CodeBlock } from "../components/CodeBlock"; import { CodeBlock } from "../components/CodeBlock";
import { Callout } from "../components/Callout"; import { Callout } from "../components/Callout";
import { ApiEndpoint } from "../components/ApiEndpoint";
import { ParamTable } from "../components/ParamTable";
export function AuthenticationPage() { export function AuthenticationPage() {
return ( return (
<div> <div>
<h1 id="authentication">Authentication</h1> <h1 id="authentication">Authentication</h1>
<p> <p>
All <code>/v1/*</code> endpoints require a Bearer JWT in the{" "} Veylant IA uses a local email/password authentication system. Users log in with their
<code>Authorization</code> header. Veylant IA validates the token against Keycloak (OIDC) credentials to receive a signed JWT (HS256). This token must be sent as a Bearer token on
or uses a mock verifier in development mode. all protected <code>/v1/*</code> requests.
</p> </p>
<h2 id="bearer-token">Bearer Token</h2> <h2 id="login">Login Obtain a Token</h2>
<ApiEndpoint
method="POST"
path="/v1/auth/login"
description="Authenticate with email and password. Returns a signed JWT and the user profile."
/>
<ParamTable
title="Request Body"
params={[
{ name: "email", type: "string", required: true, description: "User email address." },
{ name: "password", type: "string", required: true, description: "User password (bcrypt-hashed at rest)." },
]}
/>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl http://localhost:8090/v1/chat/completions \\ code={`curl -X POST http://localhost:8090/v1/auth/login \\
-H "Authorization: Bearer <your-access-token>" \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hi"}]}'`} -d '{
"email": "admin@veylant.dev",
"password": "admin123"
}'`}
/> />
<h2 id="dev-mode">Development Mode</h2>
<Callout type="info" title="Development mode auth bypass">
When <code>server.env=development</code> and Keycloak is unreachable, the proxy uses a{" "}
<code>MockVerifier</code>. Any non-empty Bearer token is accepted. The authenticated user
is injected as <code>admin@veylant.dev</code> with <code>admin</code> role and tenant ID{" "}
<code>dev-tenant</code>.
</Callout>
<CodeBlock <CodeBlock
language="bash" language="json"
code={`# Any string works as the token in dev mode code={`// 200 — Login successful
curl http://localhost:8090/v1/chat/completions \\
-H "Authorization: Bearer dev-token" \\
...`}
/>
<h2 id="keycloak-flow">Production: Keycloak OIDC Flow</h2>
<p>In production, clients obtain a token via the standard OIDC Authorization Code flow:</p>
<ol>
<li>Redirect user to Keycloak login page</li>
<li>User authenticates; Keycloak redirects back with an authorization code</li>
<li>Exchange code for tokens at the token endpoint</li>
<li>Use the <code>access_token</code> as the Bearer token</li>
</ol>
<CodeBlock
language="bash"
code={`# Token endpoint (replace values)
curl -X POST \\
http://localhost:8080/realms/veylant/protocol/openid-connect/token \\
-d "grant_type=password" \\
-d "client_id=veylant-proxy" \\
-d "username=admin@veylant.dev" \\
-d "password=admin123"
# Response includes:
{ {
"access_token": "eyJhbGci...", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 300, "user": {
"refresh_token": "eyJhbGci...", "id": "a1b2c3d4-0000-0000-0000-000000000001",
"token_type": "Bearer" "email": "admin@veylant.dev",
"name": "Admin",
"role": "admin",
"tenant_id": "dev-tenant",
"department": "IT"
}
}
// 401 — Invalid credentials
{
"error": {
"type": "authentication_error",
"message": "invalid email or password",
"code": "invalid_api_key"
}
}`} }`}
/> />
<Callout type="info" title="Token lifetime">
Tokens are valid for the duration configured in <code>auth.jwt_ttl_hours</code> (default:{" "}
<code>24</code> hours). The frontend automatically logs the user out when the token expires.
</Callout>
<h2 id="bearer-token">Using the Token</h2>
<p>
Pass the token in the <code>Authorization</code> header on every protected request:
</p>
<CodeBlock
language="bash"
code={`# Store the token after login
TOKEN=$(curl -s -X POST http://localhost:8090/v1/auth/login \\
-H "Content-Type: application/json" \\
-d '{"email":"admin@veylant.dev","password":"admin123"}' | jq -r '.token')
# Use it on subsequent requests
curl http://localhost:8090/v1/chat/completions \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Bonjour"}]}'`}
/>
<CodeBlock
language="python"
code={`import httpx
# Step 1 login
resp = httpx.post("http://localhost:8090/v1/auth/login", json={
"email": "admin@veylant.dev",
"password": "admin123",
})
token = resp.json()["token"]
# Step 2 use the OpenAI-compatible endpoint
client = httpx.Client(
base_url="http://localhost:8090/v1",
headers={"Authorization": f"Bearer {token}"},
)
chat = client.post("/chat/completions", json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Résume ce contrat"}],
})
print(chat.json()["choices"][0]["message"]["content"])`}
/>
<h2 id="jwt-claims">JWT Claims</h2> <h2 id="jwt-claims">JWT Claims</h2>
<p>The proxy extracts the following claims from the JWT:</p> <p>
Tokens are HS256-signed. The proxy extracts the following claims on each request:
</p>
<div className="overflow-x-auto my-4"> <div className="overflow-x-auto my-4">
<table className="w-full text-sm border rounded-lg overflow-hidden"> <table className="w-full text-sm border rounded-lg overflow-hidden">
<thead> <thead>
<tr className="bg-muted/50 border-b"> <tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-2.5 font-semibold">Claim</th> <th className="text-left px-4 py-2.5 font-semibold">Claim</th>
<th className="text-left px-4 py-2.5 font-semibold">Source</th> <th className="text-left px-4 py-2.5 font-semibold">Type</th>
<th className="text-left px-4 py-2.5 font-semibold">Description</th> <th className="text-left px-4 py-2.5 font-semibold">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[ {[
{ claim: "sub", source: "Standard JWT", desc: "User ID (UUID)" }, { claim: "sub", type: "string (UUID)", desc: "User ID — used in audit logs and GDPR access requests." },
{ claim: "email", source: "Standard JWT", desc: "User email" }, { claim: "email", type: "string", desc: "User email address." },
{ claim: "realm_access.roles", source: "Keycloak extension", desc: "RBAC roles: admin, manager, user, auditor" }, { claim: "name", type: "string", desc: "Display name shown in the dashboard." },
{ claim: "veylant_tenant_id", source: "Keycloak mapper", desc: "Tenant UUID" }, { claim: "role", type: "string", desc: "RBAC role: admin | manager | user | auditor." },
{ claim: "department", source: "Keycloak user attribute", desc: "Department name for routing rules" }, { claim: "tenant_id", type: "string", desc: "Tenant UUID — enforces data isolation via PostgreSQL RLS." },
{ claim: "department", type: "string", desc: "Department name — used in routing rule conditions." },
{ claim: "exp", type: "unix timestamp", desc: "Token expiry (derived from auth.jwt_ttl_hours)." },
].map((row) => ( ].map((row) => (
<tr key={row.claim} className="border-b last:border-0"> <tr key={row.claim} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.claim}</td> <td className="px-4 py-2.5 font-mono text-xs">{row.claim}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.source}</td> <td className="px-4 py-2.5 text-muted-foreground text-xs">{row.type}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td> <td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
</tr> </tr>
))} ))}
@ -91,21 +142,37 @@ curl -X POST \\
</table> </table>
</div> </div>
<h2 id="test-users">Pre-configured Test Users</h2> <h2 id="test-users">Pre-configured Dev Users</h2>
<p>The Keycloak realm export includes these users for testing:</p> <p>
The dev stack seeds two users in the <code>users</code> table (migration 000010):
</p>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`# Admin user (full access) code={`# Admin — full access, unrestricted model access
username: admin@veylant.dev email: admin@veylant.dev
password: admin123 password: admin123
roles: admin role: admin
# Regular user (restricted to allowed models) # Regular user inference only, restricted to allowed models
username: user@veylant.dev # (create this user via POST /v1/admin/users if needed)
email: user@veylant.dev
password: user123 password: user123
roles: user`} role: user`}
/> />
<h2 id="config">Auth Configuration</h2>
<CodeBlock
language="yaml"
code={`# config.yaml
auth:
jwt_secret: "change-me-in-production-min-32-chars"
jwt_ttl_hours: 24`}
/>
<Callout type="warning" title="Secret rotation">
Set <code>VEYLANT_AUTH_JWT_SECRET</code> in production to a random 32-byte value. Rotating
the secret immediately invalidates all active sessions.
</Callout>
<h2 id="error-responses">Auth Error Responses</h2> <h2 id="error-responses">Auth Error Responses</h2>
<p>Authentication errors always return OpenAI-format JSON:</p> <p>Authentication errors always return OpenAI-format JSON:</p>
<CodeBlock <CodeBlock
@ -119,13 +186,22 @@ roles: user`}
} }
} }
// 403 — Valid token, insufficient role // 403 — Valid token, insufficient role for the model
{ {
"error": { "error": {
"type": "permission_error", "type": "permission_error",
"message": "role 'user' does not have access to model 'gpt-4o'. Allowed: gpt-4o-mini", "message": "role 'user' does not have access to model 'gpt-4o'. Allowed: gpt-4o-mini",
"code": "permission_denied" "code": "permission_denied"
} }
}
// 429 — Rate limit exceeded
{
"error": {
"type": "rate_limit_error",
"message": "rate limit exceeded",
"code": "rate_limit_exceeded"
}
}`} }`}
/> />
</div> </div>

View File

@ -12,8 +12,7 @@ export function QuickStartPage() {
<Callout type="info" title="Prerequisites"> <Callout type="info" title="Prerequisites">
You need <strong>Docker 24+</strong> and <strong>Docker Compose v2</strong> installed. You need <strong>Docker 24+</strong> and <strong>Docker Compose v2</strong> installed.
Clone the repository and ensure ports 8090, 8080, 5432, 6379, 8123, 3000, and 3001 are Clone the repository and ensure ports 8090, 5432, 6379, 8123, 3000, and 3001 are free.
free.
</Callout> </Callout>
<h2 id="step-1-clone">Step 1 Clone the repository</h2> <h2 id="step-1-clone">Step 1 Clone the repository</h2>
@ -34,15 +33,18 @@ cd ia-gateway`}
# Edit .env and set: # Edit .env and set:
OPENAI_API_KEY=sk-... OPENAI_API_KEY=sk-...
# Optional: # Optional additional providers:
ANTHROPIC_API_KEY=sk-ant-...`} ANTHROPIC_API_KEY=sk-ant-...
# JWT secret for auth (change in production):
VEYLANT_AUTH_JWT_SECRET=change-me-min-32-chars-dev-only`}
/> />
<Callout type="tip" title="Development mode"> <Callout type="tip" title="Development mode graceful degradation">
In <code>server.env=development</code> (the default), all external services degrade In <code>server.env=development</code> (the default), all external services degrade
gracefully. Keycloak is bypassed (mock JWT), PostgreSQL failures disable routing, ClickHouse gracefully: PostgreSQL failures disable routing rules, ClickHouse failures fall back to an
failures disable audit logs. This means you can start the proxy even if some services in-memory audit log (data not persisted), and PII failures are silently skipped if{" "}
haven't fully initialized yet. <code>pii.fail_open=true</code>. The proxy stays up even if some services haven't
initialized yet.
</Callout> </Callout>
<h2 id="step-3-start">Step 3 Start the stack</h2> <h2 id="step-3-start">Step 3 Start the stack</h2>
@ -54,18 +56,19 @@ ANTHROPIC_API_KEY=sk-ant-...`}
docker compose up --build`} docker compose up --build`}
/> />
<p> <p>
This starts 9 services: PostgreSQL, Redis, ClickHouse, Keycloak, the Go proxy, PII This starts 8 services: PostgreSQL, Redis, ClickHouse, the Go proxy, PII detection
detection service, Prometheus, Grafana, and the React dashboard. service, Prometheus, Grafana, and the React dashboard.
</p> </p>
<p> <p>
Wait for the proxy to print <code>server listening on :8090</code>. First startup takes Wait for the proxy to print{" "}
~2 minutes while Keycloak initializes and database migrations run. <code>Veylant IA proxy started addr=:8090</code>. First startup takes ~60 seconds while
PostgreSQL runs migrations.
</p> </p>
<h2 id="step-4-verify">Step 4 Verify the stack</h2> <h2 id="step-4-verify">Step 4 Verify the stack</h2>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`# Health check code={`# Health check — no auth required
curl http://localhost:8090/healthz curl http://localhost:8090/healthz
# {"status":"ok","version":"1.0.0"}`} # {"status":"ok","version":"1.0.0"}`}
/> />
@ -82,12 +85,12 @@ curl http://localhost:8090/healthz
<tbody> <tbody>
{[ {[
{ service: "AI Proxy", url: "http://localhost:8090", creds: "—" }, { service: "AI Proxy", url: "http://localhost:8090", creds: "—" },
{ service: "React Dashboard", url: "http://localhost:3000", creds: "dev mode (no auth)" }, { service: "React Dashboard", url: "http://localhost:3000", creds: "admin@veylant.dev / admin123" },
{ service: "Keycloak Admin", url: "http://localhost:8080", creds: "admin / admin" }, { service: "Documentation", url: "http://localhost:3000/docs", creds: "— (public)" },
{ service: "API Playground", url: "http://localhost:8090/playground", creds: "— (public)" },
{ service: "OpenAPI Docs", url: "http://localhost:8090/docs", creds: "— (public)" },
{ service: "Grafana", url: "http://localhost:3001", creds: "admin / admin" }, { service: "Grafana", url: "http://localhost:3001", creds: "admin / admin" },
{ service: "Prometheus", url: "http://localhost:9090", creds: "—" }, { service: "Prometheus", url: "http://localhost:9090", creds: "—" },
{ service: "API Docs", url: "http://localhost:8090/docs", creds: "—" },
{ service: "Playground", url: "http://localhost:8090/playground", creds: "—" },
].map((row) => ( ].map((row) => (
<tr key={row.service} className="border-b last:border-0"> <tr key={row.service} className="border-b last:border-0">
<td className="px-4 py-2.5 font-medium">{row.service}</td> <td className="px-4 py-2.5 font-medium">{row.service}</td>
@ -101,62 +104,87 @@ curl http://localhost:8090/healthz
</table> </table>
</div> </div>
<h2 id="step-5-first-call">Step 5 Make your first AI call</h2> <h2 id="step-5-first-call">Step 5 Authenticate and make your first AI call</h2>
<p> <p>
In development mode, the proxy uses a mock JWT verifier. Pass any Bearer token and the Log in to get a JWT token, then use it as a Bearer token on all <code>/v1/*</code>{" "}
request will be authenticated as <code>admin@veylant.dev</code>. requests.
</p> </p>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`curl http://localhost:8090/v1/chat/completions \\ code={`# Step 5a — Login to get a JWT token
-H "Authorization: Bearer dev-token" \\ TOKEN=$(curl -s -X POST http://localhost:8090/v1/auth/login \\
-H "Content-Type: application/json" \\
-d '{"email":"admin@veylant.dev","password":"admin123"}' | jq -r '.token')
echo "Token: $TOKEN"
# Step 5b Make your first AI call
curl http://localhost:8090/v1/chat/completions \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{ -d '{
"model": "gpt-4o", "model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Hello, Veylant!"}] "messages": [{"role": "user", "content": "Bonjour depuis Veylant IA !"}]
}'`} }'`}
/> />
<p>Or use the OpenAI Python SDK with a changed base URL:</p> <p>Or use the OpenAI Python SDK by simply changing the base URL:</p>
<CodeBlock <CodeBlock
language="python" language="python"
code={`from openai import OpenAI code={`import httpx
from openai import OpenAI
# Step 1: obtain a token
token = httpx.post("http://localhost:8090/v1/auth/login", json={
"email": "admin@veylant.dev",
"password": "admin123",
}).json()["token"]
# Step 2: use the OpenAI SDK with Veylant as the base URL
client = OpenAI( client = OpenAI(
base_url="http://localhost:8090/v1", base_url="http://localhost:8090/v1",
api_key="dev-token", # any string in dev mode api_key=token, # Veylant uses JWT, not an API key
) )
response = client.chat.completions.create( response = client.chat.completions.create(
model="gpt-4o", model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello, Veylant!"}], messages=[{"role": "user", "content": "Résume ce contrat en 3 points."}],
) )
print(response.choices[0].message.content)`} print(response.choices[0].message.content)`}
/> />
<Callout type="tip" title="Drop-in replacement for any OpenAI SDK">
Veylant IA is 100% OpenAI-API compatible. Point any existing SDK (Python, Node.js, Go,
Rust) to <code>http://localhost:8090/v1</code> and pass the JWT as the API key.
</Callout>
<h2 id="step-6-dashboard">Step 6 Explore the dashboard</h2> <h2 id="step-6-dashboard">Step 6 Explore the dashboard</h2>
<p> <p>
Open <code>http://localhost:3000</code> to see the React dashboard. In development mode, Open <code>http://localhost:3000</code> and log in with{" "}
you're automatically logged in as <code>Dev Admin</code>. You'll see: <code>admin@veylant.dev / admin123</code>. You'll find:
</p> </p>
<ul> <ul>
<li> <li>
<strong>Overview</strong> request counts, costs, and tokens consumed <strong>Vue d'ensemble</strong> request counts, costs, tokens consumed, volume chart
(7d/30d)
</li> </li>
<li> <li>
<strong>Playground IA</strong> test prompts with live PII detection visualization <strong>Playground IA</strong> test prompts with live PII detection visualization
</li> </li>
<li> <li>
<strong>Policies</strong> create and manage routing rules <strong>Politiques</strong> create and manage routing rules
</li> </li>
<li> <li>
<strong>Compliance</strong> GDPR Article 30 registry and AI Act questionnaire <strong>Conformité</strong> GDPR Article 30 registry and EU AI Act questionnaire
</li>
<li>
<strong>Fournisseurs</strong> configure LLM providers with encrypted API key storage
</li> </li>
</ul> </ul>
<Callout type="tip" title="Next: Configure a routing rule"> <Callout type="tip" title="Next: Configure a routing rule">
Try creating a routing rule in the dashboard that sends all requests from the{" "} Try creating a routing rule that sends all requests from the <code>Legal</code> department
<code>legal</code> department to Anthropic instead of OpenAI. See{" "} to Anthropic instead of OpenAI. See{" "}
<a href="/docs/guides/routing">Routing Rules Engine</a> for the full guide. <a href="/docs/guides/routing">Routing Rules Engine</a> for the full guide.
</Callout> </Callout>

View File

@ -7,16 +7,16 @@ export function ComplianceGuide() {
<div> <div>
<h1 id="compliance">GDPR & EU AI Act Compliance</h1> <h1 id="compliance">GDPR & EU AI Act Compliance</h1>
<p> <p>
Veylant IA includes a built-in compliance module for GDPR Article 30 record-keeping and EU Veylant IA includes a built-in compliance module for GDPR Article 30 record-keeping, EU
AI Act risk classification. It is designed to serve as your primary compliance tool for AI AI Act risk classification, DPIA generation, and GDPR subject rights management. It is
deployments. designed to serve as the primary compliance tool for enterprise AI deployments.
</p> </p>
<h2 id="gdpr-art30">GDPR Article 30 Record of Processing Activities</h2> <h2 id="gdpr-art30">GDPR Article 30 Record of Processing Activities</h2>
<p> <p>
Article 30 requires organizations to maintain a written record of all data processing Article 30 requires organizations to maintain a written record of all data processing
activities. For AI systems, this means documenting each use case where personal data may be activities. For AI systems, this means documenting every use case where personal data may
processed. be processed including through third-party LLM providers.
</p> </p>
<h3 id="ropa-fields">Required ROPA Fields</h3> <h3 id="ropa-fields">Required ROPA Fields</h3>
@ -31,14 +31,14 @@ export function ComplianceGuide() {
</thead> </thead>
<tbody> <tbody>
{[ {[
{ field: "use_case_name", req: "Name of the processing activity", ex: "Legal contract analysis" }, { field: "use_case_name", req: "Name of the processing activity", ex: "Analyse de contrats fournisseurs" },
{ field: "purpose", req: "Art. 5(1)(b) — purpose limitation", ex: "Automated risk identification in supplier contracts" }, { field: "purpose", req: "Art. 5(1)(b) — purpose limitation", ex: "Identification automatique des risques dans les contrats" },
{ field: "legal_basis", req: "Art. 6 — lawfulness of processing", ex: "legitimate_interest" }, { field: "legal_basis", req: "Art. 6 — lawfulness of processing", ex: "legitimate_interest" },
{ field: "data_categories", req: "Art. 30(1)(c) — categories of data subjects and data", ex: "name, email, financial" }, { field: "data_categories", req: "Art. 30(1)(c) — categories of data subjects and data", ex: "[\"name\", \"financial\"]" },
{ field: "retention_period", req: "Art. 5(1)(e) — storage limitation", ex: "3 years" }, { field: "retention_period", req: "Art. 5(1)(e) — storage limitation", ex: "3 ans" },
{ field: "security_measures", req: "Art. 32 — security of processing", ex: "AES-256-GCM, PII anonymization" }, { field: "security_measures", req: "Art. 32 — security of processing", ex: "AES-256-GCM, anonymisation PII, audit logs" },
{ field: "controller_name", req: "Art. 30(1)(a) — controller identity", ex: "Acme Corp — dpo@acme.com" }, { field: "controller_name", req: "Art. 30(1)(a) — controller identity", ex: "Acme Corp — dpo@acme.com" },
{ field: "processors", req: "Art. 30(1)(d) — recipients of data", ex: "Anthropic (via Veylant IA proxy)" }, { field: "processors", req: "Art. 30(1)(d) — recipients of data", ex: "[\"Anthropic via Veylant IA proxy\"]" },
].map((row) => ( ].map((row) => (
<tr key={row.field} className="border-b last:border-0"> <tr key={row.field} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.field}</td> <td className="px-4 py-2.5 font-mono text-xs">{row.field}</td>
@ -50,7 +50,7 @@ export function ComplianceGuide() {
</table> </table>
</div> </div>
<h3 id="legal-bases">Legal Bases</h3> <h3 id="legal-bases">Legal Bases (Art. 6 GDPR)</h3>
<ul> <ul>
<li><code>consent</code> User has given explicit consent (Art. 6(1)(a))</li> <li><code>consent</code> User has given explicit consent (Art. 6(1)(a))</li>
<li><code>contract</code> Processing necessary for a contract (Art. 6(1)(b))</li> <li><code>contract</code> Processing necessary for a contract (Art. 6(1)(b))</li>
@ -62,39 +62,40 @@ export function ComplianceGuide() {
<h2 id="ai-act">EU AI Act Risk Classification</h2> <h2 id="ai-act">EU AI Act Risk Classification</h2>
<p> <p>
The EU AI Act (effective August 2024, full enforcement from August 2026) classifies AI The EU AI Act (full enforcement from August 2026) classifies AI systems into four risk
systems into four risk categories. categories. Veylant IA automates the classification via a 5-question questionnaire
(<code>q1</code><code>q5</code>), scoring each <code>true</code> answer as +1.
</p> </p>
<div className="space-y-3 my-4"> <div className="space-y-3 my-4">
{[ {[
{ {
level: "Forbidden", level: "Interdit (forbidden)",
color: "border-red-400 bg-red-50 dark:bg-red-950/30", color: "border-red-400 bg-red-50 dark:bg-red-950/30",
badge: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300", badge: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
score: "Score 5", score: "Score 5",
desc: "Cannot be deployed. Examples: social scoring systems, real-time biometric surveillance in public spaces, AI that exploits vulnerable groups.", desc: "Déploiement interdit. Exemples : notation sociale des personnes, surveillance biométrique en temps réel dans l'espace public, IA exploitant des groupes vulnérables.",
}, },
{ {
level: "High Risk", level: "Haut risque (high)",
color: "border-orange-400 bg-orange-50 dark:bg-orange-950/30", color: "border-orange-400 bg-orange-50 dark:bg-orange-950/30",
badge: "bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300", badge: "bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300",
score: "Score 34", score: "Score 34",
desc: "Requires conformity assessment before deployment. DPIA mandatory. Examples: AI in hiring, credit scoring, education grading, critical infrastructure.", desc: "Évaluation de conformité obligatoire avant déploiement. AIPD (DPIA) requise. Exemples : IA dans le recrutement, le scoring crédit, la notation scolaire, les infrastructures critiques.",
}, },
{ {
level: "Limited Risk", level: "Risque limité (limited)",
color: "border-amber-400 bg-amber-50 dark:bg-amber-950/30", color: "border-amber-400 bg-amber-50 dark:bg-amber-950/30",
badge: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300", badge: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
score: "Score 12", score: "Score 12",
desc: "Transparency obligations apply. Users must be informed they interact with AI. Examples: chatbots, recommendation systems, customer service AI.", desc: "Obligations de transparence : les utilisateurs doivent être informés qu'ils interagissent avec un système d'IA. Exemples : chatbots, systèmes de recommandation, service client automatisé.",
}, },
{ {
level: "Minimal Risk", level: "Risque minimal (minimal)",
color: "border-green-400 bg-green-50 dark:bg-green-950/30", color: "border-green-400 bg-green-50 dark:bg-green-950/30",
badge: "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300", badge: "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300",
score: "Score 0", score: "Score 0",
desc: "Minimal or no risk. Voluntary code of conduct recommended. Examples: spam filters, AI-powered search, content recommendation.", desc: "Risque minimal. Code de conduite volontaire recommandé. Exemples : filtres anti-spam, recherche IA, recommandation de contenu.",
}, },
].map((item) => ( ].map((item) => (
<div key={item.level} className={`flex items-start gap-3 rounded-lg border-l-4 p-4 ${item.color}`}> <div key={item.level} className={`flex items-start gap-3 rounded-lg border-l-4 p-4 ${item.color}`}>
@ -114,28 +115,49 @@ export function ComplianceGuide() {
<h2 id="dpia">Data Protection Impact Assessment (DPIA)</h2> <h2 id="dpia">Data Protection Impact Assessment (DPIA)</h2>
<p> <p>
A DPIA is mandatory under GDPR Art. 35 for high-risk processing activities. High-risk AI A DPIA is mandatory under GDPR Art. 35 for high-risk processing activities. High-risk AI
systems under the AI Act also trigger DPIA requirements. Veylant IA generates DPIA template systems under the AI Act (<code>risk_level: "high"</code>) also trigger DPIA requirements.
documents from the Admin Compliance Reports tab. Veylant IA generates a DPIA template PDF from any processing entry.
</p> </p>
<h2 id="reports">Compliance Reports</h2>
<p>Available report formats via the API:</p>
<CodeBlock <CodeBlock
language="bash" language="bash"
code={`# GDPR Article 30 registry — PDF code={`# Generate DPIA PDF for a specific entry
GET /v1/admin/compliance/reports/art30.pdf curl "http://localhost:8090/v1/admin/compliance/dpia/entry-uuid" \\
-H "Authorization: Bearer $TOKEN" \\
--output dpia_$(date +%Y-%m-%d).pdf`}
/>
# GDPR Article 30 registry JSON export <h2 id="reports">Compliance Reports</h2>
GET /v1/admin/compliance/reports/art30.json <p>All reports are available as PDF (default) or JSON (<code>?format=json</code>):</p>
<CodeBlock
language="bash"
code={`# GDPR Article 30 register — PDF
curl "http://localhost:8090/v1/admin/compliance/report/article30" \\
-H "Authorization: Bearer $TOKEN" \\
--output article30_rgpd_$(date +%Y-%m-%d).pdf
# GDPR Article 30 register JSON (for custom reporting)
curl "http://localhost:8090/v1/admin/compliance/report/article30?format=json" \\
-H "Authorization: Bearer $TOKEN"
# AI Act risk classification report PDF # AI Act risk classification report PDF
GET /v1/admin/compliance/reports/ai-act.pdf curl "http://localhost:8090/v1/admin/compliance/report/aiact" \\
-H "Authorization: Bearer $TOKEN" \\
--output aiact_report_$(date +%Y-%m-%d).pdf
# DPIA template PDF # AI Act report JSON
GET /v1/admin/compliance/reports/dpia/{entry_id}.pdf curl "http://localhost:8090/v1/admin/compliance/report/aiact?format=json" \\
-H "Authorization: Bearer $TOKEN"
# Audit log export CSV # DPIA for a specific entry PDF only
GET /v1/admin/logs/export?from=2026-01-01T00:00:00Z&to=2026-01-31T23:59:59Z`} curl "http://localhost:8090/v1/admin/compliance/dpia/{entry-id}" \\
-H "Authorization: Bearer $TOKEN" \\
--output dpia_{entry-id}_$(date +%Y-%m-%d).pdf
# Audit log export CSV (max 10 000 rows, accepts YYYY-MM-DD dates)
curl "http://localhost:8090/v1/admin/compliance/export/logs?start=2026-01-01&end=2026-03-31" \\
-H "Authorization: Bearer $TOKEN" \\
--output audit_q1_2026.csv`}
/> />
<Callout type="tip" title="Audit-of-the-audit"> <Callout type="tip" title="Audit-of-the-audit">
@ -143,10 +165,40 @@ GET /v1/admin/logs/export?from=2026-01-01T00:00:00Z&to=2026-01-31T23:59:59Z`}
data protection authority requirements for meta-logging of sensitive data access. data protection authority requirements for meta-logging of sensitive data access.
</Callout> </Callout>
<h2 id="next-steps">Working with Compliance</h2> <Callout type="info" title="PDF tenant name">
PDF headers display the organisation name from <code>server.tenant_name</code> in{" "}
<code>config.yaml</code>. Set this to your legal entity name before generating official
compliance documents.
</Callout>
<h2 id="gdpr-rights-guide">GDPR Subject Rights Workflow</h2>
<p> <p>
See the <Link to="/docs/api/admin/compliance">Admin Compliance API</Link> for full Veylant IA provides endpoints for responding to GDPR Art. 15 (access) and Art. 17
endpoint documentation, or navigate to{" "} (erasure) requests. Implement the following workflow for data subject requests:
</p>
<CodeBlock
language="bash"
code={`# 1. Receive a subject access request (Art. 15)
# Identify the user by email, look up their user_id
USER_ID=$(curl -s "http://localhost:8090/v1/admin/users?email=john@acme.com" \\
-H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')
# 2. Retrieve all their data
curl "http://localhost:8090/v1/admin/compliance/gdpr/access/$USER_ID" \\
-H "Authorization: Bearer $TOKEN" \\
--output gdpr_access_response.json
# 3. If erasure requested (Art. 17):
curl -X DELETE "http://localhost:8090/v1/admin/compliance/gdpr/erase/$USER_ID" \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"reason": "Demande de suppression RGPD Art. 17 reçue le 2026-03-12"}'`}
/>
<h2 id="next-steps">Next Steps</h2>
<p>
See the <Link to="/docs/api-reference/admin-compliance">Admin Compliance API</Link> for
full endpoint documentation with request/response schemas, or navigate to{" "}
<strong>Dashboard Compliance</strong> to use the visual interface. <strong>Dashboard Compliance</strong> to use the visual interface.
</p> </p>
</div> </div>

View File

@ -6,8 +6,9 @@ export function RbacGuide() {
<div> <div>
<h1 id="rbac">RBAC & Permissions</h1> <h1 id="rbac">RBAC & Permissions</h1>
<p> <p>
Veylant IA enforces Role-Based Access Control on every request. Roles are embedded in the Veylant IA enforces Role-Based Access Control on every request. Roles are stored in the{" "}
Keycloak JWT and cannot be elevated at runtime. <code>users</code> table and embedded in the HS256 JWT at login time. A role cannot be
elevated at runtime a new token must be issued after a role change.
</p> </p>
<h2 id="roles">Roles</h2> <h2 id="roles">Roles</h2>
@ -16,12 +17,12 @@ export function RbacGuide() {
{ {
role: "admin", role: "admin",
color: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300", color: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
description: "Full access. Can manage policies, users, providers, feature flags, and read all compliance/audit data. Has unrestricted model access.", description: "Full access. Can manage routing policies, users, providers, feature flags, and read all compliance/audit data. Has unrestricted model access.",
}, },
{ {
role: "manager", role: "manager",
color: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300", color: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
description: "Read-write access to routing policies and users. Can run AI inference with any model. Cannot manage feature flags or access compliance reports.", description: "Read-write access to routing policies and user profiles. Can run AI inference with any model. Cannot manage feature flags or access compliance reports.",
}, },
{ {
role: "user", role: "user",
@ -31,7 +32,7 @@ export function RbacGuide() {
{ {
role: "auditor", role: "auditor",
color: "bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300", color: "bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300",
description: "Read-only access to audit logs and compliance data. Cannot call /v1/chat/completions. Intended for compliance officers and DPOs.", description: "Read-only access to audit logs, costs, and compliance data. Cannot call /v1/chat/completions. Intended for compliance officers and DPOs.",
}, },
].map((item) => ( ].map((item) => (
<div key={item.role} className="rounded-lg border bg-card p-4 flex items-start gap-3"> <div key={item.role} className="rounded-lg border bg-card p-4 flex items-start gap-3">
@ -57,17 +58,20 @@ export function RbacGuide() {
</thead> </thead>
<tbody> <tbody>
{[ {[
{ ep: "POST /v1/auth/login", admin: "✓", manager: "✓", user: "✓", auditor: "✓" },
{ ep: "POST /v1/chat/completions", admin: "✓", manager: "✓", user: "✓ (limited models)", auditor: "✗" }, { ep: "POST /v1/chat/completions", admin: "✓", manager: "✓", user: "✓ (limited models)", auditor: "✗" },
{ ep: "POST /v1/pii/analyze", admin: "✓", manager: "✓", user: "✓", auditor: "✓" }, { ep: "POST /v1/pii/analyze", admin: "✓", manager: "✓", user: "✓", auditor: "✓" },
{ ep: "GET /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" }, { ep: "GET /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
{ ep: "POST/PUT/DELETE /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" }, { ep: "POST/PUT/DELETE /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
{ ep: "GET/POST/PUT /v1/admin/users", admin: "✓", manager: "read only", user: "✗", auditor: "✗" }, { ep: "GET/POST/PUT/DELETE /v1/admin/users", admin: "✓", manager: "read only", user: "✗", auditor: "✗" },
{ ep: "GET /v1/admin/logs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" }, { ep: "GET /v1/admin/logs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" },
{ ep: "GET /v1/admin/costs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" }, { ep: "GET /v1/admin/costs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" },
{ ep: "GET /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✓" }, { ep: "GET /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✓" },
{ ep: "POST /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✗" }, { ep: "POST/PUT/DELETE /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
{ ep: "GET/PUT/DELETE /v1/admin/flags", admin: "✓", manager: "✗", user: "✗", auditor: "✗" }, { ep: "GET/PUT/DELETE /v1/admin/flags", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
{ ep: "GET /v1/admin/providers/status", admin: "✓", manager: "✓", user: "✗", auditor: "✗" }, { ep: "GET /v1/admin/providers/status", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
{ ep: "GET/POST/PUT/DELETE /v1/admin/providers", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
{ ep: "GET/PUT/DELETE /v1/admin/rate-limits", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
].map((row) => ( ].map((row) => (
<tr key={row.ep} className="border-b last:border-0"> <tr key={row.ep} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.ep}</td> <td className="px-4 py-2.5 font-mono text-xs">{row.ep}</td>
@ -98,7 +102,7 @@ rbac:
/> />
<CodeBlock <CodeBlock
language="json" language="json"
code={`// 403 response when user requests gpt-4o: code={`// 403 response when a 'user' requests gpt-4o:
{ {
"error": { "error": {
"type": "permission_error", "type": "permission_error",
@ -108,19 +112,71 @@ rbac:
}`} }`}
/> />
<Callout type="tip" title="Admin and manager bypass model restrictions"> <Callout type="tip" title="admin and manager bypass model restrictions">
The <code>admin</code> and <code>manager</code> roles have unrestricted model access {" "} The <code>admin</code> and <code>manager</code> roles have unrestricted model access {" "}
<code>user_allowed_models</code> does not apply to them. <code>user_allowed_models</code> does not apply to them.
</Callout> </Callout>
<h2 id="keycloak-setup">Setting Up Roles in Keycloak</h2> <h2 id="managing-roles">Managing User Roles via the Admin API</h2>
<p>Assign roles to users in Keycloak:</p> <p>
<ol> Roles are managed through the <code>/v1/admin/users</code> endpoints. After updating a
<li>Log in to Keycloak Admin Console (http://localhost:8080, admin/admin)</li> user's role, they must log in again to receive a new token with the updated claims.
<li>Go to <strong>Realm: veylant</strong> <strong>Users</strong></li> </p>
<li>Select a user <strong>Role Mappings</strong> <strong>Realm Roles</strong></li>
<li>Assign one of: <code>admin</code>, <code>manager</code>, <code>user</code>, <code>auditor</code></li> <CodeBlock
</ol> language="bash"
code={`# List all users
curl "http://localhost:8090/v1/admin/users" \\
-H "Authorization: Bearer $ADMIN_TOKEN"
# Create a new user with a specific role
curl -X POST "http://localhost:8090/v1/admin/users" \\
-H "Authorization: Bearer $ADMIN_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"email": "alice@acme.com",
"password": "SecurePass123!",
"name": "Alice Martin",
"role": "auditor",
"department": "Legal"
}'
# Promote a user to manager
curl -X PUT "http://localhost:8090/v1/admin/users/user-uuid" \\
-H "Authorization: Bearer $ADMIN_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"role": "manager",
"department": "Finance"
}'
# Deactivate a user (GDPR soft-delete)
curl -X DELETE "http://localhost:8090/v1/admin/users/user-uuid" \\
-H "Authorization: Bearer $ADMIN_TOKEN"`}
/>
<Callout type="warning" title="Role changes require a new token">
JWT tokens embed the role at login time. After changing a user's role via the API, the user
must log out and log in again. The old token remains valid until its <code>exp</code> claim
(controlled by <code>auth.jwt_ttl_hours</code>).
</Callout>
<h2 id="bulk-import">Bulk User Import</h2>
<p>
For tenant onboarding, use the provided script to bulk-import users from a CSV file:
</p>
<CodeBlock
language="bash"
code={`# CSV format: email,first_name,last_name,department,role
cat > users.csv << 'EOF'
alice@acme.com,Alice,Martin,Legal,user
bob@acme.com,Bob,Dupont,Finance,manager
carol@acme.com,Carol,Lefebvre,IT,auditor
EOF
# Import (requires make dev to be running)
./deploy/onboarding/import-users.sh users.csv`}
/>
</div> </div>
); );
} }

View File

@ -84,6 +84,79 @@ ollama pull codellama`}
<code>deploy.resources.reservations.devices</code> key in docker-compose.yml. <code>deploy.resources.reservations.devices</code> key in docker-compose.yml.
</Callout> </Callout>
<h2 id="db-providers">Managing Providers via the Admin API</h2>
<p>
In addition to static <code>config.yaml</code> configuration, providers can be added,
updated, and deleted at runtime via the admin API. API keys are stored encrypted
(AES-256-GCM) in the <code>provider_configs</code> table and hot-reloaded without a proxy
restart.
</p>
<CodeBlock
language="bash"
code={`# List all configured providers
curl http://localhost:8090/v1/admin/providers \\
-H "Authorization: Bearer $TOKEN"
# Add a new provider (API key stored encrypted at rest)
curl -X POST http://localhost:8090/v1/admin/providers \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"provider": "anthropic",
"api_key": "sk-ant-...",
"base_url": "https://api.anthropic.com",
"timeout_sec": 60,
"max_conns": 10
}'
# Update an existing provider
curl -X PUT http://localhost:8090/v1/admin/providers/provider-uuid \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"api_key": "sk-ant-new-key...", "timeout_sec": 120}'
# Test connectivity before saving
curl -X POST http://localhost:8090/v1/admin/providers/provider-uuid/test \\
-H "Authorization: Bearer $TOKEN"
# Remove a provider
curl -X DELETE http://localhost:8090/v1/admin/providers/provider-uuid \\
-H "Authorization: Bearer $TOKEN"`}
/>
<Callout type="info" title="Hot-reload — no restart required">
Provider changes via the admin API take effect immediately via{" "}
<code>router.UpdateAdapter()</code> / <code>RemoveAdapter()</code>. Existing in-flight
requests are not interrupted.
</Callout>
<Callout type="tip" title="Static config takes precedence at startup">
Providers defined in <code>config.yaml</code> are loaded first. Database-configured
providers are loaded on top if the same provider name exists in both, the DB version
wins. This lets you override a static key without editing the config file.
</Callout>
<h2 id="azure-config">Azure OpenAI Additional Fields</h2>
<p>
Azure requires a resource name and deployment ID instead of a model name:
</p>
<CodeBlock
language="bash"
code={`curl -X POST http://localhost:8090/v1/admin/providers \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"provider": "azure",
"api_key": "...",
"resource_name": "my-azure-openai-resource",
"deployment_id": "gpt-4o-prod",
"api_version": "2024-02-01",
"timeout_sec": 60,
"max_conns": 20
}'`}
/>
<h2 id="provider-status">Check Provider Status</h2> <h2 id="provider-status">Check Provider Status</h2>
<p> <p>
The admin API exposes circuit breaker state for all providers: The admin API exposes circuit breaker state for all providers:
@ -108,8 +181,8 @@ ollama pull codellama`}
<p>Circuit breaker states:</p> <p>Circuit breaker states:</p>
<ul> <ul>
<li><strong>closed</strong> Normal operation, requests forwarded</li> <li><strong>closed</strong> Normal operation, requests forwarded</li>
<li><strong>open</strong> Provider bypassed, fallback chain used</li> <li><strong>open</strong> Provider bypassed after 5 consecutive failures (60s TTL)</li>
<li><strong>half-open</strong> Testing if provider has recovered</li> <li><strong>half-open</strong> Testing if provider has recovered (one probe request)</li>
</ul> </ul>
</div> </div>
); );