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/apierror/ OpenAI-format error helpers (WriteError, WriteErrorWithRequestID)
├── 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)
│ gRPC (<2ms) to localhost:50051
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()`).
**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.
**`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`.
**Tenant onboarding** (after `make dev`):

View File

@ -334,12 +334,12 @@ func main() {
// Public — CORS applied, no auth required.
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.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.
if notifHandler != nil {

View File

@ -342,13 +342,23 @@ func (h *Handler) getLogs(w http.ResponseWriter, r *http.Request) {
Limit: parseIntParam(r, "limit", 50),
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 t, err := time.Parse(time.RFC3339, s); err == nil {
if t := parseTime(s); !t.IsZero() {
q.StartTime = t
}
}
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
}
}
@ -379,13 +389,21 @@ func (h *Handler) getCosts(w http.ResponseWriter, r *http.Request) {
TenantID: tenantID,
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 t, err := time.Parse(time.RFC3339, s); err == nil {
if t := parseTime(s); !t.IsZero() {
q.StartTime = t
}
}
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
}
}

View File

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

View File

@ -245,6 +245,14 @@ func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
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{
ID: id,
TenantID: tenantID,
@ -258,6 +266,8 @@ func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
SecurityMeasures: req.SecurityMeasures,
ControllerName: req.ControllerName,
IsActive: true,
RiskLevel: existing.RiskLevel,
AiActAnswers: existing.AiActAnswers,
}
updated, err := h.store.Update(r.Context(), entry)
if err != nil {
@ -455,11 +465,22 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
return
}
targetUser := chi.URLParam(r, "user_id")
reason := r.URL.Query().Get("reason")
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
recordsDeleted := 0
erasureID := ""
if h.db != nil {
res, err := h.db.ExecContext(r.Context(),
`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)
}
// Log erasure (immutable)
_, logErr := h.db.ExecContext(r.Context(),
// Log erasure (immutable) — read back generated UUID for the response.
logErr := h.db.QueryRowContext(r.Context(),
`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,
)
).Scan(&erasureID)
if logErr != nil {
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{
ID: erasureID,
TenantID: tenantID,
TargetUser: targetUser,
RequestedBy: requestedBy,
@ -519,13 +541,26 @@ func (h *Handler) exportLogsCSV(w http.ResponseWriter, r *http.Request) {
Provider: r.URL.Query().Get("provider"),
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 t, err := time.Parse(time.RFC3339, s); err == nil {
if t := parseDate(s); !t.IsZero() {
q.StartTime = t
}
}
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
}
}

View File

@ -191,14 +191,15 @@ func GenerateArticle30(entries []ProcessingEntry, tenantName string, w io.Writer
}
}
if len(allProcessors) == 0 {
allProcessors["OpenAI (GPT-4o)"] = true
allProcessors["Anthropic (Claude)"] = true
}
setFont(pdf, "I", 9, colGray)
pdf.CellFormat(0, 6, "Aucun sous-traitant déclaré.", "", 1, "L", false, 0, "")
} else {
for proc := range allProcessors {
setFont(pdf, "", 9, colBlack)
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, "")
}
}
// Section 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) {
// 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>);
return useQuery({
queryKey: ["logs", params],
queryKey: ["logs", qs],
queryFn: () => apiFetch<AuditResult>(`/v1/admin/logs${qs}`),
refetchInterval,
staleTime: 30_000, // don't refetch if data is less than 30s old
});
}
@ -35,6 +38,7 @@ export function useRequestCount() {
queryKey: ["logs", "count"],
queryFn: () => apiFetch<AuditResult>("/v1/admin/logs?limit=1&offset=0"),
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";
// 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) {
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);
return { start: start.toISOString(), end: end.toISOString() };
}
export function VolumeChart() {
const [range, setRange] = useState<Range>("7d");
const { start, end } = buildDateRange(range);
const days = range === "7d" ? 7 : 30;
const { data, isLoading } = useAuditLogs(
const { start, end } = buildDateRange(range);
const { data, isLoading, isError } = useAuditLogs(
{ start, end, limit: 1000 },
30_000
60_000 // 60s — halves request frequency vs previous 30s
);
const chartData = useMemo(() => {
if (!data?.data) return [];
// Group by day
// Pre-fill all days in range with zeros regardless of API result.
const map = new Map<string, { requests: number; errors: number }>();
// Pre-fill all days in range
for (let i = days - 1; i >= 0; i--) {
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
map.set(d, { requests: 0, errors: 0 });
}
if (data?.data) {
for (const entry of data.data) {
const day = format(startOfDay(parseISO(entry.timestamp)), "yyyy-MM-dd");
const existing = map.get(day) ?? { requests: 0, errors: 0 };
@ -54,6 +60,7 @@ export function VolumeChart() {
if (entry.status === "error") existing.errors++;
map.set(day, existing);
}
}
return Array.from(map.entries()).map(([date, stats]) => ({
date: format(parseISO(date), "dd/MM", { locale: fr }),
@ -62,6 +69,8 @@ export function VolumeChart() {
}));
}, [data, days]);
const hasActivity = chartData.some((d) => d.Requêtes > 0 || d.Erreurs > 0);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@ -86,6 +95,15 @@ export function VolumeChart() {
<CardContent>
{isLoading ? (
<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}>
<LineChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
@ -95,22 +113,24 @@ export function VolumeChart() {
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} className="text-muted-foreground" />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="Requêtes"
stroke="hsl(222.2 47.4% 11.2%)"
stroke="#1e3a5f"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="Erreurs"
stroke="hsl(0 84.2% 60.2%)"
stroke="#ef4444"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>

View File

@ -8,7 +8,7 @@ import { fr } from "date-fns/locale";
export function OverviewPage() {
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 totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;

View File

@ -8,103 +8,146 @@ export function AdminCompliancePage() {
<div>
<h1 id="admin-compliance">Admin Compliance</h1>
<p>
GDPR Article 30 processing registry, EU AI Act risk classification, and data subject
rights (access and erasure).
GDPR Article 30 processing registry, EU AI Act risk classification, DPIA generation, PDF
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>
<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>
<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="GET" path="/v1/admin/compliance/entries/{id}" description="Get a specific processing entry." />
<ApiEndpoint method="PUT" path="/v1/admin/compliance/entries/{id}" description="Update a processing activity 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. 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
title="Processing Entry Fields"
params={[
{ 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: "legal_basis", type: "string", required: true, description: "Legal basis: legitimate_interest | contract | legal_obligation | consent | vital_interests | public_task" },
{ name: "data_categories", type: "[]string", required: true, description: "Categories of personal data: name, email, phone, id_number, financial, health, etc." },
{ name: "retention_period", type: "string", required: true, description: "Data retention period (e.g. '3 years', '90 days')." },
{ name: "security_measures", type: "string", required: true, description: "Technical and organizational security measures." },
{ name: "controller_name", type: "string", required: true, description: "Data controller name and contact." },
{ name: "recipients", type: "[]string", required: false, description: "Third parties receiving the data." },
{ name: "processors", type: "[]string", required: false, description: "Data processors (LLM providers, cloud services)." },
{ name: "dpia_required", type: "boolean", required: false, default: "false", description: "Whether a DPIA (Art. 35) is required." },
{ 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: 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, biometric, etc." },
{ 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: false, description: "Technical and organizational security measures in place." },
{ 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 (e.g. 'OpenAI via Veylant IA proxy')." },
{ name: "processors", type: "[]string", required: false, description: "Sub-processors involved in the processing." },
]}
/>
<CodeBlock
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 "Content-Type: application/json" \\
-d '{
"use_case_name": "Legal contract analysis",
"purpose": "Automated review of supplier contracts for risk identification",
"use_case_name": "Analyse de contrats fournisseurs",
"purpose": "Identification automatique des risques dans les contrats fournisseurs",
"legal_basis": "legitimate_interest",
"data_categories": ["name", "financial", "company_data"],
"retention_period": "3 years",
"security_measures": "AES-256-GCM encryption, PII anonymization, audit logs",
"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)"]
"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
language="bash"
code={`curl -X POST http://localhost:8090/v1/admin/compliance/entries/entry-uuid/classify \\
-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:
language="json"
code={`// 201 — Entry created
{
"risk_level": "limited",
"score": 2,
"description": "Limited risk — transparency obligations apply. Users must be informed they are interacting with an AI system.",
"actions_required": [
"Display AI disclosure in the user interface",
"Document in GDPR Art. 30 registry",
"Annual review recommended"
]
"id": "ce4f1234-0000-0000-0000-000000000001",
"tenant_id": "dev-tenant",
"use_case_name": "Analyse de contrats fournisseurs",
"purpose": "Identification automatique des risques dans les contrats fournisseurs",
"legal_basis": "legitimate_interest",
"data_categories": ["name", "financial", "company_data"],
"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">
<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">Score</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">Description</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">Question</th>
</tr>
</thead>
<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" },
{ score: "34", level: "High", desc: "Strict conformity assessment required before deployment. DPIA mandatory.", color: "text-orange-600" },
{ score: "12", level: "Limited", desc: "Transparency obligations: users must be informed they interact with AI.", color: "text-amber-600" },
{ score: "0", level: "Minimal", desc: "Minimal risk. Voluntary code of conduct recommended.", color: "text-green-600" },
{ key: "q1", q: "Le système prend-il des décisions autonomes affectant des droits légaux ou des situations similaires ?" },
{ key: "q2", q: "Implique-t-il une identification biométrique ou une reconnaissance des émotions ?" },
{ key: "q3", q: "Est-il utilisé dans des décisions critiques (médical, justice, emploi, crédit) ?" },
{ 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) => (
<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-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>
</tr>
))}
@ -112,31 +155,168 @@ export function AdminCompliancePage() {
</table>
</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>
<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
language="bash"
code={`curl http://localhost:8090/v1/admin/compliance/gdpr/access/user-uuid \\
-H "Authorization: Bearer $TOKEN"
code={`curl "http://localhost:8090/v1/admin/compliance/gdpr/access/a1b2c3d4-0000-0000-0000-000000000001" \\
-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>
<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
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 "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
content and pseudonymizes user identifiers, but the request metadata (token counts, cost,
timestamps) is retained for compliance reporting.
<CodeBlock
language="json"
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>
</div>
);

View File

@ -8,125 +8,302 @@ export function AdminLogsPage() {
<div>
<h1 id="admin-logs">Admin Audit Logs & Costs</h1>
<p>
Query the immutable audit trail and cost breakdown for AI requests. All data is stored in
ClickHouse (append-only no DELETE operations).
Query the immutable audit trail and cost breakdown for all AI requests. Data is stored in
ClickHouse (append-only no DELETE operations). In development without ClickHouse, an
in-memory fallback is used (data is lost on restart).
</p>
<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
title="Query Parameters"
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: "model", type: "string", required: false, description: "Filter by model used." },
{ name: "from", type: "string (ISO 8601)", required: false, description: "Start of time range, e.g. 2026-01-01T00:00:00Z" },
{ name: "to", type: "string (ISO 8601)", required: false, description: "End of time range." },
{ 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 1000)." },
{ name: "min_sensitivity", type: "string", required: false, description: "Minimum PII sensitivity level: none | low | medium | high | critical" },
{ 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: "end", type: "string (RFC3339 or RFC3339Nano)", required: false, description: "End of time range. Same format as start." },
{ name: "limit", type: "integer", required: false, default: "50", description: "Max entries to return (max 10 000)." },
{ name: "offset", type: "integer", required: false, default: "0", description: "Pagination offset." },
]}
/>
<CodeBlock
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"
# 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": [
{
"id": "log-uuid",
"tenant_id": "tenant-uuid",
"user_id": "user-uuid",
"user_email": "alice@acme.com",
"request_id": "req_01HV...",
"tenant_id": "dev-tenant",
"user_id": "a1b2c3d4-0000-0000-0000-000000000001",
"timestamp": "2026-03-10T14:32:11Z",
"provider": "anthropic",
"model_requested": "gpt-4o",
"model_used": "claude-3-5-sonnet-20241022",
"prompt_tokens": 128,
"completion_tokens": 345,
"total_tokens": 473,
"department": "Legal",
"user_role": "user",
"sensitivity_level": "high",
"token_input": 128,
"token_output": 345,
"token_total": 473,
"cost_usd": 0.003412,
"latency_ms": 1423,
"pii_detected": true,
"pii_entities": ["PERSON", "EMAIL_ADDRESS"],
"policy_matched": "Legal → Anthropic",
"status_code": 200,
"timestamp": "2026-01-15T14:32:11Z"
"status": "ok",
"error_type": "",
"pii_entity_count": 3,
"stream": false,
"prompt_hash": "sha256:a3f...",
"response_hash": "sha256:b7c..."
}
],
"total": 142,
"limit": 10,
"offset": 0
"total": 142
}`}
/>
<Callout type="info" title="Audit-of-the-audit">
All accesses to audit logs are themselves logged. This satisfies the "audit-of-the-audit"
requirement for sensitive compliance use cases.
All accesses to audit logs are themselves logged. This satisfies the meta-logging
requirement for data protection authorities.
</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>
<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
title="Query Parameters"
params={[
{ name: "group_by", type: "string", required: false, default: "provider", description: "Grouping dimension: provider | model | department | user" },
{ name: "from", type: "string (ISO 8601)", required: false, description: "Start of billing period." },
{ name: "to", type: "string (ISO 8601)", required: false, description: "End of billing period." },
{ name: "group_by", type: "string", required: false, default: "provider", description: "Grouping dimension: provider | model | department" },
{ name: "start", type: "string (RFC3339)", required: false, description: "Start of billing period." },
{ name: "end", type: "string (RFC3339)", required: false, description: "End of billing period." },
]}
/>
<CodeBlock
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"
# 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": [
{
"key": "openai",
"request_count": 1423,
"total_tokens": 2840000,
"prompt_tokens": 1200000,
"completion_tokens": 1640000,
"total_cost_usd": 28.40
"total_cost_usd": 28.40,
"request_count": 1423
},
{
"key": "anthropic",
"request_count": 231,
"total_tokens": 462000,
"prompt_tokens": 230000,
"completion_tokens": 232000,
"total_cost_usd": 6.93
"total_cost_usd": 6.93,
"request_count": 231
},
{
"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>
<ApiEndpoint method="GET" path="/v1/admin/rate-limits" description="List per-tenant rate limit overrides." />
<ApiEndpoint method="GET" path="/v1/admin/rate-limits/{tenant_id}" description="Get rate limit configuration for a specific tenant." />
<h2 id="csv-export">CSV Export</h2>
<ApiEndpoint
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
language="bash"
code={`# Get rate limit for a tenant
curl http://localhost:8090/v1/admin/rate-limits/tenant-uuid \\
code={`# Export all logs as CSV
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"
# 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",
"rpm": 500, # requests per minute
"tpm": 50000, # tokens per minute
"daily_token_limit": 1000000
}`}
"tenant_id": "acme-corp-tenant-id",
"requests_per_min": 500,
"burst_size": 50,
"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>
);

View File

@ -1,89 +1,140 @@
import { CodeBlock } from "../components/CodeBlock";
import { Callout } from "../components/Callout";
import { ApiEndpoint } from "../components/ApiEndpoint";
import { ParamTable } from "../components/ParamTable";
export function AuthenticationPage() {
return (
<div>
<h1 id="authentication">Authentication</h1>
<p>
All <code>/v1/*</code> endpoints require a Bearer JWT in the{" "}
<code>Authorization</code> header. Veylant IA validates the token against Keycloak (OIDC)
or uses a mock verifier in development mode.
Veylant IA uses a local email/password authentication system. Users log in with their
credentials to receive a signed JWT (HS256). This token must be sent as a Bearer token on
all protected <code>/v1/*</code> requests.
</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
language="bash"
code={`curl http://localhost:8090/v1/chat/completions \\
-H "Authorization: Bearer <your-access-token>" \\
code={`curl -X POST http://localhost:8090/v1/auth/login \\
-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
language="bash"
code={`# Any string works as the token in dev mode
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:
language="json"
code={`// 200 — Login successful
{
"access_token": "eyJhbGci...",
"expires_in": 300,
"refresh_token": "eyJhbGci...",
"token_type": "Bearer"
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "a1b2c3d4-0000-0000-0000-000000000001",
"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>
<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">
<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">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>
</tr>
</thead>
<tbody>
{[
{ claim: "sub", source: "Standard JWT", desc: "User ID (UUID)" },
{ claim: "email", source: "Standard JWT", desc: "User email" },
{ claim: "realm_access.roles", source: "Keycloak extension", desc: "RBAC roles: admin, manager, user, auditor" },
{ claim: "veylant_tenant_id", source: "Keycloak mapper", desc: "Tenant UUID" },
{ claim: "department", source: "Keycloak user attribute", desc: "Department name for routing rules" },
{ claim: "sub", type: "string (UUID)", desc: "User ID — used in audit logs and GDPR access requests." },
{ claim: "email", type: "string", desc: "User email address." },
{ claim: "name", type: "string", desc: "Display name shown in the dashboard." },
{ claim: "role", type: "string", desc: "RBAC role: admin | manager | user | auditor." },
{ 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) => (
<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 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>
</tr>
))}
@ -91,21 +142,37 @@ curl -X POST \\
</table>
</div>
<h2 id="test-users">Pre-configured Test Users</h2>
<p>The Keycloak realm export includes these users for testing:</p>
<h2 id="test-users">Pre-configured Dev Users</h2>
<p>
The dev stack seeds two users in the <code>users</code> table (migration 000010):
</p>
<CodeBlock
language="bash"
code={`# Admin user (full access)
username: admin@veylant.dev
code={`# Admin — full access, unrestricted model access
email: admin@veylant.dev
password: admin123
roles: admin
role: admin
# Regular user (restricted to allowed models)
username: user@veylant.dev
# Regular user inference only, restricted to allowed models
# (create this user via POST /v1/admin/users if needed)
email: user@veylant.dev
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>
<p>Authentication errors always return OpenAI-format JSON:</p>
<CodeBlock
@ -119,13 +186,22 @@ roles: user`}
}
}
// 403 — Valid token, insufficient role
// 403 — Valid token, insufficient role for the model
{
"error": {
"type": "permission_error",
"message": "role 'user' does not have access to model 'gpt-4o'. Allowed: gpt-4o-mini",
"code": "permission_denied"
}
}
// 429 — Rate limit exceeded
{
"error": {
"type": "rate_limit_error",
"message": "rate limit exceeded",
"code": "rate_limit_exceeded"
}
}`}
/>
</div>

View File

@ -12,8 +12,7 @@ export function QuickStartPage() {
<Callout type="info" title="Prerequisites">
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
free.
Clone the repository and ensure ports 8090, 5432, 6379, 8123, 3000, and 3001 are free.
</Callout>
<h2 id="step-1-clone">Step 1 Clone the repository</h2>
@ -34,15 +33,18 @@ cd ia-gateway`}
# Edit .env and set:
OPENAI_API_KEY=sk-...
# Optional:
ANTHROPIC_API_KEY=sk-ant-...`}
# Optional additional providers:
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
gracefully. Keycloak is bypassed (mock JWT), PostgreSQL failures disable routing, ClickHouse
failures disable audit logs. This means you can start the proxy even if some services
haven't fully initialized yet.
gracefully: PostgreSQL failures disable routing rules, ClickHouse failures fall back to an
in-memory audit log (data not persisted), and PII failures are silently skipped if{" "}
<code>pii.fail_open=true</code>. The proxy stays up even if some services haven't
initialized yet.
</Callout>
<h2 id="step-3-start">Step 3 Start the stack</h2>
@ -54,18 +56,19 @@ ANTHROPIC_API_KEY=sk-ant-...`}
docker compose up --build`}
/>
<p>
This starts 9 services: PostgreSQL, Redis, ClickHouse, Keycloak, the Go proxy, PII
detection service, Prometheus, Grafana, and the React dashboard.
This starts 8 services: PostgreSQL, Redis, ClickHouse, the Go proxy, PII detection
service, Prometheus, Grafana, and the React dashboard.
</p>
<p>
Wait for the proxy to print <code>server listening on :8090</code>. First startup takes
~2 minutes while Keycloak initializes and database migrations run.
Wait for the proxy to print{" "}
<code>Veylant IA proxy started addr=:8090</code>. First startup takes ~60 seconds while
PostgreSQL runs migrations.
</p>
<h2 id="step-4-verify">Step 4 Verify the stack</h2>
<CodeBlock
language="bash"
code={`# Health check
code={`# Health check — no auth required
curl http://localhost:8090/healthz
# {"status":"ok","version":"1.0.0"}`}
/>
@ -82,12 +85,12 @@ curl http://localhost:8090/healthz
<tbody>
{[
{ service: "AI Proxy", url: "http://localhost:8090", creds: "—" },
{ service: "React Dashboard", url: "http://localhost:3000", creds: "dev mode (no auth)" },
{ service: "Keycloak Admin", url: "http://localhost:8080", creds: "admin / admin" },
{ service: "React Dashboard", url: "http://localhost:3000", creds: "admin@veylant.dev / admin123" },
{ 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: "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) => (
<tr key={row.service} className="border-b last:border-0">
<td className="px-4 py-2.5 font-medium">{row.service}</td>
@ -101,62 +104,87 @@ curl http://localhost:8090/healthz
</table>
</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>
In development mode, the proxy uses a mock JWT verifier. Pass any Bearer token and the
request will be authenticated as <code>admin@veylant.dev</code>.
Log in to get a JWT token, then use it as a Bearer token on all <code>/v1/*</code>{" "}
requests.
</p>
<CodeBlock
language="bash"
code={`curl http://localhost:8090/v1/chat/completions \\
-H "Authorization: Bearer dev-token" \\
code={`# Step 5a — Login to get a JWT 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" \\
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hello, Veylant!"}]
"model": "gpt-4o-mini",
"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
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(
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(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello, Veylant!"}],
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Résume ce contrat en 3 points."}],
)
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>
<p>
Open <code>http://localhost:3000</code> to see the React dashboard. In development mode,
you're automatically logged in as <code>Dev Admin</code>. You'll see:
Open <code>http://localhost:3000</code> and log in with{" "}
<code>admin@veylant.dev / admin123</code>. You'll find:
</p>
<ul>
<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>
<strong>Playground IA</strong> test prompts with live PII detection visualization
</li>
<li>
<strong>Policies</strong> create and manage routing rules
<strong>Politiques</strong> create and manage routing rules
</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>
</ul>
<Callout type="tip" title="Next: Configure a routing rule">
Try creating a routing rule in the dashboard that sends all requests from the{" "}
<code>legal</code> department to Anthropic instead of OpenAI. See{" "}
Try creating a routing rule that sends all requests from the <code>Legal</code> department
to Anthropic instead of OpenAI. See{" "}
<a href="/docs/guides/routing">Routing Rules Engine</a> for the full guide.
</Callout>

View File

@ -7,16 +7,16 @@ export function ComplianceGuide() {
<div>
<h1 id="compliance">GDPR & EU AI Act Compliance</h1>
<p>
Veylant IA includes a built-in compliance module for GDPR Article 30 record-keeping and EU
AI Act risk classification. It is designed to serve as your primary compliance tool for AI
deployments.
Veylant IA includes a built-in compliance module for GDPR Article 30 record-keeping, EU
AI Act risk classification, DPIA generation, and GDPR subject rights management. It is
designed to serve as the primary compliance tool for enterprise AI deployments.
</p>
<h2 id="gdpr-art30">GDPR Article 30 Record of Processing Activities</h2>
<p>
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
processed.
activities. For AI systems, this means documenting every use case where personal data may
be processed including through third-party LLM providers.
</p>
<h3 id="ropa-fields">Required ROPA Fields</h3>
@ -31,14 +31,14 @@ export function ComplianceGuide() {
</thead>
<tbody>
{[
{ field: "use_case_name", req: "Name of the processing activity", ex: "Legal contract analysis" },
{ field: "purpose", req: "Art. 5(1)(b) — purpose limitation", ex: "Automated risk identification in supplier contracts" },
{ 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: "Identification automatique des risques dans les contrats" },
{ 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: "retention_period", req: "Art. 5(1)(e) — storage limitation", ex: "3 years" },
{ field: "security_measures", req: "Art. 32 — security of processing", ex: "AES-256-GCM, PII anonymization" },
{ 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 ans" },
{ 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: "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) => (
<tr key={row.field} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.field}</td>
@ -50,7 +50,7 @@ export function ComplianceGuide() {
</table>
</div>
<h3 id="legal-bases">Legal Bases</h3>
<h3 id="legal-bases">Legal Bases (Art. 6 GDPR)</h3>
<ul>
<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>
@ -62,39 +62,40 @@ export function ComplianceGuide() {
<h2 id="ai-act">EU AI Act Risk Classification</h2>
<p>
The EU AI Act (effective August 2024, full enforcement from August 2026) classifies AI
systems into four risk categories.
The EU AI Act (full enforcement from August 2026) classifies AI systems into four risk
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>
<div className="space-y-3 my-4">
{[
{
level: "Forbidden",
level: "Interdit (forbidden)",
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",
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",
badge: "bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300",
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",
badge: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
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",
badge: "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300",
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) => (
<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>
<p>
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
documents from the Admin Compliance Reports tab.
systems under the AI Act (<code>risk_level: "high"</code>) also trigger DPIA requirements.
Veylant IA generates a DPIA template PDF from any processing entry.
</p>
<h2 id="reports">Compliance Reports</h2>
<p>Available report formats via the API:</p>
<CodeBlock
language="bash"
code={`# GDPR Article 30 registry — PDF
GET /v1/admin/compliance/reports/art30.pdf
code={`# Generate DPIA PDF for a specific entry
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
GET /v1/admin/compliance/reports/art30.json
<h2 id="reports">Compliance Reports</h2>
<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
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
GET /v1/admin/compliance/reports/dpia/{entry_id}.pdf
# AI Act report JSON
curl "http://localhost:8090/v1/admin/compliance/report/aiact?format=json" \\
-H "Authorization: Bearer $TOKEN"
# Audit log export CSV
GET /v1/admin/logs/export?from=2026-01-01T00:00:00Z&to=2026-01-31T23:59:59Z`}
# DPIA for a specific entry PDF only
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">
@ -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.
</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>
See the <Link to="/docs/api/admin/compliance">Admin Compliance API</Link> for full
endpoint documentation, or navigate to{" "}
Veylant IA provides endpoints for responding to GDPR Art. 15 (access) and Art. 17
(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.
</p>
</div>

View File

@ -6,8 +6,9 @@ export function RbacGuide() {
<div>
<h1 id="rbac">RBAC & Permissions</h1>
<p>
Veylant IA enforces Role-Based Access Control on every request. Roles are embedded in the
Keycloak JWT and cannot be elevated at runtime.
Veylant IA enforces Role-Based Access Control on every request. Roles are stored in the{" "}
<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>
<h2 id="roles">Roles</h2>
@ -16,12 +17,12 @@ export function RbacGuide() {
{
role: "admin",
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",
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",
@ -31,7 +32,7 @@ export function RbacGuide() {
{
role: "auditor",
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) => (
<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>
<tbody>
{[
{ ep: "POST /v1/auth/login", admin: "✓", manager: "✓", user: "✓", auditor: "✓" },
{ ep: "POST /v1/chat/completions", admin: "✓", manager: "✓", user: "✓ (limited models)", auditor: "✗" },
{ ep: "POST /v1/pii/analyze", 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: "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/costs", 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 /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) => (
<tr key={row.ep} className="border-b last:border-0">
<td className="px-4 py-2.5 font-mono text-xs">{row.ep}</td>
@ -98,7 +102,7 @@ rbac:
/>
<CodeBlock
language="json"
code={`// 403 response when user requests gpt-4o:
code={`// 403 response when a 'user' requests gpt-4o:
{
"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 {" "}
<code>user_allowed_models</code> does not apply to them.
</Callout>
<h2 id="keycloak-setup">Setting Up Roles in Keycloak</h2>
<p>Assign roles to users in Keycloak:</p>
<ol>
<li>Log in to Keycloak Admin Console (http://localhost:8080, admin/admin)</li>
<li>Go to <strong>Realm: veylant</strong> <strong>Users</strong></li>
<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>
</ol>
<h2 id="managing-roles">Managing User Roles via the Admin API</h2>
<p>
Roles are managed through the <code>/v1/admin/users</code> endpoints. After updating a
user's role, they must log in again to receive a new token with the updated claims.
</p>
<CodeBlock
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>
);
}

View File

@ -84,6 +84,79 @@ ollama pull codellama`}
<code>deploy.resources.reservations.devices</code> key in docker-compose.yml.
</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>
<p>
The admin API exposes circuit breaker state for all providers:
@ -108,8 +181,8 @@ ollama pull codellama`}
<p>Circuit breaker states:</p>
<ul>
<li><strong>closed</strong> Normal operation, requests forwarded</li>
<li><strong>open</strong> Provider bypassed, fallback chain used</li>
<li><strong>half-open</strong> Testing if provider has recovered</li>
<li><strong>open</strong> Provider bypassed after 5 consecutive failures (60s TTL)</li>
<li><strong>half-open</strong> Testing if provider has recovered (one probe request)</li>
</ul>
</div>
);