fix
This commit is contained in:
parent
3051f71edd
commit
279e8f88c3
13
CLAUDE.md
13
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/proxy/ Core request handler (PII → upstream → audit → response)
|
||||||
├── internal/apierror/ OpenAI-format error helpers (WriteError, WriteErrorWithRequestID)
|
├── internal/apierror/ OpenAI-format error helpers (WriteError, WriteErrorWithRequestID)
|
||||||
├── internal/health/ /healthz, /docs, /playground, /playground/analyze handlers
|
├── internal/health/ /healthz, /docs, /playground, /playground/analyze handlers
|
||||||
|
├── internal/notifications/ SMTP email notifications — budget alert emails (POST /v1/notifications/send)
|
||||||
└── internal/config/ Viper-based config loader (VEYLANT_* env var overrides)
|
└── internal/config/ Viper-based config loader (VEYLANT_* env var overrides)
|
||||||
│ gRPC (<2ms) to localhost:50051
|
│ gRPC (<2ms) to localhost:50051
|
||||||
PII Detection Service [services/pii] — FastAPI + grpc.aio
|
PII Detection Service [services/pii] — FastAPI + grpc.aio
|
||||||
@ -136,8 +137,20 @@ pytest services/pii/tests/test_file.py::test_function
|
|||||||
|
|
||||||
**Provider configs:** LLM provider API keys are stored encrypted (AES-256-GCM) in the `provider_configs` table (migration 000011). CRUD via `GET|POST /v1/admin/providers`, `PUT|DELETE|POST-test /v1/admin/providers/{id}`. Adapters hot-reload on save/update without proxy restart (`router.UpdateAdapter()` / `RemoveAdapter()`).
|
**Provider configs:** LLM provider API keys are stored encrypted (AES-256-GCM) in the `provider_configs` table (migration 000011). CRUD via `GET|POST /v1/admin/providers`, `PUT|DELETE|POST-test /v1/admin/providers/{id}`. Adapters hot-reload on save/update without proxy restart (`router.UpdateAdapter()` / `RemoveAdapter()`).
|
||||||
|
|
||||||
|
**Email notifications:** Budget alert emails via SMTP. Configured with `notifications.smtp.host/port/username/password/from/from_name`. Endpoint `POST /v1/notifications/send` (JWT required) — called by the frontend when a budget threshold is crossed. Disabled gracefully if SMTP config is absent. Email body is French HTML.
|
||||||
|
|
||||||
**`admin.Handler` builder pattern:** The handler is wired via method chaining — `adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)`. All `With*` methods are optional; missing dependencies disable their feature.
|
**`admin.Handler` builder pattern:** The handler is wired via method chaining — `adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)`. All `With*` methods are optional; missing dependencies disable their feature.
|
||||||
|
|
||||||
|
**`compliance.Handler` builder pattern:** `compliance.New(compStore, logger).WithAudit(auditLogger).WithDB(db).WithTenantName(cfg.Server.TenantName)`. `WithAudit` is required for GDPR access/erase and CSV export; `WithDB` is required for Art.17 erasure log; `WithTenantName` sets the name shown in PDF headers (config key: `server.tenant_name`). PDF reports use the `go-pdf/fpdf` library. Compliance endpoints are mounted at `/v1/admin/compliance/`:
|
||||||
|
- `GET/POST /entries`, `GET/PUT/DELETE /entries/{id}` — GDPR Art.30 processing registry
|
||||||
|
- `POST /entries/{id}/classify` — AI Act risk classification (answers map → `ScoreRisk()`)
|
||||||
|
- `GET /report/article30`, `GET /report/aiact`, `GET /dpia/{id}` — PDF reports (`?format=json` returns JSON instead)
|
||||||
|
- `GET /gdpr/access/{user_id}` — GDPR Art.15 (returns audit log for that user)
|
||||||
|
- `DELETE /gdpr/erase/{user_id}` — GDPR Art.17 soft-delete (`users.is_active=FALSE`); logged to `gdpr_erasure_log` table
|
||||||
|
- `GET /export/logs` — CSV export (up to 10,000 rows)
|
||||||
|
|
||||||
|
**Audit log date filtering:** The `GET /v1/admin/logs` and `/export/logs` endpoints accept both `RFC3339Nano` (JavaScript `toISOString()` format, e.g. `2026-03-10T11:30:00.000Z`) and `RFC3339` for `start`/`end` query params. The export endpoint additionally accepts `YYYY-MM-DD` date-only strings (date input HTML element); when end is date-only, the full day (23:59:59) is included automatically.
|
||||||
|
|
||||||
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
|
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
|
||||||
|
|
||||||
**Tenant onboarding** (after `make dev`):
|
**Tenant onboarding** (after `make dev`):
|
||||||
|
|||||||
@ -334,12 +334,12 @@ func main() {
|
|||||||
// Public — CORS applied, no auth required.
|
// Public — CORS applied, no auth required.
|
||||||
r.Post("/auth/login", loginHandler.ServeHTTP)
|
r.Post("/auth/login", loginHandler.ServeHTTP)
|
||||||
|
|
||||||
// Protected — JWT auth + tenant rate limit.
|
// Protected — JWT auth required for all routes below.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Auth(jwtVerifier))
|
r.Use(middleware.Auth(jwtVerifier))
|
||||||
r.Use(middleware.RateLimit(rateLimiter))
|
|
||||||
|
|
||||||
r.Post("/chat/completions", proxyHandler.ServeHTTP)
|
// Rate limit applied only to the LLM proxy — not to admin/dashboard API calls.
|
||||||
|
r.With(middleware.RateLimit(rateLimiter)).Post("/chat/completions", proxyHandler.ServeHTTP)
|
||||||
|
|
||||||
// Email notification delivery — called by the frontend when a budget threshold is crossed.
|
// Email notification delivery — called by the frontend when a budget threshold is crossed.
|
||||||
if notifHandler != nil {
|
if notifHandler != nil {
|
||||||
|
|||||||
@ -342,13 +342,23 @@ func (h *Handler) getLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: parseIntParam(r, "limit", 50),
|
Limit: parseIntParam(r, "limit", 50),
|
||||||
Offset: parseIntParam(r, "offset", 0),
|
Offset: parseIntParam(r, "offset", 0),
|
||||||
}
|
}
|
||||||
|
// Accept both RFC3339Nano (JavaScript toISOString: "2026-03-10T11:30:00.000Z")
|
||||||
|
// and RFC3339 (API clients without sub-second precision).
|
||||||
|
parseTime := func(s string) time.Time {
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
if s := r.URL.Query().Get("start"); s != "" {
|
if s := r.URL.Query().Get("start"); s != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
if t := parseTime(s); !t.IsZero() {
|
||||||
q.StartTime = t
|
q.StartTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s := r.URL.Query().Get("end"); s != "" {
|
if s := r.URL.Query().Get("end"); s != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
if t := parseTime(s); !t.IsZero() {
|
||||||
q.EndTime = t
|
q.EndTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -379,13 +389,21 @@ func (h *Handler) getCosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
GroupBy: r.URL.Query().Get("group_by"),
|
GroupBy: r.URL.Query().Get("group_by"),
|
||||||
}
|
}
|
||||||
|
parseTime := func(s string) time.Time {
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
if s := r.URL.Query().Get("start"); s != "" {
|
if s := r.URL.Query().Get("start"); s != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
if t := parseTime(s); !t.IsZero() {
|
||||||
q.StartTime = t
|
q.StartTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s := r.URL.Query().Get("end"); s != "" {
|
if s := r.URL.Query().Get("end"); s != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
if t := parseTime(s); !t.IsZero() {
|
||||||
q.EndTime = t
|
q.EndTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,8 +84,10 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error)
|
|||||||
filtered = make([]AuditEntry, 0)
|
filtered = make([]AuditEntry, 0)
|
||||||
}
|
}
|
||||||
limit := q.Limit
|
limit := q.Limit
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
|
} else if limit > 10000 {
|
||||||
|
limit = 10000
|
||||||
}
|
}
|
||||||
if len(filtered) > limit {
|
if len(filtered) > limit {
|
||||||
filtered = filtered[:limit]
|
filtered = filtered[:limit]
|
||||||
|
|||||||
@ -245,6 +245,14 @@ func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Processors = []string{}
|
req.Processors = []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch existing entry to preserve AI Act classification (risk_level + ai_act_answers).
|
||||||
|
// Without this, every Registre edit would null out a previously saved classification.
|
||||||
|
existing, fetchErr := h.store.Get(r.Context(), id, tenantID)
|
||||||
|
if fetchErr != nil {
|
||||||
|
writeStoreError(w, fetchErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
entry := ProcessingEntry{
|
entry := ProcessingEntry{
|
||||||
ID: id,
|
ID: id,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
@ -258,6 +266,8 @@ func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
SecurityMeasures: req.SecurityMeasures,
|
SecurityMeasures: req.SecurityMeasures,
|
||||||
ControllerName: req.ControllerName,
|
ControllerName: req.ControllerName,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
|
RiskLevel: existing.RiskLevel,
|
||||||
|
AiActAnswers: existing.AiActAnswers,
|
||||||
}
|
}
|
||||||
updated, err := h.store.Update(r.Context(), entry)
|
updated, err := h.store.Update(r.Context(), entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -455,11 +465,22 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
targetUser := chi.URLParam(r, "user_id")
|
targetUser := chi.URLParam(r, "user_id")
|
||||||
reason := r.URL.Query().Get("reason")
|
|
||||||
requestedBy := userFrom(r)
|
requestedBy := userFrom(r)
|
||||||
|
|
||||||
|
// reason is sent as JSON body by the frontend; fall back to query param for API clients.
|
||||||
|
var reason string
|
||||||
|
var bodyReq struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&bodyReq); err == nil && bodyReq.Reason != "" {
|
||||||
|
reason = bodyReq.Reason
|
||||||
|
} else {
|
||||||
|
reason = r.URL.Query().Get("reason")
|
||||||
|
}
|
||||||
|
|
||||||
// Soft-delete user in users table
|
// Soft-delete user in users table
|
||||||
recordsDeleted := 0
|
recordsDeleted := 0
|
||||||
|
erasureID := ""
|
||||||
if h.db != nil {
|
if h.db != nil {
|
||||||
res, err := h.db.ExecContext(r.Context(),
|
res, err := h.db.ExecContext(r.Context(),
|
||||||
`UPDATE users SET is_active=FALSE, updated_at=NOW() WHERE email=$1 AND tenant_id=$2`,
|
`UPDATE users SET is_active=FALSE, updated_at=NOW() WHERE email=$1 AND tenant_id=$2`,
|
||||||
@ -472,12 +493,12 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
|
|||||||
recordsDeleted = int(n)
|
recordsDeleted = int(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log erasure (immutable)
|
// Log erasure (immutable) — read back generated UUID for the response.
|
||||||
_, logErr := h.db.ExecContext(r.Context(),
|
logErr := h.db.QueryRowContext(r.Context(),
|
||||||
`INSERT INTO gdpr_erasure_log (tenant_id, target_user, requested_by, reason, records_deleted)
|
`INSERT INTO gdpr_erasure_log (tenant_id, target_user, requested_by, reason, records_deleted)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
tenantID, targetUser, requestedBy, reason, recordsDeleted,
|
tenantID, targetUser, requestedBy, reason, recordsDeleted,
|
||||||
)
|
).Scan(&erasureID)
|
||||||
if logErr != nil {
|
if logErr != nil {
|
||||||
h.logger.Error("GDPR erase: failed to write erasure log", zap.Error(logErr))
|
h.logger.Error("GDPR erase: failed to write erasure log", zap.Error(logErr))
|
||||||
}
|
}
|
||||||
@ -490,6 +511,7 @@ func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, ErasureRecord{
|
writeJSON(w, http.StatusOK, ErasureRecord{
|
||||||
|
ID: erasureID,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
TargetUser: targetUser,
|
TargetUser: targetUser,
|
||||||
RequestedBy: requestedBy,
|
RequestedBy: requestedBy,
|
||||||
@ -519,13 +541,26 @@ func (h *Handler) exportLogsCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
Provider: r.URL.Query().Get("provider"),
|
Provider: r.URL.Query().Get("provider"),
|
||||||
Limit: 10000,
|
Limit: 10000,
|
||||||
}
|
}
|
||||||
|
// Accept both RFC3339 (API clients) and date-only YYYY-MM-DD (HTML date input from frontend).
|
||||||
|
parseDate := func(s string) time.Time {
|
||||||
|
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
if s := r.URL.Query().Get("start"); s != "" {
|
if s := r.URL.Query().Get("start"); s != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
if t := parseDate(s); !t.IsZero() {
|
||||||
q.StartTime = t
|
q.StartTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s := r.URL.Query().Get("end"); s != "" {
|
if s := r.URL.Query().Get("end"); s != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
if t := parseDate(s); !t.IsZero() {
|
||||||
|
// For date-only end, include the full day (end at 23:59:59).
|
||||||
|
if len(s) == 10 {
|
||||||
|
t = t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||||
|
}
|
||||||
q.EndTime = t
|
q.EndTime = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -191,14 +191,15 @@ func GenerateArticle30(entries []ProcessingEntry, tenantName string, w io.Writer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(allProcessors) == 0 {
|
if len(allProcessors) == 0 {
|
||||||
allProcessors["OpenAI (GPT-4o)"] = true
|
setFont(pdf, "I", 9, colGray)
|
||||||
allProcessors["Anthropic (Claude)"] = true
|
pdf.CellFormat(0, 6, "Aucun sous-traitant déclaré.", "", 1, "L", false, 0, "")
|
||||||
}
|
} else {
|
||||||
for proc := range allProcessors {
|
for proc := range allProcessors {
|
||||||
setFont(pdf, "", 9, colBlack)
|
setFont(pdf, "", 9, colBlack)
|
||||||
pdf.CellFormat(5, 6, "•", "", 0, "L", false, 0, "")
|
pdf.CellFormat(5, 6, "•", "", 0, "L", false, 0, "")
|
||||||
pdf.CellFormat(0, 6, proc+" — fournisseur LLM (sous-traitant au sens de l'Art. 28 RGPD)", "", 1, "L", false, 0, "")
|
pdf.CellFormat(0, 6, proc+" — fournisseur LLM (sous-traitant au sens de l'Art. 28 RGPD)", "", 1, "L", false, 0, "")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Section 4 — Durées de conservation
|
// Section 4 — Durées de conservation
|
||||||
sectionHeader(pdf, "4. Durées de Conservation")
|
sectionHeader(pdf, "4. Durées de Conservation")
|
||||||
|
|||||||
67
test_smtp.go
Normal file
67
test_smtp.go
Normal 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()
|
||||||
|
}
|
||||||
@ -21,11 +21,14 @@ function buildQueryString(params: Record<string, string | number | undefined>):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAuditLogs(params: AuditQueryParams = {}, refetchInterval?: number) {
|
export function useAuditLogs(params: AuditQueryParams = {}, refetchInterval?: number) {
|
||||||
|
// Use the serialized query string as the key — stable across re-renders as long
|
||||||
|
// as the param values don't change (avoids a new request on every millisecond).
|
||||||
const qs = buildQueryString(params as Record<string, string | number | undefined>);
|
const qs = buildQueryString(params as Record<string, string | number | undefined>);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["logs", params],
|
queryKey: ["logs", qs],
|
||||||
queryFn: () => apiFetch<AuditResult>(`/v1/admin/logs${qs}`),
|
queryFn: () => apiFetch<AuditResult>(`/v1/admin/logs${qs}`),
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
|
staleTime: 30_000, // don't refetch if data is less than 30s old
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +38,7 @@ export function useRequestCount() {
|
|||||||
queryKey: ["logs", "count"],
|
queryKey: ["logs", "count"],
|
||||||
queryFn: () => apiFetch<AuditResult>("/v1/admin/logs?limit=1&offset=0"),
|
queryFn: () => apiFetch<AuditResult>("/v1/admin/logs?limit=1&offset=0"),
|
||||||
select: (d) => d.total,
|
select: (d) => d.total,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,35 +18,41 @@ import { fr } from "date-fns/locale";
|
|||||||
|
|
||||||
type Range = "7d" | "30d";
|
type Range = "7d" | "30d";
|
||||||
|
|
||||||
|
// Rounds `end` UP to the start of the NEXT minute so:
|
||||||
|
// • all data from the current minute is included (no recent entries cut off)
|
||||||
|
// • the key string is identical for every render within the same 60-second window
|
||||||
|
// → React Query reuses the cached result, no per-render HTTP floods
|
||||||
function buildDateRange(range: Range) {
|
function buildDateRange(range: Range) {
|
||||||
const days = range === "7d" ? 7 : 30;
|
const days = range === "7d" ? 7 : 30;
|
||||||
const end = new Date();
|
const now = new Date();
|
||||||
|
// setSeconds(60, 0) rolls the clock forward to the next whole minute in JS.
|
||||||
|
// e.g. 18:48:34.713 → 18:49:00.000
|
||||||
|
now.setSeconds(60, 0);
|
||||||
|
const end = now;
|
||||||
const start = subDays(end, days);
|
const start = subDays(end, days);
|
||||||
return { start: start.toISOString(), end: end.toISOString() };
|
return { start: start.toISOString(), end: end.toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VolumeChart() {
|
export function VolumeChart() {
|
||||||
const [range, setRange] = useState<Range>("7d");
|
const [range, setRange] = useState<Range>("7d");
|
||||||
const { start, end } = buildDateRange(range);
|
|
||||||
const days = range === "7d" ? 7 : 30;
|
const days = range === "7d" ? 7 : 30;
|
||||||
|
|
||||||
const { data, isLoading } = useAuditLogs(
|
const { start, end } = buildDateRange(range);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useAuditLogs(
|
||||||
{ start, end, limit: 1000 },
|
{ start, end, limit: 1000 },
|
||||||
30_000
|
60_000 // 60s — halves request frequency vs previous 30s
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!data?.data) return [];
|
// Pre-fill all days in range with zeros regardless of API result.
|
||||||
|
|
||||||
// Group by day
|
|
||||||
const map = new Map<string, { requests: number; errors: number }>();
|
const map = new Map<string, { requests: number; errors: number }>();
|
||||||
|
|
||||||
// Pre-fill all days in range
|
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
|
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
|
||||||
map.set(d, { requests: 0, errors: 0 });
|
map.set(d, { requests: 0, errors: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data?.data) {
|
||||||
for (const entry of data.data) {
|
for (const entry of data.data) {
|
||||||
const day = format(startOfDay(parseISO(entry.timestamp)), "yyyy-MM-dd");
|
const day = format(startOfDay(parseISO(entry.timestamp)), "yyyy-MM-dd");
|
||||||
const existing = map.get(day) ?? { requests: 0, errors: 0 };
|
const existing = map.get(day) ?? { requests: 0, errors: 0 };
|
||||||
@ -54,6 +60,7 @@ export function VolumeChart() {
|
|||||||
if (entry.status === "error") existing.errors++;
|
if (entry.status === "error") existing.errors++;
|
||||||
map.set(day, existing);
|
map.set(day, existing);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Array.from(map.entries()).map(([date, stats]) => ({
|
return Array.from(map.entries()).map(([date, stats]) => ({
|
||||||
date: format(parseISO(date), "dd/MM", { locale: fr }),
|
date: format(parseISO(date), "dd/MM", { locale: fr }),
|
||||||
@ -62,6 +69,8 @@ export function VolumeChart() {
|
|||||||
}));
|
}));
|
||||||
}, [data, days]);
|
}, [data, days]);
|
||||||
|
|
||||||
|
const hasActivity = chartData.some((d) => d.Requêtes > 0 || d.Erreurs > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
@ -86,6 +95,15 @@ export function VolumeChart() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-64 w-full" />
|
<Skeleton className="h-64 w-full" />
|
||||||
|
) : isError ? (
|
||||||
|
<div className="h-64 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Impossible de charger les données.
|
||||||
|
</div>
|
||||||
|
) : !hasActivity ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<p className="text-sm">Aucune requête sur les {days} derniers jours.</p>
|
||||||
|
<p className="text-xs">Les données apparaîtront après le premier appel via le proxy.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={256}>
|
<ResponsiveContainer width="100%" height={256}>
|
||||||
<LineChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
|
<LineChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
|
||||||
@ -95,22 +113,24 @@ export function VolumeChart() {
|
|||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} className="text-muted-foreground" />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="Requêtes"
|
dataKey="Requêtes"
|
||||||
stroke="hsl(222.2 47.4% 11.2%)"
|
stroke="#1e3a5f"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="Erreurs"
|
dataKey="Erreurs"
|
||||||
stroke="hsl(0 84.2% 60.2%)"
|
stroke="#ef4444"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { fr } from "date-fns/locale";
|
|||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount();
|
const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount();
|
||||||
const { data: costData, isLoading: costsLoading } = useCosts({}, 30_000);
|
const { data: costData, isLoading: costsLoading } = useCosts({}, 60_000);
|
||||||
|
|
||||||
const totalCost = costData?.data?.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0;
|
const totalCost = costData?.data?.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0;
|
||||||
const totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;
|
const totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;
|
||||||
|
|||||||
@ -8,103 +8,146 @@ export function AdminCompliancePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 id="admin-compliance">Admin — Compliance</h1>
|
<h1 id="admin-compliance">Admin — Compliance</h1>
|
||||||
<p>
|
<p>
|
||||||
GDPR Article 30 processing registry, EU AI Act risk classification, and data subject
|
GDPR Article 30 processing registry, EU AI Act risk classification, DPIA generation, PDF
|
||||||
rights (access and erasure).
|
reports, CSV export, and GDPR subject rights (access and erasure). All endpoints are mounted
|
||||||
|
under <code>/v1/admin/compliance</code> and require a valid JWT.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Callout type="info" title="Required role">
|
<Callout type="info" title="Required role">
|
||||||
Compliance endpoints require <code>admin</code> role. Auditors can read but not modify.
|
Read endpoints (<code>GET</code>) are accessible to <code>admin</code> and{" "}
|
||||||
|
<code>auditor</code> roles. Write endpoints (<code>POST</code>, <code>PUT</code>,{" "}
|
||||||
|
<code>DELETE</code>) require <code>admin</code>.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<h2 id="processing-registry">GDPR Article 30 — Processing Registry</h2>
|
<h2 id="processing-registry">GDPR Article 30 — Processing Registry</h2>
|
||||||
|
<p>
|
||||||
|
Every AI use case that may process personal data must be documented in the registry. The
|
||||||
|
compliance module generates the legally required Article 30 register directly from these
|
||||||
|
entries.
|
||||||
|
</p>
|
||||||
|
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/compliance/entries" description="List all processing activities in the GDPR Art. 30 registry." />
|
<ApiEndpoint method="GET" path="/v1/admin/compliance/entries" description="List all processing activities for the authenticated tenant." />
|
||||||
<ApiEndpoint method="POST" path="/v1/admin/compliance/entries" description="Create a new processing activity entry." />
|
<ApiEndpoint method="POST" path="/v1/admin/compliance/entries" description="Create a new processing activity entry." />
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/compliance/entries/{id}" description="Get a specific processing entry." />
|
<ApiEndpoint method="GET" path="/v1/admin/compliance/entries/{id}" description="Get a specific processing entry by ID." />
|
||||||
<ApiEndpoint method="PUT" path="/v1/admin/compliance/entries/{id}" description="Update a processing activity entry." />
|
<ApiEndpoint method="PUT" path="/v1/admin/compliance/entries/{id}" description="Update a processing activity. AI Act classification (risk_level, ai_act_answers) is preserved automatically." />
|
||||||
|
<ApiEndpoint method="DELETE" path="/v1/admin/compliance/entries/{id}" description="Soft-delete a processing entry (is_active = false)." />
|
||||||
|
|
||||||
<ParamTable
|
<ParamTable
|
||||||
title="Processing Entry Fields"
|
title="Processing Entry Fields"
|
||||||
params={[
|
params={[
|
||||||
{ name: "use_case_name", type: "string", required: true, description: "Name of the AI use case (e.g. 'Legal contract analysis')." },
|
{ name: "use_case_name", type: "string", required: true, description: "Name of the AI use case (e.g. 'Legal contract analysis')." },
|
||||||
{ name: "purpose", type: "string", required: true, description: "Processing purpose as required by GDPR Art. 5." },
|
{ name: "purpose", type: "string", required: true, description: "Processing purpose as required by GDPR Art. 5(1)(b)." },
|
||||||
{ name: "legal_basis", type: "string", required: true, description: "Legal basis: legitimate_interest | contract | legal_obligation | consent | vital_interests | public_task" },
|
{ name: "legal_basis", type: "string", required: true, description: "Legal basis: consent | contract | legal_obligation | vital_interests | public_task | legitimate_interest" },
|
||||||
{ name: "data_categories", type: "[]string", required: true, description: "Categories of personal data: name, email, phone, id_number, financial, health, etc." },
|
{ name: "data_categories", type: "[]string", required: true, description: "Categories of personal data: name, email, phone, id_number, financial, health, biometric, etc." },
|
||||||
{ name: "retention_period", type: "string", required: true, description: "Data retention period (e.g. '3 years', '90 days')." },
|
{ name: "retention_period", type: "string", required: true, description: "Data retention period (e.g. '3 years', '90 days', '6 months after contract end')." },
|
||||||
{ name: "security_measures", type: "string", required: true, description: "Technical and organizational security measures." },
|
{ name: "security_measures", type: "string", required: false, description: "Technical and organizational security measures in place." },
|
||||||
{ name: "controller_name", type: "string", required: true, description: "Data controller name and contact." },
|
{ name: "controller_name", type: "string", required: false, description: "Data controller name and DPO contact." },
|
||||||
{ name: "recipients", type: "[]string", required: false, description: "Third parties receiving the data." },
|
{ name: "recipients", type: "[]string", required: false, description: "Third parties receiving the data (e.g. 'OpenAI via Veylant IA proxy')." },
|
||||||
{ name: "processors", type: "[]string", required: false, description: "Data processors (LLM providers, cloud services)." },
|
{ name: "processors", type: "[]string", required: false, description: "Sub-processors involved in the processing." },
|
||||||
{ name: "dpia_required", type: "boolean", required: false, default: "false", description: "Whether a DPIA (Art. 35) is required." },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl -X POST http://localhost:8090/v1/admin/compliance/entries \\
|
code={`# Create a processing entry
|
||||||
|
curl -X POST http://localhost:8090/v1/admin/compliance/entries \\
|
||||||
-H "Authorization: Bearer $TOKEN" \\
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{
|
-d '{
|
||||||
"use_case_name": "Legal contract analysis",
|
"use_case_name": "Analyse de contrats fournisseurs",
|
||||||
"purpose": "Automated review of supplier contracts for risk identification",
|
"purpose": "Identification automatique des risques dans les contrats fournisseurs",
|
||||||
"legal_basis": "legitimate_interest",
|
"legal_basis": "legitimate_interest",
|
||||||
"data_categories": ["name", "financial", "company_data"],
|
"data_categories": ["name", "financial", "company_data"],
|
||||||
"retention_period": "3 years",
|
"retention_period": "3 ans",
|
||||||
"security_measures": "AES-256-GCM encryption, PII anonymization, audit logs",
|
"security_measures": "Chiffrement AES-256-GCM, anonymisation PII, logs d'\''audit immuables",
|
||||||
"controller_name": "Acme Corp — dpo@acme.com",
|
"controller_name": "Acme Corp — dpo@acme.com",
|
||||||
"processors": ["Anthropic (Claude via Veylant IA proxy)"]
|
"processors": ["Anthropic (Claude via Veylant IA proxy)"],
|
||||||
|
"recipients": []
|
||||||
}'`}
|
}'`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2 id="ai-act">EU AI Act Classification</h2>
|
|
||||||
<ApiEndpoint method="POST" path="/v1/admin/compliance/entries/{id}/classify" description="Run the AI Act risk questionnaire for a processing entry and classify the risk level." />
|
|
||||||
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="json"
|
||||||
code={`curl -X POST http://localhost:8090/v1/admin/compliance/entries/entry-uuid/classify \\
|
code={`// 201 — Entry created
|
||||||
-H "Authorization: Bearer $TOKEN" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"autonomous_decisions": false,
|
|
||||||
"biometric_data": false,
|
|
||||||
"critical_infrastructure": false,
|
|
||||||
"sensitive_data": true,
|
|
||||||
"transparency_required": true
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Response:
|
|
||||||
{
|
{
|
||||||
"risk_level": "limited",
|
"id": "ce4f1234-0000-0000-0000-000000000001",
|
||||||
"score": 2,
|
"tenant_id": "dev-tenant",
|
||||||
"description": "Limited risk — transparency obligations apply. Users must be informed they are interacting with an AI system.",
|
"use_case_name": "Analyse de contrats fournisseurs",
|
||||||
"actions_required": [
|
"purpose": "Identification automatique des risques dans les contrats fournisseurs",
|
||||||
"Display AI disclosure in the user interface",
|
"legal_basis": "legitimate_interest",
|
||||||
"Document in GDPR Art. 30 registry",
|
"data_categories": ["name", "financial", "company_data"],
|
||||||
"Annual review recommended"
|
"retention_period": "3 ans",
|
||||||
]
|
"security_measures": "Chiffrement AES-256-GCM, anonymisation PII, logs d'audit immuables",
|
||||||
|
"controller_name": "Acme Corp — dpo@acme.com",
|
||||||
|
"processors": ["Anthropic (Claude via Veylant IA proxy)"],
|
||||||
|
"recipients": [],
|
||||||
|
"risk_level": "",
|
||||||
|
"ai_act_answers": null,
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-03-12T09:00:00Z",
|
||||||
|
"updated_at": "2026-03-12T09:00:00Z"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p>Risk level mapping:</p>
|
<Callout type="tip" title="AI Act classification is preserved on update">
|
||||||
|
When you <code>PUT</code> an entry, the <code>risk_level</code> and{" "}
|
||||||
|
<code>ai_act_answers</code> fields are automatically carried over from the existing record.
|
||||||
|
Editing the registry never resets a previously computed classification.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="ai-act">EU AI Act Classification</h2>
|
||||||
|
<ApiEndpoint
|
||||||
|
method="POST"
|
||||||
|
path="/v1/admin/compliance/entries/{id}/classify"
|
||||||
|
description="Run the 5-question AI Act risk questionnaire. Updates risk_level and ai_act_answers on the entry."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p>The questionnaire uses 5 boolean keys (<code>q1</code>–<code>q5</code>):</p>
|
||||||
<div className="overflow-x-auto my-4">
|
<div className="overflow-x-auto my-4">
|
||||||
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-muted/50 border-b">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Score</th>
|
<th className="text-left px-4 py-2.5 font-semibold">Key</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Level</th>
|
<th className="text-left px-4 py-2.5 font-semibold">Question</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Description</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
{ score: "5", level: "Forbidden", desc: "System must not be deployed. Example: social scoring, real-time biometric surveillance in public spaces.", color: "text-red-600" },
|
{ key: "q1", q: "Le système prend-il des décisions autonomes affectant des droits légaux ou des situations similaires ?" },
|
||||||
{ score: "3–4", level: "High", desc: "Strict conformity assessment required before deployment. DPIA mandatory.", color: "text-orange-600" },
|
{ key: "q2", q: "Implique-t-il une identification biométrique ou une reconnaissance des émotions ?" },
|
||||||
{ score: "1–2", level: "Limited", desc: "Transparency obligations: users must be informed they interact with AI.", color: "text-amber-600" },
|
{ key: "q3", q: "Est-il utilisé dans des décisions critiques (médical, justice, emploi, crédit) ?" },
|
||||||
{ score: "0", level: "Minimal", desc: "Minimal risk. Voluntary code of conduct recommended.", color: "text-green-600" },
|
{ key: "q4", q: "Traite-t-il des catégories spéciales de données (santé, biométrie, origine raciale) ?" },
|
||||||
|
{ key: "q5", q: "La transparence sur l'utilisation de l'IA est-elle indispensable au consentement éclairé ?" },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.key} className="border-b last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs font-bold">{row.key}</td>
|
||||||
|
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.q}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Scoring: count the number of <code>true</code> answers.</p>
|
||||||
|
<div className="overflow-x-auto my-4">
|
||||||
|
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">Yes answers</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">risk_level</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">Implication</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
{ score: "5", level: "forbidden", color: "text-red-600", desc: "Déploiement interdit (ex: notation sociale, surveillance biométrique en temps réel dans l'espace public)." },
|
||||||
|
{ score: "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) => (
|
].map((row) => (
|
||||||
<tr key={row.level} className="border-b last:border-0">
|
<tr key={row.level} className="border-b last:border-0">
|
||||||
<td className="px-4 py-2.5 font-mono text-xs">{row.score}</td>
|
<td className="px-4 py-2.5 font-mono text-xs">{row.score}</td>
|
||||||
<td className={`px-4 py-2.5 font-semibold text-xs ${row.color}`}>{row.level}</td>
|
<td className={`px-4 py-2.5 font-semibold text-xs font-mono ${row.color}`}>{row.level}</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
|
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -112,31 +155,168 @@ export function AdminCompliancePage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Classify an entry — chatbot answering FAQ (limited risk)
|
||||||
|
curl -X POST http://localhost:8090/v1/admin/compliance/entries/ce4f1234-0000-0000-0000-000000000001/classify \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"answers": {
|
||||||
|
"q1": false,
|
||||||
|
"q2": false,
|
||||||
|
"q3": false,
|
||||||
|
"q4": false,
|
||||||
|
"q5": true
|
||||||
|
}
|
||||||
|
}'`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
code={`// 200 — Returns the updated ProcessingEntry
|
||||||
|
{
|
||||||
|
"id": "ce4f1234-0000-0000-0000-000000000001",
|
||||||
|
"use_case_name": "Analyse de contrats fournisseurs",
|
||||||
|
"risk_level": "limited",
|
||||||
|
"ai_act_answers": {
|
||||||
|
"q1": false,
|
||||||
|
"q2": false,
|
||||||
|
"q3": false,
|
||||||
|
"q4": false,
|
||||||
|
"q5": true
|
||||||
|
},
|
||||||
|
"is_active": true,
|
||||||
|
...
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 id="reports">PDF & JSON Reports</h2>
|
||||||
|
<p>
|
||||||
|
Reports stream directly as PDF (default) or JSON (<code>?format=json</code>). No polling
|
||||||
|
required — the response is the file.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ApiEndpoint method="GET" path="/v1/admin/compliance/report/article30" description="GDPR Art. 30 register for the tenant. PDF by default, JSON with ?format=json." />
|
||||||
|
<ApiEndpoint method="GET" path="/v1/admin/compliance/report/aiact" description="EU AI Act risk classification report for all entries. PDF by default, JSON with ?format=json." />
|
||||||
|
<ApiEndpoint method="GET" path="/v1/admin/compliance/dpia/{id}" description="DPIA template PDF for a specific processing entry." />
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Download GDPR Art. 30 register as PDF
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/report/article30" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output article30_register.pdf
|
||||||
|
|
||||||
|
# Download as JSON (for custom reporting tools)
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/report/article30?format=json" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Download AI Act risk classification report as PDF
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/report/aiact" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output aiact_report.pdf
|
||||||
|
|
||||||
|
# Generate DPIA for a specific high-risk entry
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/dpia/ce4f1234-0000-0000-0000-000000000001" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output dpia_contract_analysis.pdf`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Callout type="tip" title="PDF headers use server.tenant_name">
|
||||||
|
PDF reports display the organisation name configured in <code>server.tenant_name</code>{" "}
|
||||||
|
(config.yaml). Set this to your legal entity name for compliance documentation.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="csv-export">Audit Log CSV Export</h2>
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/v1/admin/compliance/export/logs"
|
||||||
|
description="Export audit logs as CSV (max 10 000 rows). Accepts RFC3339 or YYYY-MM-DD date format for start/end."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Export all audit logs
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/export/logs" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output audit_export.csv
|
||||||
|
|
||||||
|
# Export for Q1 2026 using simple date format
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/export/logs?start=2026-01-01&end=2026-03-31" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output q1_2026_audit.csv`}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2 id="gdpr-rights">GDPR Subject Rights</h2>
|
<h2 id="gdpr-rights">GDPR Subject Rights</h2>
|
||||||
|
|
||||||
<h3 id="access">Article 15 — Right of Access</h3>
|
<h3 id="access">Article 15 — Right of Access</h3>
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/compliance/gdpr/access/{user_id}" description="Return all personal data stored for a specific user (prompt history, audit entries, pseudonymization mappings)." />
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/v1/admin/compliance/gdpr/access/{user_id}"
|
||||||
|
description="Return all audit log entries for a specific user (up to 1 000 records). Used to respond to GDPR Art. 15 subject access requests."
|
||||||
|
/>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl http://localhost:8090/v1/admin/compliance/gdpr/access/user-uuid \\
|
code={`curl "http://localhost:8090/v1/admin/compliance/gdpr/access/a1b2c3d4-0000-0000-0000-000000000001" \\
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"`}
|
||||||
|
/>
|
||||||
|
|
||||||
# Returns: audit entries, PII mapping references, user profile data`}
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
code={`// 200 — All audit entries for the user
|
||||||
|
{
|
||||||
|
"user_id": "a1b2c3d4-0000-0000-0000-000000000001",
|
||||||
|
"generated_at": "2026-03-12T10:00:00Z",
|
||||||
|
"total": 47,
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"request_id": "req_01HV...",
|
||||||
|
"timestamp": "2026-03-10T14:32:11Z",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"model_used": "claude-3-5-sonnet-20241022",
|
||||||
|
"sensitivity_level": "high",
|
||||||
|
"pii_entity_count": 3,
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h3 id="erasure">Article 17 — Right to Erasure</h3>
|
<h3 id="erasure">Article 17 — Right to Erasure</h3>
|
||||||
<ApiEndpoint method="POST" path="/v1/admin/compliance/gdpr/erase/{user_id}" description="Pseudonymize or delete all personal data for a user. Audit log metadata is retained but PII is scrubbed." />
|
<ApiEndpoint
|
||||||
|
method="DELETE"
|
||||||
|
path="/v1/admin/compliance/gdpr/erase/{user_id}"
|
||||||
|
description="Soft-delete the user account (is_active = false) and log the erasure request to the gdpr_erasure_log table."
|
||||||
|
/>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl -X POST http://localhost:8090/v1/admin/compliance/gdpr/erase/user-uuid \\
|
code={`curl -X DELETE "http://localhost:8090/v1/admin/compliance/gdpr/erase/a1b2c3d4-0000-0000-0000-000000000001" \\
|
||||||
-H "Authorization: Bearer $TOKEN" \\
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{"reason": "User request under GDPR Art. 17"}'`}
|
-d '{"reason": "Demande utilisateur conformément au RGPD Art. 17"}'`}
|
||||||
/>
|
/>
|
||||||
<Callout type="warning" title="ClickHouse is append-only">
|
|
||||||
ClickHouse audit logs cannot be deleted. The erasure endpoint scrubs PII from prompt
|
<CodeBlock
|
||||||
content and pseudonymizes user identifiers, but the request metadata (token counts, cost,
|
language="json"
|
||||||
timestamps) is retained for compliance reporting.
|
code={`// 200 — Erasure confirmed
|
||||||
|
{
|
||||||
|
"erasure_id": "er9a0000-0000-0000-0000-000000000042",
|
||||||
|
"tenant_id": "dev-tenant",
|
||||||
|
"user_id": "a1b2c3d4-0000-0000-0000-000000000001",
|
||||||
|
"requested_by": "admin@veylant.dev",
|
||||||
|
"reason": "Demande utilisateur conformément au RGPD Art. 17",
|
||||||
|
"records_deleted": 1,
|
||||||
|
"status": "completed",
|
||||||
|
"timestamp": "2026-03-12T10:05:00Z"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Callout type="warning" title="Soft-delete — ClickHouse logs are append-only">
|
||||||
|
The erasure endpoint sets <code>users.is_active = false</code> in PostgreSQL. ClickHouse
|
||||||
|
audit logs are append-only and cannot be deleted; request metadata (token counts, cost,
|
||||||
|
timestamps) is retained for compliance reporting. The <code>gdpr_erasure_log</code> table
|
||||||
|
provides an immutable audit trail of all erasure actions.
|
||||||
</Callout>
|
</Callout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,125 +8,302 @@ export function AdminLogsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 id="admin-logs">Admin — Audit Logs & Costs</h1>
|
<h1 id="admin-logs">Admin — Audit Logs & Costs</h1>
|
||||||
<p>
|
<p>
|
||||||
Query the immutable audit trail and cost breakdown for AI requests. All data is stored in
|
Query the immutable audit trail and cost breakdown for all AI requests. Data is stored in
|
||||||
ClickHouse (append-only — no DELETE operations).
|
ClickHouse (append-only — no DELETE operations). In development without ClickHouse, an
|
||||||
|
in-memory fallback is used (data is lost on restart).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="audit-logs">Audit Logs</h2>
|
<h2 id="audit-logs">Audit Logs</h2>
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/logs" description="Query audit log entries with optional filters." />
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/v1/admin/logs"
|
||||||
|
description="Query audit log entries with optional filters. Returns paginated results."
|
||||||
|
/>
|
||||||
|
|
||||||
<ParamTable
|
<ParamTable
|
||||||
title="Query Parameters"
|
title="Query Parameters"
|
||||||
params={[
|
params={[
|
||||||
{ name: "user_id", type: "string", required: false, description: "Filter by user UUID." },
|
|
||||||
{ name: "provider", type: "string", required: false, description: "Filter by provider: openai | anthropic | azure | mistral | ollama" },
|
{ name: "provider", type: "string", required: false, description: "Filter by provider: openai | anthropic | azure | mistral | ollama" },
|
||||||
{ name: "model", type: "string", required: false, description: "Filter by model used." },
|
{ name: "min_sensitivity", type: "string", required: false, description: "Minimum PII sensitivity level: none | low | medium | high | critical" },
|
||||||
{ name: "from", type: "string (ISO 8601)", required: false, description: "Start of time range, e.g. 2026-01-01T00:00:00Z" },
|
{ name: "start", type: "string (RFC3339 or RFC3339Nano)", required: false, description: "Start of time range. Accepts JavaScript toISOString() format (e.g. 2026-03-01T00:00:00.000Z) or standard RFC3339." },
|
||||||
{ name: "to", type: "string (ISO 8601)", required: false, description: "End of time range." },
|
{ name: "end", type: "string (RFC3339 or RFC3339Nano)", required: false, description: "End of time range. Same format as start." },
|
||||||
{ name: "has_pii", type: "boolean", required: false, description: "Filter entries where PII was detected." },
|
{ name: "limit", type: "integer", required: false, default: "50", description: "Max entries to return (max 10 000)." },
|
||||||
{ name: "limit", type: "integer", required: false, default: "50", description: "Max entries to return (max 1000)." },
|
|
||||||
{ name: "offset", type: "integer", required: false, default: "0", description: "Pagination offset." },
|
{ name: "offset", type: "integer", required: false, default: "0", description: "Pagination offset." },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl "http://localhost:8090/v1/admin/logs?from=2026-01-01T00:00:00Z&has_pii=true&limit=10" \\
|
code={`# All logs for the authenticated tenant (default: last 50)
|
||||||
|
curl "http://localhost:8090/v1/admin/logs" \\
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
# Response:
|
# Filter by provider and sensitivity
|
||||||
|
curl "http://localhost:8090/v1/admin/logs?provider=anthropic&min_sensitivity=medium" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Date range — RFC3339 (API clients)
|
||||||
|
curl "http://localhost:8090/v1/admin/logs?start=2026-03-01T00:00:00Z&end=2026-03-31T23:59:59Z&limit=100" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Date range — RFC3339Nano (JavaScript frontend)
|
||||||
|
curl "http://localhost:8090/v1/admin/logs?start=2026-03-01T00:00:00.000Z&end=2026-03-31T23:59:59.999Z" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
code={`// 200 — Success
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": "log-uuid",
|
"request_id": "req_01HV...",
|
||||||
"tenant_id": "tenant-uuid",
|
"tenant_id": "dev-tenant",
|
||||||
"user_id": "user-uuid",
|
"user_id": "a1b2c3d4-0000-0000-0000-000000000001",
|
||||||
"user_email": "alice@acme.com",
|
"timestamp": "2026-03-10T14:32:11Z",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"model_requested": "gpt-4o",
|
"model_requested": "gpt-4o",
|
||||||
"model_used": "claude-3-5-sonnet-20241022",
|
"model_used": "claude-3-5-sonnet-20241022",
|
||||||
"prompt_tokens": 128,
|
"department": "Legal",
|
||||||
"completion_tokens": 345,
|
"user_role": "user",
|
||||||
"total_tokens": 473,
|
"sensitivity_level": "high",
|
||||||
|
"token_input": 128,
|
||||||
|
"token_output": 345,
|
||||||
|
"token_total": 473,
|
||||||
"cost_usd": 0.003412,
|
"cost_usd": 0.003412,
|
||||||
"latency_ms": 1423,
|
"latency_ms": 1423,
|
||||||
"pii_detected": true,
|
"status": "ok",
|
||||||
"pii_entities": ["PERSON", "EMAIL_ADDRESS"],
|
"error_type": "",
|
||||||
"policy_matched": "Legal → Anthropic",
|
"pii_entity_count": 3,
|
||||||
"status_code": 200,
|
"stream": false,
|
||||||
"timestamp": "2026-01-15T14:32:11Z"
|
"prompt_hash": "sha256:a3f...",
|
||||||
|
"response_hash": "sha256:b7c..."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 142,
|
"total": 142
|
||||||
"limit": 10,
|
|
||||||
"offset": 0
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Callout type="info" title="Audit-of-the-audit">
|
<Callout type="info" title="Audit-of-the-audit">
|
||||||
All accesses to audit logs are themselves logged. This satisfies the "audit-of-the-audit"
|
All accesses to audit logs are themselves logged. This satisfies the meta-logging
|
||||||
requirement for sensitive compliance use cases.
|
requirement for data protection authorities.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
<Callout type="warning" title="prompt_anonymized is never returned">
|
||||||
|
The <code>prompt_anonymized</code> field (AES-256-GCM encrypted) is stored in ClickHouse
|
||||||
|
but is excluded from all API responses. Use the CSV export or GDPR access endpoint for
|
||||||
|
compliance data requests.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h3 id="audit-entry-fields">Audit Entry Fields</h3>
|
||||||
|
<div className="overflow-x-auto my-4">
|
||||||
|
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">Field</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-semibold">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
{ field: "request_id", type: "string", desc: "Unique request identifier (X-Request-ID header)." },
|
||||||
|
{ field: "tenant_id", type: "string", desc: "Tenant UUID — always scoped to the authenticated tenant." },
|
||||||
|
{ field: "user_id", type: "string", desc: "User UUID from the JWT sub claim." },
|
||||||
|
{ field: "timestamp", type: "RFC3339", desc: "UTC timestamp when the request was processed." },
|
||||||
|
{ field: "provider", type: "string", desc: "LLM provider that served the request: openai | anthropic | azure | mistral | ollama" },
|
||||||
|
{ field: "model_requested", type: "string", desc: "Model requested by the client (may differ from model_used after routing)." },
|
||||||
|
{ field: "model_used", type: "string", desc: "Actual model used after routing rule evaluation." },
|
||||||
|
{ field: "department", type: "string", desc: "User department from JWT claims (used in routing conditions)." },
|
||||||
|
{ field: "user_role", type: "string", desc: "RBAC role: admin | manager | user | auditor" },
|
||||||
|
{ field: "sensitivity_level", type: "string", desc: "Highest PII sensitivity detected: none | low | medium | high | critical" },
|
||||||
|
{ field: "token_input", type: "integer", desc: "Prompt tokens consumed." },
|
||||||
|
{ field: "token_output", type: "integer", desc: "Completion tokens generated." },
|
||||||
|
{ field: "token_total", type: "integer", desc: "token_input + token_output." },
|
||||||
|
{ field: "cost_usd", type: "float", desc: "Estimated cost in USD (per-provider pricing table)." },
|
||||||
|
{ field: "latency_ms", type: "integer", desc: "End-to-end latency in milliseconds (proxy entry → last byte)." },
|
||||||
|
{ field: "status", type: "string", desc: "\"ok\" or \"error\"." },
|
||||||
|
{ field: "error_type", type: "string", desc: "Error category if status=error (empty otherwise)." },
|
||||||
|
{ field: "pii_entity_count", type: "integer", desc: "Number of PII entities detected in the prompt." },
|
||||||
|
{ field: "stream", type: "boolean", desc: "Whether the request used Server-Sent Events streaming." },
|
||||||
|
{ field: "prompt_hash", type: "string", desc: "SHA-256 of the original prompt (for deduplication/integrity)." },
|
||||||
|
{ field: "response_hash", type: "string", desc: "SHA-256 of the response content." },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.field} className="border-b last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs">{row.field}</td>
|
||||||
|
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.type}</td>
|
||||||
|
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 id="costs">Cost Breakdown</h2>
|
<h2 id="costs">Cost Breakdown</h2>
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/costs" description="Aggregate cost breakdown by provider, model, or department." />
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/v1/admin/costs"
|
||||||
|
description="Aggregate token consumption and cost, grouped by provider, model, or department."
|
||||||
|
/>
|
||||||
|
|
||||||
<ParamTable
|
<ParamTable
|
||||||
title="Query Parameters"
|
title="Query Parameters"
|
||||||
params={[
|
params={[
|
||||||
{ name: "group_by", type: "string", required: false, default: "provider", description: "Grouping dimension: provider | model | department | user" },
|
{ name: "group_by", type: "string", required: false, default: "provider", description: "Grouping dimension: provider | model | department" },
|
||||||
{ name: "from", type: "string (ISO 8601)", required: false, description: "Start of billing period." },
|
{ name: "start", type: "string (RFC3339)", required: false, description: "Start of billing period." },
|
||||||
{ name: "to", type: "string (ISO 8601)", required: false, description: "End of billing period." },
|
{ name: "end", type: "string (RFC3339)", required: false, description: "End of billing period." },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl "http://localhost:8090/v1/admin/costs?group_by=provider&from=2026-01-01T00:00:00Z" \\
|
code={`# Costs by provider for March 2026
|
||||||
|
curl "http://localhost:8090/v1/admin/costs?group_by=provider&start=2026-03-01T00:00:00Z&end=2026-03-31T23:59:59Z" \\
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
# Response:
|
# Costs by department (for chargeback)
|
||||||
|
curl "http://localhost:8090/v1/admin/costs?group_by=department" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Costs by model
|
||||||
|
curl "http://localhost:8090/v1/admin/costs?group_by=model&start=2026-03-01T00:00:00Z" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
code={`// 200 — Costs grouped by provider
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"key": "openai",
|
"key": "openai",
|
||||||
"request_count": 1423,
|
|
||||||
"total_tokens": 2840000,
|
"total_tokens": 2840000,
|
||||||
"prompt_tokens": 1200000,
|
"total_cost_usd": 28.40,
|
||||||
"completion_tokens": 1640000,
|
"request_count": 1423
|
||||||
"total_cost_usd": 28.40
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "anthropic",
|
"key": "anthropic",
|
||||||
"request_count": 231,
|
|
||||||
"total_tokens": 462000,
|
"total_tokens": 462000,
|
||||||
"prompt_tokens": 230000,
|
"total_cost_usd": 6.93,
|
||||||
"completion_tokens": 232000,
|
"request_count": 231
|
||||||
"total_cost_usd": 6.93
|
},
|
||||||
|
{
|
||||||
|
"key": "mistral",
|
||||||
|
"total_tokens": 180000,
|
||||||
|
"total_cost_usd": 0.54,
|
||||||
|
"request_count": 89
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"period_from": "2026-01-01T00:00:00Z",
|
}
|
||||||
"period_to": "2026-01-31T23:59:59Z"
|
|
||||||
|
// 200 — Costs grouped by department
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "key": "Legal", "total_tokens": 1200000, "total_cost_usd": 18.00, "request_count": 650 },
|
||||||
|
{ "key": "Finance", "total_tokens": 900000, "total_cost_usd": 9.00, "request_count": 410 },
|
||||||
|
{ "key": "IT", "total_tokens": 540000, "total_cost_usd": 8.10, "request_count": 280 }
|
||||||
|
]
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2 id="rate-limits">Rate Limit Overrides</h2>
|
<h2 id="csv-export">CSV Export</h2>
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/rate-limits" description="List per-tenant rate limit overrides." />
|
<ApiEndpoint
|
||||||
<ApiEndpoint method="GET" path="/v1/admin/rate-limits/{tenant_id}" description="Get rate limit configuration for a specific tenant." />
|
method="GET"
|
||||||
|
path="/v1/admin/compliance/export/logs"
|
||||||
|
description="Export audit logs as a CSV file (up to 10 000 rows). For bulk compliance downloads or GDPR documentation."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParamTable
|
||||||
|
title="Query Parameters"
|
||||||
|
params={[
|
||||||
|
{ name: "provider", type: "string", required: false, description: "Filter by provider." },
|
||||||
|
{ name: "start", type: "string (RFC3339 or YYYY-MM-DD)", required: false, description: "Start date. Accepts RFC3339 or date-only format (e.g. 2026-03-01)." },
|
||||||
|
{ name: "end", type: "string (RFC3339 or YYYY-MM-DD)", required: false, description: "End date. When date-only, the full day (23:59:59) is included automatically." },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`# Get rate limit for a tenant
|
code={`# Export all logs as CSV
|
||||||
curl http://localhost:8090/v1/admin/rate-limits/tenant-uuid \\
|
curl "http://localhost:8090/v1/admin/compliance/export/logs" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output audit_logs.csv
|
||||||
|
|
||||||
|
# Export for a specific date range using simple date format
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/export/logs?start=2026-03-01&end=2026-03-31" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output march_2026_logs.csv
|
||||||
|
|
||||||
|
# Export only high-sensitivity entries from OpenAI
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/export/logs?provider=openai" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output openai_logs.csv`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`# CSV columns (header row):
|
||||||
|
request_id,timestamp,user_id,tenant_id,provider,model_requested,model_used,department,user_role,sensitivity_level,token_input,token_output,token_total,cost_usd,latency_ms,status,error_type,pii_entity_count,stream
|
||||||
|
|
||||||
|
# Example rows:
|
||||||
|
req_01HV...,2026-03-10T14:32:11Z,a1b2c3d4-...,dev-tenant,anthropic,gpt-4o,claude-3-5-sonnet-20241022,Legal,user,high,128,345,473,0.003412,1423,ok,,3,false
|
||||||
|
req_01HW...,2026-03-10T15:01:44Z,b2c3d4e5-...,dev-tenant,openai,gpt-4o-mini,gpt-4o-mini,Finance,user,none,64,120,184,0.000074,342,ok,,0,false`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 id="rate-limits">Rate Limit Configuration</h2>
|
||||||
|
<p>
|
||||||
|
Per-tenant rate limits override the global defaults configured in{" "}
|
||||||
|
<code>rate_limit.default_tenant_rpm</code>.
|
||||||
|
</p>
|
||||||
|
<ApiEndpoint method="GET" path="/v1/admin/rate-limits" description="List all per-tenant rate limit overrides." />
|
||||||
|
<ApiEndpoint method="GET" path="/v1/admin/rate-limits/{tenant_id}" description="Get rate limit configuration for a specific tenant (returns default if no override exists)." />
|
||||||
|
<ApiEndpoint method="PUT" path="/v1/admin/rate-limits/{tenant_id}" description="Create or update a per-tenant rate limit. Applied immediately — no restart required." />
|
||||||
|
<ApiEndpoint method="DELETE" path="/v1/admin/rate-limits/{tenant_id}" description="Remove the per-tenant override. The tenant reverts to global defaults." />
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Set a rate limit override for a tenant
|
||||||
|
curl -X PUT http://localhost:8090/v1/admin/rate-limits/acme-corp-tenant-id \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"requests_per_min": 500,
|
||||||
|
"burst_size": 50,
|
||||||
|
"user_rpm": 60,
|
||||||
|
"user_burst": 10,
|
||||||
|
"is_enabled": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Get current config (returns default if no override)
|
||||||
|
curl http://localhost:8090/v1/admin/rate-limits/acme-corp-tenant-id \\
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
# Response:
|
# Remove override (reverts to global defaults)
|
||||||
|
curl -X DELETE http://localhost:8090/v1/admin/rate-limits/acme-corp-tenant-id \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
code={`// PUT/GET response:
|
||||||
{
|
{
|
||||||
"tenant_id": "tenant-uuid",
|
"tenant_id": "acme-corp-tenant-id",
|
||||||
"rpm": 500, # requests per minute
|
"requests_per_min": 500,
|
||||||
"tpm": 50000, # tokens per minute
|
"burst_size": 50,
|
||||||
"daily_token_limit": 1000000
|
"user_rpm": 60,
|
||||||
}`}
|
"user_burst": 10,
|
||||||
|
"is_enabled": true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 response when rate limit is exceeded:
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"type": "rate_limit_error",
|
||||||
|
"message": "rate limit exceeded",
|
||||||
|
"code": "rate_limit_exceeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The response also includes: Retry-After: 1`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,89 +1,140 @@
|
|||||||
import { CodeBlock } from "../components/CodeBlock";
|
import { CodeBlock } from "../components/CodeBlock";
|
||||||
import { Callout } from "../components/Callout";
|
import { Callout } from "../components/Callout";
|
||||||
|
import { ApiEndpoint } from "../components/ApiEndpoint";
|
||||||
|
import { ParamTable } from "../components/ParamTable";
|
||||||
|
|
||||||
export function AuthenticationPage() {
|
export function AuthenticationPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 id="authentication">Authentication</h1>
|
<h1 id="authentication">Authentication</h1>
|
||||||
<p>
|
<p>
|
||||||
All <code>/v1/*</code> endpoints require a Bearer JWT in the{" "}
|
Veylant IA uses a local email/password authentication system. Users log in with their
|
||||||
<code>Authorization</code> header. Veylant IA validates the token against Keycloak (OIDC)
|
credentials to receive a signed JWT (HS256). This token must be sent as a Bearer token on
|
||||||
or uses a mock verifier in development mode.
|
all protected <code>/v1/*</code> requests.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="bearer-token">Bearer Token</h2>
|
<h2 id="login">Login — Obtain a Token</h2>
|
||||||
|
<ApiEndpoint
|
||||||
|
method="POST"
|
||||||
|
path="/v1/auth/login"
|
||||||
|
description="Authenticate with email and password. Returns a signed JWT and the user profile."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParamTable
|
||||||
|
title="Request Body"
|
||||||
|
params={[
|
||||||
|
{ name: "email", type: "string", required: true, description: "User email address." },
|
||||||
|
{ name: "password", type: "string", required: true, description: "User password (bcrypt-hashed at rest)." },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl http://localhost:8090/v1/chat/completions \\
|
code={`curl -X POST http://localhost:8090/v1/auth/login \\
|
||||||
-H "Authorization: Bearer <your-access-token>" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hi"}]}'`}
|
-d '{
|
||||||
|
"email": "admin@veylant.dev",
|
||||||
|
"password": "admin123"
|
||||||
|
}'`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2 id="dev-mode">Development Mode</h2>
|
|
||||||
<Callout type="info" title="Development mode auth bypass">
|
|
||||||
When <code>server.env=development</code> and Keycloak is unreachable, the proxy uses a{" "}
|
|
||||||
<code>MockVerifier</code>. Any non-empty Bearer token is accepted. The authenticated user
|
|
||||||
is injected as <code>admin@veylant.dev</code> with <code>admin</code> role and tenant ID{" "}
|
|
||||||
<code>dev-tenant</code>.
|
|
||||||
</Callout>
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="json"
|
||||||
code={`# Any string works as the token in dev mode
|
code={`// 200 — Login successful
|
||||||
curl http://localhost:8090/v1/chat/completions \\
|
|
||||||
-H "Authorization: Bearer dev-token" \\
|
|
||||||
...`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h2 id="keycloak-flow">Production: Keycloak OIDC Flow</h2>
|
|
||||||
<p>In production, clients obtain a token via the standard OIDC Authorization Code flow:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Redirect user to Keycloak login page</li>
|
|
||||||
<li>User authenticates; Keycloak redirects back with an authorization code</li>
|
|
||||||
<li>Exchange code for tokens at the token endpoint</li>
|
|
||||||
<li>Use the <code>access_token</code> as the Bearer token</li>
|
|
||||||
</ol>
|
|
||||||
<CodeBlock
|
|
||||||
language="bash"
|
|
||||||
code={`# Token endpoint (replace values)
|
|
||||||
curl -X POST \\
|
|
||||||
http://localhost:8080/realms/veylant/protocol/openid-connect/token \\
|
|
||||||
-d "grant_type=password" \\
|
|
||||||
-d "client_id=veylant-proxy" \\
|
|
||||||
-d "username=admin@veylant.dev" \\
|
|
||||||
-d "password=admin123"
|
|
||||||
|
|
||||||
# Response includes:
|
|
||||||
{
|
{
|
||||||
"access_token": "eyJhbGci...",
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
"expires_in": 300,
|
"user": {
|
||||||
"refresh_token": "eyJhbGci...",
|
"id": "a1b2c3d4-0000-0000-0000-000000000001",
|
||||||
"token_type": "Bearer"
|
"email": "admin@veylant.dev",
|
||||||
|
"name": "Admin",
|
||||||
|
"role": "admin",
|
||||||
|
"tenant_id": "dev-tenant",
|
||||||
|
"department": "IT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 401 — Invalid credentials
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"type": "authentication_error",
|
||||||
|
"message": "invalid email or password",
|
||||||
|
"code": "invalid_api_key"
|
||||||
|
}
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Callout type="info" title="Token lifetime">
|
||||||
|
Tokens are valid for the duration configured in <code>auth.jwt_ttl_hours</code> (default:{" "}
|
||||||
|
<code>24</code> hours). The frontend automatically logs the user out when the token expires.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="bearer-token">Using the Token</h2>
|
||||||
|
<p>
|
||||||
|
Pass the token in the <code>Authorization</code> header on every protected request:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Store the token after login
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8090/v1/auth/login \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"email":"admin@veylant.dev","password":"admin123"}' | jq -r '.token')
|
||||||
|
|
||||||
|
# Use it on subsequent requests
|
||||||
|
curl http://localhost:8090/v1/chat/completions \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Bonjour"}]}'`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="python"
|
||||||
|
code={`import httpx
|
||||||
|
|
||||||
|
# Step 1 — login
|
||||||
|
resp = httpx.post("http://localhost:8090/v1/auth/login", json={
|
||||||
|
"email": "admin@veylant.dev",
|
||||||
|
"password": "admin123",
|
||||||
|
})
|
||||||
|
token = resp.json()["token"]
|
||||||
|
|
||||||
|
# Step 2 — use the OpenAI-compatible endpoint
|
||||||
|
client = httpx.Client(
|
||||||
|
base_url="http://localhost:8090/v1",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
chat = client.post("/chat/completions", json={
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"messages": [{"role": "user", "content": "Résume ce contrat"}],
|
||||||
|
})
|
||||||
|
print(chat.json()["choices"][0]["message"]["content"])`}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2 id="jwt-claims">JWT Claims</h2>
|
<h2 id="jwt-claims">JWT Claims</h2>
|
||||||
<p>The proxy extracts the following claims from the JWT:</p>
|
<p>
|
||||||
|
Tokens are HS256-signed. The proxy extracts the following claims on each request:
|
||||||
|
</p>
|
||||||
<div className="overflow-x-auto my-4">
|
<div className="overflow-x-auto my-4">
|
||||||
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-muted/50 border-b">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Claim</th>
|
<th className="text-left px-4 py-2.5 font-semibold">Claim</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Source</th>
|
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
|
||||||
<th className="text-left px-4 py-2.5 font-semibold">Description</th>
|
<th className="text-left px-4 py-2.5 font-semibold">Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
{ claim: "sub", source: "Standard JWT", desc: "User ID (UUID)" },
|
{ claim: "sub", type: "string (UUID)", desc: "User ID — used in audit logs and GDPR access requests." },
|
||||||
{ claim: "email", source: "Standard JWT", desc: "User email" },
|
{ claim: "email", type: "string", desc: "User email address." },
|
||||||
{ claim: "realm_access.roles", source: "Keycloak extension", desc: "RBAC roles: admin, manager, user, auditor" },
|
{ claim: "name", type: "string", desc: "Display name shown in the dashboard." },
|
||||||
{ claim: "veylant_tenant_id", source: "Keycloak mapper", desc: "Tenant UUID" },
|
{ claim: "role", type: "string", desc: "RBAC role: admin | manager | user | auditor." },
|
||||||
{ claim: "department", source: "Keycloak user attribute", desc: "Department name for routing rules" },
|
{ claim: "tenant_id", type: "string", desc: "Tenant UUID — enforces data isolation via PostgreSQL RLS." },
|
||||||
|
{ claim: "department", type: "string", desc: "Department name — used in routing rule conditions." },
|
||||||
|
{ claim: "exp", type: "unix timestamp", desc: "Token expiry (derived from auth.jwt_ttl_hours)." },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.claim} className="border-b last:border-0">
|
<tr key={row.claim} className="border-b last:border-0">
|
||||||
<td className="px-4 py-2.5 font-mono text-xs">{row.claim}</td>
|
<td className="px-4 py-2.5 font-mono text-xs">{row.claim}</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.source}</td>
|
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.type}</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
|
<td className="px-4 py-2.5 text-muted-foreground text-xs">{row.desc}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -91,21 +142,37 @@ curl -X POST \\
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="test-users">Pre-configured Test Users</h2>
|
<h2 id="test-users">Pre-configured Dev Users</h2>
|
||||||
<p>The Keycloak realm export includes these users for testing:</p>
|
<p>
|
||||||
|
The dev stack seeds two users in the <code>users</code> table (migration 000010):
|
||||||
|
</p>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`# Admin user (full access)
|
code={`# Admin — full access, unrestricted model access
|
||||||
username: admin@veylant.dev
|
email: admin@veylant.dev
|
||||||
password: admin123
|
password: admin123
|
||||||
roles: admin
|
role: admin
|
||||||
|
|
||||||
# Regular user (restricted to allowed models)
|
# Regular user — inference only, restricted to allowed models
|
||||||
username: user@veylant.dev
|
# (create this user via POST /v1/admin/users if needed)
|
||||||
|
email: user@veylant.dev
|
||||||
password: user123
|
password: user123
|
||||||
roles: user`}
|
role: user`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<h2 id="config">Auth Configuration</h2>
|
||||||
|
<CodeBlock
|
||||||
|
language="yaml"
|
||||||
|
code={`# config.yaml
|
||||||
|
auth:
|
||||||
|
jwt_secret: "change-me-in-production-min-32-chars"
|
||||||
|
jwt_ttl_hours: 24`}
|
||||||
|
/>
|
||||||
|
<Callout type="warning" title="Secret rotation">
|
||||||
|
Set <code>VEYLANT_AUTH_JWT_SECRET</code> in production to a random 32-byte value. Rotating
|
||||||
|
the secret immediately invalidates all active sessions.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
<h2 id="error-responses">Auth Error Responses</h2>
|
<h2 id="error-responses">Auth Error Responses</h2>
|
||||||
<p>Authentication errors always return OpenAI-format JSON:</p>
|
<p>Authentication errors always return OpenAI-format JSON:</p>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
@ -119,13 +186,22 @@ roles: user`}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 403 — Valid token, insufficient role
|
// 403 — Valid token, insufficient role for the model
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"type": "permission_error",
|
"type": "permission_error",
|
||||||
"message": "role 'user' does not have access to model 'gpt-4o'. Allowed: gpt-4o-mini",
|
"message": "role 'user' does not have access to model 'gpt-4o'. Allowed: gpt-4o-mini",
|
||||||
"code": "permission_denied"
|
"code": "permission_denied"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 — Rate limit exceeded
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"type": "rate_limit_error",
|
||||||
|
"message": "rate limit exceeded",
|
||||||
|
"code": "rate_limit_exceeded"
|
||||||
|
}
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,8 +12,7 @@ export function QuickStartPage() {
|
|||||||
|
|
||||||
<Callout type="info" title="Prerequisites">
|
<Callout type="info" title="Prerequisites">
|
||||||
You need <strong>Docker 24+</strong> and <strong>Docker Compose v2</strong> installed.
|
You need <strong>Docker 24+</strong> and <strong>Docker Compose v2</strong> installed.
|
||||||
Clone the repository and ensure ports 8090, 8080, 5432, 6379, 8123, 3000, and 3001 are
|
Clone the repository and ensure ports 8090, 5432, 6379, 8123, 3000, and 3001 are free.
|
||||||
free.
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<h2 id="step-1-clone">Step 1 — Clone the repository</h2>
|
<h2 id="step-1-clone">Step 1 — Clone the repository</h2>
|
||||||
@ -34,15 +33,18 @@ cd ia-gateway`}
|
|||||||
|
|
||||||
# Edit .env and set:
|
# Edit .env and set:
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-...
|
||||||
# Optional:
|
# Optional additional providers:
|
||||||
ANTHROPIC_API_KEY=sk-ant-...`}
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# JWT secret for auth (change in production):
|
||||||
|
VEYLANT_AUTH_JWT_SECRET=change-me-min-32-chars-dev-only`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Callout type="tip" title="Development mode">
|
<Callout type="tip" title="Development mode graceful degradation">
|
||||||
In <code>server.env=development</code> (the default), all external services degrade
|
In <code>server.env=development</code> (the default), all external services degrade
|
||||||
gracefully. Keycloak is bypassed (mock JWT), PostgreSQL failures disable routing, ClickHouse
|
gracefully: PostgreSQL failures disable routing rules, ClickHouse failures fall back to an
|
||||||
failures disable audit logs. This means you can start the proxy even if some services
|
in-memory audit log (data not persisted), and PII failures are silently skipped if{" "}
|
||||||
haven't fully initialized yet.
|
<code>pii.fail_open=true</code>. The proxy stays up even if some services haven't
|
||||||
|
initialized yet.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<h2 id="step-3-start">Step 3 — Start the stack</h2>
|
<h2 id="step-3-start">Step 3 — Start the stack</h2>
|
||||||
@ -54,18 +56,19 @@ ANTHROPIC_API_KEY=sk-ant-...`}
|
|||||||
docker compose up --build`}
|
docker compose up --build`}
|
||||||
/>
|
/>
|
||||||
<p>
|
<p>
|
||||||
This starts 9 services: PostgreSQL, Redis, ClickHouse, Keycloak, the Go proxy, PII
|
This starts 8 services: PostgreSQL, Redis, ClickHouse, the Go proxy, PII detection
|
||||||
detection service, Prometheus, Grafana, and the React dashboard.
|
service, Prometheus, Grafana, and the React dashboard.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Wait for the proxy to print <code>server listening on :8090</code>. First startup takes
|
Wait for the proxy to print{" "}
|
||||||
~2 minutes while Keycloak initializes and database migrations run.
|
<code>Veylant IA proxy started addr=:8090</code>. First startup takes ~60 seconds while
|
||||||
|
PostgreSQL runs migrations.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="step-4-verify">Step 4 — Verify the stack</h2>
|
<h2 id="step-4-verify">Step 4 — Verify the stack</h2>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`# Health check
|
code={`# Health check — no auth required
|
||||||
curl http://localhost:8090/healthz
|
curl http://localhost:8090/healthz
|
||||||
# {"status":"ok","version":"1.0.0"}`}
|
# {"status":"ok","version":"1.0.0"}`}
|
||||||
/>
|
/>
|
||||||
@ -82,12 +85,12 @@ curl http://localhost:8090/healthz
|
|||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
{ service: "AI Proxy", url: "http://localhost:8090", creds: "—" },
|
{ service: "AI Proxy", url: "http://localhost:8090", creds: "—" },
|
||||||
{ service: "React Dashboard", url: "http://localhost:3000", creds: "dev mode (no auth)" },
|
{ service: "React Dashboard", url: "http://localhost:3000", creds: "admin@veylant.dev / admin123" },
|
||||||
{ service: "Keycloak Admin", url: "http://localhost:8080", creds: "admin / admin" },
|
{ service: "Documentation", url: "http://localhost:3000/docs", creds: "— (public)" },
|
||||||
|
{ service: "API Playground", url: "http://localhost:8090/playground", creds: "— (public)" },
|
||||||
|
{ service: "OpenAPI Docs", url: "http://localhost:8090/docs", creds: "— (public)" },
|
||||||
{ service: "Grafana", url: "http://localhost:3001", creds: "admin / admin" },
|
{ service: "Grafana", url: "http://localhost:3001", creds: "admin / admin" },
|
||||||
{ service: "Prometheus", url: "http://localhost:9090", creds: "—" },
|
{ service: "Prometheus", url: "http://localhost:9090", creds: "—" },
|
||||||
{ service: "API Docs", url: "http://localhost:8090/docs", creds: "—" },
|
|
||||||
{ service: "Playground", url: "http://localhost:8090/playground", creds: "—" },
|
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.service} className="border-b last:border-0">
|
<tr key={row.service} className="border-b last:border-0">
|
||||||
<td className="px-4 py-2.5 font-medium">{row.service}</td>
|
<td className="px-4 py-2.5 font-medium">{row.service}</td>
|
||||||
@ -101,62 +104,87 @@ curl http://localhost:8090/healthz
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="step-5-first-call">Step 5 — Make your first AI call</h2>
|
<h2 id="step-5-first-call">Step 5 — Authenticate and make your first AI call</h2>
|
||||||
<p>
|
<p>
|
||||||
In development mode, the proxy uses a mock JWT verifier. Pass any Bearer token and the
|
Log in to get a JWT token, then use it as a Bearer token on all <code>/v1/*</code>{" "}
|
||||||
request will be authenticated as <code>admin@veylant.dev</code>.
|
requests.
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`curl http://localhost:8090/v1/chat/completions \\
|
code={`# Step 5a — Login to get a JWT token
|
||||||
-H "Authorization: Bearer dev-token" \\
|
TOKEN=$(curl -s -X POST http://localhost:8090/v1/auth/login \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"email":"admin@veylant.dev","password":"admin123"}' | jq -r '.token')
|
||||||
|
|
||||||
|
echo "Token: $TOKEN"
|
||||||
|
|
||||||
|
# Step 5b — Make your first AI call
|
||||||
|
curl http://localhost:8090/v1/chat/completions \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{
|
-d '{
|
||||||
"model": "gpt-4o",
|
"model": "gpt-4o-mini",
|
||||||
"messages": [{"role": "user", "content": "Hello, Veylant!"}]
|
"messages": [{"role": "user", "content": "Bonjour depuis Veylant IA !"}]
|
||||||
}'`}
|
}'`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p>Or use the OpenAI Python SDK with a changed base URL:</p>
|
<p>Or use the OpenAI Python SDK by simply changing the base URL:</p>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="python"
|
language="python"
|
||||||
code={`from openai import OpenAI
|
code={`import httpx
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
# Step 1: obtain a token
|
||||||
|
token = httpx.post("http://localhost:8090/v1/auth/login", json={
|
||||||
|
"email": "admin@veylant.dev",
|
||||||
|
"password": "admin123",
|
||||||
|
}).json()["token"]
|
||||||
|
|
||||||
|
# Step 2: use the OpenAI SDK with Veylant as the base URL
|
||||||
client = OpenAI(
|
client = OpenAI(
|
||||||
base_url="http://localhost:8090/v1",
|
base_url="http://localhost:8090/v1",
|
||||||
api_key="dev-token", # any string in dev mode
|
api_key=token, # Veylant uses JWT, not an API key
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model="gpt-4o",
|
model="gpt-4o-mini",
|
||||||
messages=[{"role": "user", "content": "Hello, Veylant!"}],
|
messages=[{"role": "user", "content": "Résume ce contrat en 3 points."}],
|
||||||
)
|
)
|
||||||
print(response.choices[0].message.content)`}
|
print(response.choices[0].message.content)`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Callout type="tip" title="Drop-in replacement for any OpenAI SDK">
|
||||||
|
Veylant IA is 100% OpenAI-API compatible. Point any existing SDK (Python, Node.js, Go,
|
||||||
|
Rust…) to <code>http://localhost:8090/v1</code> and pass the JWT as the API key.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
<h2 id="step-6-dashboard">Step 6 — Explore the dashboard</h2>
|
<h2 id="step-6-dashboard">Step 6 — Explore the dashboard</h2>
|
||||||
<p>
|
<p>
|
||||||
Open <code>http://localhost:3000</code> to see the React dashboard. In development mode,
|
Open <code>http://localhost:3000</code> and log in with{" "}
|
||||||
you're automatically logged in as <code>Dev Admin</code>. You'll see:
|
<code>admin@veylant.dev / admin123</code>. You'll find:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Overview</strong> — request counts, costs, and tokens consumed
|
<strong>Vue d'ensemble</strong> — request counts, costs, tokens consumed, volume chart
|
||||||
|
(7d/30d)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Playground IA</strong> — test prompts with live PII detection visualization
|
<strong>Playground IA</strong> — test prompts with live PII detection visualization
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Policies</strong> — create and manage routing rules
|
<strong>Politiques</strong> — create and manage routing rules
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Compliance</strong> — GDPR Article 30 registry and AI Act questionnaire
|
<strong>Conformité</strong> — GDPR Article 30 registry and EU AI Act questionnaire
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fournisseurs</strong> — configure LLM providers with encrypted API key storage
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Callout type="tip" title="Next: Configure a routing rule">
|
<Callout type="tip" title="Next: Configure a routing rule">
|
||||||
Try creating a routing rule in the dashboard that sends all requests from the{" "}
|
Try creating a routing rule that sends all requests from the <code>Legal</code> department
|
||||||
<code>legal</code> department to Anthropic instead of OpenAI. See{" "}
|
to Anthropic instead of OpenAI. See{" "}
|
||||||
<a href="/docs/guides/routing">Routing Rules Engine</a> for the full guide.
|
<a href="/docs/guides/routing">Routing Rules Engine</a> for the full guide.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@ -7,16 +7,16 @@ export function ComplianceGuide() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 id="compliance">GDPR & EU AI Act Compliance</h1>
|
<h1 id="compliance">GDPR & EU AI Act Compliance</h1>
|
||||||
<p>
|
<p>
|
||||||
Veylant IA includes a built-in compliance module for GDPR Article 30 record-keeping and EU
|
Veylant IA includes a built-in compliance module for GDPR Article 30 record-keeping, EU
|
||||||
AI Act risk classification. It is designed to serve as your primary compliance tool for AI
|
AI Act risk classification, DPIA generation, and GDPR subject rights management. It is
|
||||||
deployments.
|
designed to serve as the primary compliance tool for enterprise AI deployments.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="gdpr-art30">GDPR Article 30 — Record of Processing Activities</h2>
|
<h2 id="gdpr-art30">GDPR Article 30 — Record of Processing Activities</h2>
|
||||||
<p>
|
<p>
|
||||||
Article 30 requires organizations to maintain a written record of all data processing
|
Article 30 requires organizations to maintain a written record of all data processing
|
||||||
activities. For AI systems, this means documenting each use case where personal data may be
|
activities. For AI systems, this means documenting every use case where personal data may
|
||||||
processed.
|
be processed — including through third-party LLM providers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 id="ropa-fields">Required ROPA Fields</h3>
|
<h3 id="ropa-fields">Required ROPA Fields</h3>
|
||||||
@ -31,14 +31,14 @@ export function ComplianceGuide() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
{ field: "use_case_name", req: "Name of the processing activity", ex: "Legal contract analysis" },
|
{ field: "use_case_name", req: "Name of the processing activity", ex: "Analyse de contrats fournisseurs" },
|
||||||
{ field: "purpose", req: "Art. 5(1)(b) — purpose limitation", ex: "Automated risk identification in supplier contracts" },
|
{ field: "purpose", req: "Art. 5(1)(b) — purpose limitation", ex: "Identification automatique des risques dans les contrats" },
|
||||||
{ field: "legal_basis", req: "Art. 6 — lawfulness of processing", ex: "legitimate_interest" },
|
{ field: "legal_basis", req: "Art. 6 — lawfulness of processing", ex: "legitimate_interest" },
|
||||||
{ field: "data_categories", req: "Art. 30(1)(c) — categories of data subjects and data", ex: "name, email, financial" },
|
{ field: "data_categories", req: "Art. 30(1)(c) — categories of data subjects and data", ex: "[\"name\", \"financial\"]" },
|
||||||
{ field: "retention_period", req: "Art. 5(1)(e) — storage limitation", ex: "3 years" },
|
{ field: "retention_period", req: "Art. 5(1)(e) — storage limitation", ex: "3 ans" },
|
||||||
{ field: "security_measures", req: "Art. 32 — security of processing", ex: "AES-256-GCM, PII anonymization" },
|
{ field: "security_measures", req: "Art. 32 — security of processing", ex: "AES-256-GCM, anonymisation PII, audit logs" },
|
||||||
{ field: "controller_name", req: "Art. 30(1)(a) — controller identity", ex: "Acme Corp — dpo@acme.com" },
|
{ field: "controller_name", req: "Art. 30(1)(a) — controller identity", ex: "Acme Corp — dpo@acme.com" },
|
||||||
{ field: "processors", req: "Art. 30(1)(d) — recipients of data", ex: "Anthropic (via Veylant IA proxy)" },
|
{ field: "processors", req: "Art. 30(1)(d) — recipients of data", ex: "[\"Anthropic via Veylant IA proxy\"]" },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.field} className="border-b last:border-0">
|
<tr key={row.field} className="border-b last:border-0">
|
||||||
<td className="px-4 py-2.5 font-mono text-xs">{row.field}</td>
|
<td className="px-4 py-2.5 font-mono text-xs">{row.field}</td>
|
||||||
@ -50,7 +50,7 @@ export function ComplianceGuide() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 id="legal-bases">Legal Bases</h3>
|
<h3 id="legal-bases">Legal Bases (Art. 6 GDPR)</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>consent</code> — User has given explicit consent (Art. 6(1)(a))</li>
|
<li><code>consent</code> — User has given explicit consent (Art. 6(1)(a))</li>
|
||||||
<li><code>contract</code> — Processing necessary for a contract (Art. 6(1)(b))</li>
|
<li><code>contract</code> — Processing necessary for a contract (Art. 6(1)(b))</li>
|
||||||
@ -62,39 +62,40 @@ export function ComplianceGuide() {
|
|||||||
|
|
||||||
<h2 id="ai-act">EU AI Act Risk Classification</h2>
|
<h2 id="ai-act">EU AI Act Risk Classification</h2>
|
||||||
<p>
|
<p>
|
||||||
The EU AI Act (effective August 2024, full enforcement from August 2026) classifies AI
|
The EU AI Act (full enforcement from August 2026) classifies AI systems into four risk
|
||||||
systems into four risk categories.
|
categories. Veylant IA automates the classification via a 5-question questionnaire
|
||||||
|
(<code>q1</code>–<code>q5</code>), scoring each <code>true</code> answer as +1.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3 my-4">
|
<div className="space-y-3 my-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
level: "Forbidden",
|
level: "Interdit (forbidden)",
|
||||||
color: "border-red-400 bg-red-50 dark:bg-red-950/30",
|
color: "border-red-400 bg-red-50 dark:bg-red-950/30",
|
||||||
badge: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
|
badge: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
|
||||||
score: "Score 5",
|
score: "Score 5",
|
||||||
desc: "Cannot be deployed. Examples: social scoring systems, real-time biometric surveillance in public spaces, AI that exploits vulnerable groups.",
|
desc: "Déploiement interdit. Exemples : notation sociale des personnes, surveillance biométrique en temps réel dans l'espace public, IA exploitant des groupes vulnérables.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
level: "High Risk",
|
level: "Haut risque (high)",
|
||||||
color: "border-orange-400 bg-orange-50 dark:bg-orange-950/30",
|
color: "border-orange-400 bg-orange-50 dark:bg-orange-950/30",
|
||||||
badge: "bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300",
|
badge: "bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300",
|
||||||
score: "Score 3–4",
|
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",
|
color: "border-amber-400 bg-amber-50 dark:bg-amber-950/30",
|
||||||
badge: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
|
badge: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
|
||||||
score: "Score 1–2",
|
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",
|
color: "border-green-400 bg-green-50 dark:bg-green-950/30",
|
||||||
badge: "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300",
|
badge: "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300",
|
||||||
score: "Score 0",
|
score: "Score 0",
|
||||||
desc: "Minimal or no risk. Voluntary code of conduct recommended. Examples: spam filters, AI-powered search, content recommendation.",
|
desc: "Risque minimal. Code de conduite volontaire recommandé. Exemples : filtres anti-spam, recherche IA, recommandation de contenu.",
|
||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.level} className={`flex items-start gap-3 rounded-lg border-l-4 p-4 ${item.color}`}>
|
<div key={item.level} className={`flex items-start gap-3 rounded-lg border-l-4 p-4 ${item.color}`}>
|
||||||
@ -114,28 +115,49 @@ export function ComplianceGuide() {
|
|||||||
<h2 id="dpia">Data Protection Impact Assessment (DPIA)</h2>
|
<h2 id="dpia">Data Protection Impact Assessment (DPIA)</h2>
|
||||||
<p>
|
<p>
|
||||||
A DPIA is mandatory under GDPR Art. 35 for high-risk processing activities. High-risk AI
|
A DPIA is mandatory under GDPR Art. 35 for high-risk processing activities. High-risk AI
|
||||||
systems under the AI Act also trigger DPIA requirements. Veylant IA generates DPIA template
|
systems under the AI Act (<code>risk_level: "high"</code>) also trigger DPIA requirements.
|
||||||
documents from the Admin → Compliance → Reports tab.
|
Veylant IA generates a DPIA template PDF from any processing entry.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="reports">Compliance Reports</h2>
|
|
||||||
<p>Available report formats via the API:</p>
|
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="bash"
|
language="bash"
|
||||||
code={`# GDPR Article 30 registry — PDF
|
code={`# Generate DPIA PDF for a specific entry
|
||||||
GET /v1/admin/compliance/reports/art30.pdf
|
curl "http://localhost:8090/v1/admin/compliance/dpia/entry-uuid" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output dpia_$(date +%Y-%m-%d).pdf`}
|
||||||
|
/>
|
||||||
|
|
||||||
# GDPR Article 30 registry — JSON export
|
<h2 id="reports">Compliance Reports</h2>
|
||||||
GET /v1/admin/compliance/reports/art30.json
|
<p>All reports are available as PDF (default) or JSON (<code>?format=json</code>):</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# GDPR Article 30 register — PDF
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/report/article30" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output article30_rgpd_$(date +%Y-%m-%d).pdf
|
||||||
|
|
||||||
|
# GDPR Article 30 register — JSON (for custom reporting)
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/report/article30?format=json" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
# AI Act risk classification report — PDF
|
# AI Act risk classification report — PDF
|
||||||
GET /v1/admin/compliance/reports/ai-act.pdf
|
curl "http://localhost:8090/v1/admin/compliance/report/aiact" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output aiact_report_$(date +%Y-%m-%d).pdf
|
||||||
|
|
||||||
# DPIA template — PDF
|
# AI Act report — JSON
|
||||||
GET /v1/admin/compliance/reports/dpia/{entry_id}.pdf
|
curl "http://localhost:8090/v1/admin/compliance/report/aiact?format=json" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
# Audit log export — CSV
|
# DPIA for a specific entry — PDF only
|
||||||
GET /v1/admin/logs/export?from=2026-01-01T00:00:00Z&to=2026-01-31T23:59:59Z`}
|
curl "http://localhost:8090/v1/admin/compliance/dpia/{entry-id}" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output dpia_{entry-id}_$(date +%Y-%m-%d).pdf
|
||||||
|
|
||||||
|
# Audit log export — CSV (max 10 000 rows, accepts YYYY-MM-DD dates)
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/export/logs?start=2026-01-01&end=2026-03-31" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output audit_q1_2026.csv`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Callout type="tip" title="Audit-of-the-audit">
|
<Callout type="tip" title="Audit-of-the-audit">
|
||||||
@ -143,10 +165,40 @@ GET /v1/admin/logs/export?from=2026-01-01T00:00:00Z&to=2026-01-31T23:59:59Z`}
|
|||||||
data protection authority requirements for meta-logging of sensitive data access.
|
data protection authority requirements for meta-logging of sensitive data access.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<h2 id="next-steps">Working with Compliance</h2>
|
<Callout type="info" title="PDF tenant name">
|
||||||
|
PDF headers display the organisation name from <code>server.tenant_name</code> in{" "}
|
||||||
|
<code>config.yaml</code>. Set this to your legal entity name before generating official
|
||||||
|
compliance documents.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="gdpr-rights-guide">GDPR Subject Rights Workflow</h2>
|
||||||
<p>
|
<p>
|
||||||
See the <Link to="/docs/api/admin/compliance">Admin — Compliance API</Link> for full
|
Veylant IA provides endpoints for responding to GDPR Art. 15 (access) and Art. 17
|
||||||
endpoint documentation, or navigate to{" "}
|
(erasure) requests. Implement the following workflow for data subject requests:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# 1. Receive a subject access request (Art. 15)
|
||||||
|
# Identify the user by email, look up their user_id
|
||||||
|
USER_ID=$(curl -s "http://localhost:8090/v1/admin/users?email=john@acme.com" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')
|
||||||
|
|
||||||
|
# 2. Retrieve all their data
|
||||||
|
curl "http://localhost:8090/v1/admin/compliance/gdpr/access/$USER_ID" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
--output gdpr_access_response.json
|
||||||
|
|
||||||
|
# 3. If erasure requested (Art. 17):
|
||||||
|
curl -X DELETE "http://localhost:8090/v1/admin/compliance/gdpr/erase/$USER_ID" \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"reason": "Demande de suppression RGPD Art. 17 reçue le 2026-03-12"}'`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 id="next-steps">Next Steps</h2>
|
||||||
|
<p>
|
||||||
|
See the <Link to="/docs/api-reference/admin-compliance">Admin — Compliance API</Link> for
|
||||||
|
full endpoint documentation with request/response schemas, or navigate to{" "}
|
||||||
<strong>Dashboard → Compliance</strong> to use the visual interface.
|
<strong>Dashboard → Compliance</strong> to use the visual interface.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,8 +6,9 @@ export function RbacGuide() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 id="rbac">RBAC & Permissions</h1>
|
<h1 id="rbac">RBAC & Permissions</h1>
|
||||||
<p>
|
<p>
|
||||||
Veylant IA enforces Role-Based Access Control on every request. Roles are embedded in the
|
Veylant IA enforces Role-Based Access Control on every request. Roles are stored in the{" "}
|
||||||
Keycloak JWT and cannot be elevated at runtime.
|
<code>users</code> table and embedded in the HS256 JWT at login time. A role cannot be
|
||||||
|
elevated at runtime — a new token must be issued after a role change.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 id="roles">Roles</h2>
|
<h2 id="roles">Roles</h2>
|
||||||
@ -16,12 +17,12 @@ export function RbacGuide() {
|
|||||||
{
|
{
|
||||||
role: "admin",
|
role: "admin",
|
||||||
color: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
|
color: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
|
||||||
description: "Full access. Can manage policies, users, providers, feature flags, and read all compliance/audit data. Has unrestricted model access.",
|
description: "Full access. Can manage routing policies, users, providers, feature flags, and read all compliance/audit data. Has unrestricted model access.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "manager",
|
role: "manager",
|
||||||
color: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
|
color: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
|
||||||
description: "Read-write access to routing policies and users. Can run AI inference with any model. Cannot manage feature flags or access compliance reports.",
|
description: "Read-write access to routing policies and user profiles. Can run AI inference with any model. Cannot manage feature flags or access compliance reports.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -31,7 +32,7 @@ export function RbacGuide() {
|
|||||||
{
|
{
|
||||||
role: "auditor",
|
role: "auditor",
|
||||||
color: "bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300",
|
color: "bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300",
|
||||||
description: "Read-only access to audit logs and compliance data. Cannot call /v1/chat/completions. Intended for compliance officers and DPOs.",
|
description: "Read-only access to audit logs, costs, and compliance data. Cannot call /v1/chat/completions. Intended for compliance officers and DPOs.",
|
||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.role} className="rounded-lg border bg-card p-4 flex items-start gap-3">
|
<div key={item.role} className="rounded-lg border bg-card p-4 flex items-start gap-3">
|
||||||
@ -57,17 +58,20 @@ export function RbacGuide() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
|
{ ep: "POST /v1/auth/login", admin: "✓", manager: "✓", user: "✓", auditor: "✓" },
|
||||||
{ ep: "POST /v1/chat/completions", admin: "✓", manager: "✓", user: "✓ (limited models)", auditor: "✗" },
|
{ ep: "POST /v1/chat/completions", admin: "✓", manager: "✓", user: "✓ (limited models)", auditor: "✗" },
|
||||||
{ ep: "POST /v1/pii/analyze", admin: "✓", manager: "✓", user: "✓", auditor: "✓" },
|
{ ep: "POST /v1/pii/analyze", admin: "✓", manager: "✓", user: "✓", auditor: "✓" },
|
||||||
{ ep: "GET /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
|
{ ep: "GET /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
|
||||||
{ ep: "POST/PUT/DELETE /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
|
{ ep: "POST/PUT/DELETE /v1/admin/policies", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
|
||||||
{ ep: "GET/POST/PUT /v1/admin/users", admin: "✓", manager: "read only", user: "✗", auditor: "✗" },
|
{ ep: "GET/POST/PUT/DELETE /v1/admin/users", admin: "✓", manager: "read only", user: "✗", auditor: "✗" },
|
||||||
{ ep: "GET /v1/admin/logs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" },
|
{ ep: "GET /v1/admin/logs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" },
|
||||||
{ ep: "GET /v1/admin/costs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" },
|
{ ep: "GET /v1/admin/costs", admin: "✓", manager: "✓", user: "✗", auditor: "✓" },
|
||||||
{ ep: "GET /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✓" },
|
{ ep: "GET /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✓" },
|
||||||
{ ep: "POST /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
|
{ ep: "POST/PUT/DELETE /v1/admin/compliance/*", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
|
||||||
{ ep: "GET/PUT/DELETE /v1/admin/flags", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
|
{ ep: "GET/PUT/DELETE /v1/admin/flags", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
|
||||||
{ ep: "GET /v1/admin/providers/status", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
|
{ ep: "GET /v1/admin/providers/status", admin: "✓", manager: "✓", user: "✗", auditor: "✗" },
|
||||||
|
{ ep: "GET/POST/PUT/DELETE /v1/admin/providers", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
|
||||||
|
{ ep: "GET/PUT/DELETE /v1/admin/rate-limits", admin: "✓", manager: "✗", user: "✗", auditor: "✗" },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.ep} className="border-b last:border-0">
|
<tr key={row.ep} className="border-b last:border-0">
|
||||||
<td className="px-4 py-2.5 font-mono text-xs">{row.ep}</td>
|
<td className="px-4 py-2.5 font-mono text-xs">{row.ep}</td>
|
||||||
@ -98,7 +102,7 @@ rbac:
|
|||||||
/>
|
/>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
language="json"
|
language="json"
|
||||||
code={`// 403 response when user requests gpt-4o:
|
code={`// 403 response when a 'user' requests gpt-4o:
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"type": "permission_error",
|
"type": "permission_error",
|
||||||
@ -108,19 +112,71 @@ rbac:
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Callout type="tip" title="Admin and manager bypass model restrictions">
|
<Callout type="tip" title="admin and manager bypass model restrictions">
|
||||||
The <code>admin</code> and <code>manager</code> roles have unrestricted model access —{" "}
|
The <code>admin</code> and <code>manager</code> roles have unrestricted model access —{" "}
|
||||||
<code>user_allowed_models</code> does not apply to them.
|
<code>user_allowed_models</code> does not apply to them.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<h2 id="keycloak-setup">Setting Up Roles in Keycloak</h2>
|
<h2 id="managing-roles">Managing User Roles via the Admin API</h2>
|
||||||
<p>Assign roles to users in Keycloak:</p>
|
<p>
|
||||||
<ol>
|
Roles are managed through the <code>/v1/admin/users</code> endpoints. After updating a
|
||||||
<li>Log in to Keycloak Admin Console (http://localhost:8080, admin/admin)</li>
|
user's role, they must log in again to receive a new token with the updated claims.
|
||||||
<li>Go to <strong>Realm: veylant</strong> → <strong>Users</strong></li>
|
</p>
|
||||||
<li>Select a user → <strong>Role Mappings</strong> → <strong>Realm Roles</strong></li>
|
|
||||||
<li>Assign one of: <code>admin</code>, <code>manager</code>, <code>user</code>, <code>auditor</code></li>
|
<CodeBlock
|
||||||
</ol>
|
language="bash"
|
||||||
|
code={`# List all users
|
||||||
|
curl "http://localhost:8090/v1/admin/users" \\
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||||
|
|
||||||
|
# Create a new user with a specific role
|
||||||
|
curl -X POST "http://localhost:8090/v1/admin/users" \\
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"email": "alice@acme.com",
|
||||||
|
"password": "SecurePass123!",
|
||||||
|
"name": "Alice Martin",
|
||||||
|
"role": "auditor",
|
||||||
|
"department": "Legal"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Promote a user to manager
|
||||||
|
curl -X PUT "http://localhost:8090/v1/admin/users/user-uuid" \\
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"role": "manager",
|
||||||
|
"department": "Finance"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Deactivate a user (GDPR soft-delete)
|
||||||
|
curl -X DELETE "http://localhost:8090/v1/admin/users/user-uuid" \\
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN"`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Callout type="warning" title="Role changes require a new token">
|
||||||
|
JWT tokens embed the role at login time. After changing a user's role via the API, the user
|
||||||
|
must log out and log in again. The old token remains valid until its <code>exp</code> claim
|
||||||
|
(controlled by <code>auth.jwt_ttl_hours</code>).
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="bulk-import">Bulk User Import</h2>
|
||||||
|
<p>
|
||||||
|
For tenant onboarding, use the provided script to bulk-import users from a CSV file:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# CSV format: email,first_name,last_name,department,role
|
||||||
|
cat > users.csv << 'EOF'
|
||||||
|
alice@acme.com,Alice,Martin,Legal,user
|
||||||
|
bob@acme.com,Bob,Dupont,Finance,manager
|
||||||
|
carol@acme.com,Carol,Lefebvre,IT,auditor
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Import (requires make dev to be running)
|
||||||
|
./deploy/onboarding/import-users.sh users.csv`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,79 @@ ollama pull codellama`}
|
|||||||
<code>deploy.resources.reservations.devices</code> key in docker-compose.yml.
|
<code>deploy.resources.reservations.devices</code> key in docker-compose.yml.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="db-providers">Managing Providers via the Admin API</h2>
|
||||||
|
<p>
|
||||||
|
In addition to static <code>config.yaml</code> configuration, providers can be added,
|
||||||
|
updated, and deleted at runtime via the admin API. API keys are stored encrypted
|
||||||
|
(AES-256-GCM) in the <code>provider_configs</code> table and hot-reloaded without a proxy
|
||||||
|
restart.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# List all configured providers
|
||||||
|
curl http://localhost:8090/v1/admin/providers \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Add a new provider (API key stored encrypted at rest)
|
||||||
|
curl -X POST http://localhost:8090/v1/admin/providers \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"provider": "anthropic",
|
||||||
|
"api_key": "sk-ant-...",
|
||||||
|
"base_url": "https://api.anthropic.com",
|
||||||
|
"timeout_sec": 60,
|
||||||
|
"max_conns": 10
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Update an existing provider
|
||||||
|
curl -X PUT http://localhost:8090/v1/admin/providers/provider-uuid \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"api_key": "sk-ant-new-key...", "timeout_sec": 120}'
|
||||||
|
|
||||||
|
# Test connectivity before saving
|
||||||
|
curl -X POST http://localhost:8090/v1/admin/providers/provider-uuid/test \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Remove a provider
|
||||||
|
curl -X DELETE http://localhost:8090/v1/admin/providers/provider-uuid \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Callout type="info" title="Hot-reload — no restart required">
|
||||||
|
Provider changes via the admin API take effect immediately via{" "}
|
||||||
|
<code>router.UpdateAdapter()</code> / <code>RemoveAdapter()</code>. Existing in-flight
|
||||||
|
requests are not interrupted.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Callout type="tip" title="Static config takes precedence at startup">
|
||||||
|
Providers defined in <code>config.yaml</code> are loaded first. Database-configured
|
||||||
|
providers are loaded on top — if the same provider name exists in both, the DB version
|
||||||
|
wins. This lets you override a static key without editing the config file.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<h2 id="azure-config">Azure OpenAI — Additional Fields</h2>
|
||||||
|
<p>
|
||||||
|
Azure requires a resource name and deployment ID instead of a model name:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`curl -X POST http://localhost:8090/v1/admin/providers \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"provider": "azure",
|
||||||
|
"api_key": "...",
|
||||||
|
"resource_name": "my-azure-openai-resource",
|
||||||
|
"deployment_id": "gpt-4o-prod",
|
||||||
|
"api_version": "2024-02-01",
|
||||||
|
"timeout_sec": 60,
|
||||||
|
"max_conns": 20
|
||||||
|
}'`}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2 id="provider-status">Check Provider Status</h2>
|
<h2 id="provider-status">Check Provider Status</h2>
|
||||||
<p>
|
<p>
|
||||||
The admin API exposes circuit breaker state for all providers:
|
The admin API exposes circuit breaker state for all providers:
|
||||||
@ -108,8 +181,8 @@ ollama pull codellama`}
|
|||||||
<p>Circuit breaker states:</p>
|
<p>Circuit breaker states:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>closed</strong> — Normal operation, requests forwarded</li>
|
<li><strong>closed</strong> — Normal operation, requests forwarded</li>
|
||||||
<li><strong>open</strong> — Provider bypassed, fallback chain used</li>
|
<li><strong>open</strong> — Provider bypassed after 5 consecutive failures (60s TTL)</li>
|
||||||
<li><strong>half-open</strong> — Testing if provider has recovered</li>
|
<li><strong>half-open</strong> — Testing if provider has recovered (one probe request)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user