diff --git a/CLAUDE.md b/CLAUDE.md index a2e374a..50d4ffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`): diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 3d468d2..7bc1e98 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -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 { diff --git a/internal/admin/handler.go b/internal/admin/handler.go index c2834f5..7e90793 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -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 } } diff --git a/internal/auditlog/logger.go b/internal/auditlog/logger.go index 22072f9..b803a09 100644 --- a/internal/auditlog/logger.go +++ b/internal/auditlog/logger.go @@ -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] diff --git a/internal/compliance/handler.go b/internal/compliance/handler.go index 933a66f..efb940b 100644 --- a/internal/compliance/handler.go +++ b/internal/compliance/handler.go @@ -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 } } diff --git a/internal/compliance/pdf.go b/internal/compliance/pdf.go index 0427540..3bd7415 100644 --- a/internal/compliance/pdf.go +++ b/internal/compliance/pdf.go @@ -191,13 +191,14 @@ func GenerateArticle30(entries []ProcessingEntry, tenantName string, w io.Writer } } if len(allProcessors) == 0 { - allProcessors["OpenAI (GPT-4o)"] = true - allProcessors["Anthropic (Claude)"] = true - } - 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, "") + 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 diff --git a/test_smtp.go b/test_smtp.go new file mode 100644 index 0000000..ec71cc0 --- /dev/null +++ b/test_smtp.go @@ -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() +} diff --git a/web/src/api/logs.ts b/web/src/api/logs.ts index d3fcbf8..6b71b3c 100644 --- a/web/src/api/logs.ts +++ b/web/src/api/logs.ts @@ -21,11 +21,14 @@ function buildQueryString(params: Record): } 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); return useQuery({ - queryKey: ["logs", params], + queryKey: ["logs", qs], queryFn: () => apiFetch(`/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("/v1/admin/logs?limit=1&offset=0"), select: (d) => d.total, - refetchInterval: 30_000, + refetchInterval: 60_000, + staleTime: 30_000, }); } diff --git a/web/src/components/VolumeChart.tsx b/web/src/components/VolumeChart.tsx index 5bc810f..191097e 100644 --- a/web/src/components/VolumeChart.tsx +++ b/web/src/components/VolumeChart.tsx @@ -18,41 +18,48 @@ 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("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(); - - // 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 }); } - 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 }; - existing.requests++; - if (entry.status === "error") existing.errors++; - map.set(day, existing); + 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 }; + existing.requests++; + if (entry.status === "error") existing.errors++; + map.set(day, existing); + } } return Array.from(map.entries()).map(([date, stats]) => ({ @@ -62,6 +69,8 @@ export function VolumeChart() { })); }, [data, days]); + const hasActivity = chartData.some((d) => d.Requêtes > 0 || d.Erreurs > 0); + return ( @@ -86,6 +95,15 @@ export function VolumeChart() { {isLoading ? ( + ) : isError ? ( +
+ Impossible de charger les données. +
+ ) : !hasActivity ? ( +
+

Aucune requête sur les {days} derniers jours.

+

Les données apparaîtront après le premier appel via le proxy.

+
) : ( @@ -95,22 +113,24 @@ export function VolumeChart() { tick={{ fontSize: 12 }} className="text-muted-foreground" /> - + diff --git a/web/src/pages/OverviewPage.tsx b/web/src/pages/OverviewPage.tsx index 81386e2..70903b1 100644 --- a/web/src/pages/OverviewPage.tsx +++ b/web/src/pages/OverviewPage.tsx @@ -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; diff --git a/web/src/pages/docs/api-reference/AdminCompliancePage.tsx b/web/src/pages/docs/api-reference/AdminCompliancePage.tsx index d5225c5..23b607e 100644 --- a/web/src/pages/docs/api-reference/AdminCompliancePage.tsx +++ b/web/src/pages/docs/api-reference/AdminCompliancePage.tsx @@ -8,103 +8,146 @@ export function AdminCompliancePage() {

Admin — Compliance

- 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 /v1/admin/compliance and require a valid JWT.

- Compliance endpoints require admin role. Auditors can read but not modify. + Read endpoints (GET) are accessible to admin and{" "} + auditor roles. Write endpoints (POST, PUT,{" "} + DELETE) require admin.

GDPR Article 30 — Processing Registry

+

+ 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. +

- + - - + + + -

EU AI Act Classification

- - -

Risk level mapping:

+ + When you PUT an entry, the risk_level and{" "} + ai_act_answers fields are automatically carried over from the existing record. + Editing the registry never resets a previously computed classification. + + +

EU AI Act Classification

+ + +

The questionnaire uses 5 boolean keys (q1q5):

- - - + + {[ - { 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: "3–4", level: "High", desc: "Strict conformity assessment required before deployment. DPIA mandatory.", color: "text-orange-600" }, - { score: "1–2", 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) => ( + + + + + ))} + +
ScoreLevelDescriptionKeyQuestion
{row.key}{row.q}
+
+ +

Scoring: count the number of true answers.

+
+ + + + + + + + + + {[ + { 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: "3–4", level: "high", color: "text-orange-600", desc: "Évaluation de conformité obligatoire. AIPD/DPIA requise avant déploiement." }, + { score: "1–2", 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) => ( - + ))} @@ -112,31 +155,168 @@ export function AdminCompliancePage() {
Yes answersrisk_levelImplication
{row.score}{row.level}{row.level} {row.desc}
+ + + + +

PDF & JSON Reports

+

+ Reports stream directly as PDF (default) or JSON (?format=json). No polling + required — the response is the file. +

+ + + + + + + + + PDF reports display the organisation name configured in server.tenant_name{" "} + (config.yaml). Set this to your legal entity name for compliance documentation. + + +

Audit Log CSV Export

+ + + +

GDPR Subject Rights

Article 15 — Right of Access

- + -# Returns: audit entries, PII mapping references, user profile data`} +

Article 17 — Right to Erasure

- + - - 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. + + + + + The erasure endpoint sets users.is_active = false in PostgreSQL. ClickHouse + audit logs are append-only and cannot be deleted; request metadata (token counts, cost, + timestamps) is retained for compliance reporting. The gdpr_erasure_log table + provides an immutable audit trail of all erasure actions.
); diff --git a/web/src/pages/docs/api-reference/AdminLogsPage.tsx b/web/src/pages/docs/api-reference/AdminLogsPage.tsx index 0849ced..c01744d 100644 --- a/web/src/pages/docs/api-reference/AdminLogsPage.tsx +++ b/web/src/pages/docs/api-reference/AdminLogsPage.tsx @@ -8,125 +8,302 @@ export function AdminLogsPage() {

Admin — Audit Logs & Costs

- 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).

Audit Logs

- + + + - 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. + + The prompt_anonymized 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. + + +

Audit Entry Fields

+
+ + + + + + + + + + {[ + { 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) => ( + + + + + + ))} + +
FieldTypeDescription
{row.field}{row.type}{row.desc}
+
+

Cost Breakdown

- + + + -

Rate Limit Overrides

- - +

CSV Export

+ + + + + + +

Rate Limit Configuration

+

+ Per-tenant rate limits override the global defaults configured in{" "} + rate_limit.default_tenant_rpm. +

+ + + + + + + +
); diff --git a/web/src/pages/docs/api-reference/AuthenticationPage.tsx b/web/src/pages/docs/api-reference/AuthenticationPage.tsx index 6836d3b..783c10d 100644 --- a/web/src/pages/docs/api-reference/AuthenticationPage.tsx +++ b/web/src/pages/docs/api-reference/AuthenticationPage.tsx @@ -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 (

Authentication

- All /v1/* endpoints require a Bearer JWT in the{" "} - Authorization 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 /v1/* requests.

-

Bearer Token

+

Login — Obtain a 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" + }'`} /> -

Development Mode

- - When server.env=development and Keycloak is unreachable, the proxy uses a{" "} - MockVerifier. Any non-empty Bearer token is accepted. The authenticated user - is injected as admin@veylant.dev with admin role and tenant ID{" "} - dev-tenant. - - -

Production: Keycloak OIDC Flow

-

In production, clients obtain a token via the standard OIDC Authorization Code flow:

-
    -
  1. Redirect user to Keycloak login page
  2. -
  3. User authenticates; Keycloak redirects back with an authorization code
  4. -
  5. Exchange code for tokens at the token endpoint
  6. -
  7. Use the access_token as the Bearer token
  8. -
- + + Tokens are valid for the duration configured in auth.jwt_ttl_hours (default:{" "} + 24 hours). The frontend automatically logs the user out when the token expires. + + +

Using the Token

+

+ Pass the token in the Authorization header on every protected request: +

+ + + +

JWT Claims

-

The proxy extracts the following claims from the JWT:

+

+ Tokens are HS256-signed. The proxy extracts the following claims on each request: +

- + {[ - { 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) => ( - + ))} @@ -91,21 +142,37 @@ curl -X POST \\
ClaimSourceType Description
{row.claim}{row.source}{row.type} {row.desc}
-

Pre-configured Test Users

-

The Keycloak realm export includes these users for testing:

+

Pre-configured Dev Users

+

+ The dev stack seeds two users in the users table (migration 000010): +

+

Auth Configuration

+ + + Set VEYLANT_AUTH_JWT_SECRET in production to a random 32-byte value. Rotating + the secret immediately invalidates all active sessions. + +

Auth Error Responses

Authentication errors always return OpenAI-format JSON:

diff --git a/web/src/pages/docs/getting-started/QuickStartPage.tsx b/web/src/pages/docs/getting-started/QuickStartPage.tsx index dbd9191..e5847ae 100644 --- a/web/src/pages/docs/getting-started/QuickStartPage.tsx +++ b/web/src/pages/docs/getting-started/QuickStartPage.tsx @@ -12,8 +12,7 @@ export function QuickStartPage() { You need Docker 24+ and Docker Compose v2 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.

Step 1 — Clone the repository

@@ -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`} /> - + In server.env=development (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{" "} + pii.fail_open=true. The proxy stays up even if some services haven't + initialized yet.

Step 3 — Start the stack

@@ -54,18 +56,19 @@ ANTHROPIC_API_KEY=sk-ant-...`} docker compose up --build`} />

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

- Wait for the proxy to print server listening on :8090. First startup takes - ~2 minutes while Keycloak initializes and database migrations run. + Wait for the proxy to print{" "} + Veylant IA proxy started addr=:8090. First startup takes ~60 seconds while + PostgreSQL runs migrations.

Step 4 — Verify the stack

@@ -82,12 +85,12 @@ curl http://localhost:8090/healthz {[ { 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) => ( {row.service} @@ -101,62 +104,87 @@ curl http://localhost:8090/healthz -

Step 5 — Make your first AI call

+

Step 5 — Authenticate and make your first AI call

- In development mode, the proxy uses a mock JWT verifier. Pass any Bearer token and the - request will be authenticated as admin@veylant.dev. + Log in to get a JWT token, then use it as a Bearer token on all /v1/*{" "} + requests.

-

Or use the OpenAI Python SDK with a changed base URL:

+

Or use the OpenAI Python SDK by simply changing the base URL:

+ + Veylant IA is 100% OpenAI-API compatible. Point any existing SDK (Python, Node.js, Go, + Rust…) to http://localhost:8090/v1 and pass the JWT as the API key. + +

Step 6 — Explore the dashboard

- Open http://localhost:3000 to see the React dashboard. In development mode, - you're automatically logged in as Dev Admin. You'll see: + Open http://localhost:3000 and log in with{" "} + admin@veylant.dev / admin123. You'll find:

  • - Overview — request counts, costs, and tokens consumed + Vue d'ensemble — request counts, costs, tokens consumed, volume chart + (7d/30d)
  • Playground IA — test prompts with live PII detection visualization
  • - Policies — create and manage routing rules + Politiques — create and manage routing rules
  • - Compliance — GDPR Article 30 registry and AI Act questionnaire + Conformité — GDPR Article 30 registry and EU AI Act questionnaire +
  • +
  • + Fournisseurs — configure LLM providers with encrypted API key storage
- Try creating a routing rule in the dashboard that sends all requests from the{" "} - legal department to Anthropic instead of OpenAI. See{" "} + Try creating a routing rule that sends all requests from the Legal department + to Anthropic instead of OpenAI. See{" "} Routing Rules Engine for the full guide. diff --git a/web/src/pages/docs/guides/ComplianceGuide.tsx b/web/src/pages/docs/guides/ComplianceGuide.tsx index b58d802..5714e6d 100644 --- a/web/src/pages/docs/guides/ComplianceGuide.tsx +++ b/web/src/pages/docs/guides/ComplianceGuide.tsx @@ -7,16 +7,16 @@ export function ComplianceGuide() {

GDPR & EU AI Act Compliance

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

GDPR Article 30 — Record of Processing Activities

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.

Required ROPA Fields

@@ -31,14 +31,14 @@ export function ComplianceGuide() { {[ - { 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) => ( {row.field} @@ -50,7 +50,7 @@ export function ComplianceGuide() {
- +
  • consent — User has given explicit consent (Art. 6(1)(a))
  • contract — Processing necessary for a contract (Art. 6(1)(b))
  • @@ -62,39 +62,40 @@ export function ComplianceGuide() {

    EU AI Act Risk Classification

    - 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 + (q1q5), scoring each true answer as +1.

    {[ { - 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 3–4", - 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 1–2", - 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) => (
    @@ -114,28 +115,49 @@ export function ComplianceGuide() {

    Data Protection Impact Assessment (DPIA)

    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 (risk_level: "high") also trigger DPIA requirements. + Veylant IA generates a DPIA template PDF from any processing entry.

    -

    Compliance Reports

    -

    Available report formats via the API:

    -# GDPR Article 30 registry — JSON export -GET /v1/admin/compliance/reports/art30.json +

    Compliance Reports

    +

    All reports are available as PDF (default) or JSON (?format=json):

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

    Working with Compliance

    + + PDF headers display the organisation name from server.tenant_name in{" "} + config.yaml. Set this to your legal entity name before generating official + compliance documents. + + +

    GDPR Subject Rights Workflow

    - See the Admin — Compliance API 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: +

    + + +

    Next Steps

    +

    + See the Admin — Compliance API for + full endpoint documentation with request/response schemas, or navigate to{" "} Dashboard → Compliance to use the visual interface.

    diff --git a/web/src/pages/docs/guides/RbacGuide.tsx b/web/src/pages/docs/guides/RbacGuide.tsx index a559ba7..f934c1f 100644 --- a/web/src/pages/docs/guides/RbacGuide.tsx +++ b/web/src/pages/docs/guides/RbacGuide.tsx @@ -6,8 +6,9 @@ export function RbacGuide() {

    RBAC & Permissions

    - 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{" "} + users 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.

    Roles

    @@ -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) => (
    @@ -57,17 +58,20 @@ export function RbacGuide() { {[ + { 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) => ( {row.ep} @@ -98,7 +102,7 @@ rbac: /> - + The admin and manager roles have unrestricted model access —{" "} user_allowed_models does not apply to them. -

    Setting Up Roles in Keycloak

    -

    Assign roles to users in Keycloak:

    -
      -
    1. Log in to Keycloak Admin Console (http://localhost:8080, admin/admin)
    2. -
    3. Go to Realm: veylantUsers
    4. -
    5. Select a user → Role MappingsRealm Roles
    6. -
    7. Assign one of: admin, manager, user, auditor
    8. -
    +

    Managing User Roles via the Admin API

    +

    + Roles are managed through the /v1/admin/users endpoints. After updating a + user's role, they must log in again to receive a new token with the updated claims. +

    + + + + + 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 exp claim + (controlled by auth.jwt_ttl_hours). + + +

    Bulk User Import

    +

    + For tenant onboarding, use the provided script to bulk-import users from a CSV file: +

    + 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`} + />
    ); } diff --git a/web/src/pages/docs/installation/ProvidersPage.tsx b/web/src/pages/docs/installation/ProvidersPage.tsx index 91bfce1..e8eb6a5 100644 --- a/web/src/pages/docs/installation/ProvidersPage.tsx +++ b/web/src/pages/docs/installation/ProvidersPage.tsx @@ -84,6 +84,79 @@ ollama pull codellama`} deploy.resources.reservations.devices key in docker-compose.yml. +

    Managing Providers via the Admin API

    +

    + In addition to static config.yaml configuration, providers can be added, + updated, and deleted at runtime via the admin API. API keys are stored encrypted + (AES-256-GCM) in the provider_configs table and hot-reloaded without a proxy + restart. +

    + + + + + Provider changes via the admin API take effect immediately via{" "} + router.UpdateAdapter() / RemoveAdapter(). Existing in-flight + requests are not interrupted. + + + + Providers defined in config.yaml 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. + + +

    Azure OpenAI — Additional Fields

    +

    + Azure requires a resource name and deployment ID instead of a model name: +

    + +

    Check Provider Status

    The admin API exposes circuit breaker state for all providers: @@ -108,8 +181,8 @@ ollama pull codellama`}

    Circuit breaker states:

    • closed — Normal operation, requests forwarded
    • -
    • open — Provider bypassed, fallback chain used
    • -
    • half-open — Testing if provider has recovered
    • +
    • open — Provider bypassed after 5 consecutive failures (60s TTL)
    • +
    • half-open — Testing if provider has recovered (one probe request)
    );