fix error for config

This commit is contained in:
David 2026-03-06 18:38:04 +01:00
parent 410ae18d2d
commit b30c1147ea
37 changed files with 2507 additions and 696 deletions

View File

@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Veylant IA** — A B2B SaaS platform acting as an intelligent proxy/gateway for enterprise AI consumption. Core value proposition: prevent Shadow AI, enforce PII anonymization, ensure GDPR/EU AI Act compliance, and control costs across all LLM usage in an organization. **Veylant IA** — A B2B SaaS platform acting as an intelligent proxy/gateway for enterprise AI consumption. Core value proposition: prevent Shadow AI, enforce PII anonymization, ensure GDPR/EU AI Act compliance, and control costs across all LLM usage in an organization.
Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month execution plan (13 sprints, 164 tasks) is in `docs/AI_Governance_Hub_Plan_Realisation.md`. Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month execution plan (13 sprints, 164 tasks) is in `docs/AI_Governance_Hub_Plan_Realisation.md`. Architecture Decision Records live in `docs/adr/`.
## Architecture ## Architecture
@ -18,7 +18,8 @@ Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month
API Gateway (Traefik) API Gateway (Traefik)
Go Proxy [cmd/proxy] — chi router, zap logger, viper config Go Proxy [cmd/proxy] — chi router, zap logger, viper config
├── internal/middleware/ Auth (OIDC/Keycloak), RateLimit, RequestID, SecurityHeaders ├── internal/auth/ Local JWT auth (HS256) — LocalJWTVerifier + LoginHandler (POST /v1/auth/login)
├── internal/middleware/ Auth (JWT verification), RateLimit, RequestID, SecurityHeaders
├── internal/router/ RBAC enforcement + provider dispatch + fallback chain ├── internal/router/ RBAC enforcement + provider dispatch + fallback chain
├── internal/routing/ Rules engine (PostgreSQL JSONB, in-memory cache, priority ASC) ├── internal/routing/ Rules engine (PostgreSQL JSONB, in-memory cache, priority ASC)
├── internal/pii/ gRPC client to PII sidecar + /v1/pii/analyze HTTP handler ├── internal/pii/ gRPC client to PII sidecar + /v1/pii/analyze HTTP handler
@ -50,11 +51,10 @@ LLM Provider Adapters (OpenAI, Anthropic, Azure, Mistral, Ollama)
- PostgreSQL 16 — config, users, policies, processing registry (Row-Level Security for multi-tenancy; app role: `veylant_app`) - PostgreSQL 16 — config, users, policies, processing registry (Row-Level Security for multi-tenancy; app role: `veylant_app`)
- ClickHouse — analytics and immutable audit logs - ClickHouse — analytics and immutable audit logs
- Redis 7 — sessions, rate limiting, PII pseudonymization mappings (AES-256-GCM + TTL) - Redis 7 — sessions, rate limiting, PII pseudonymization mappings (AES-256-GCM + TTL)
- Keycloak — IAM, SSO, SAML 2.0/OIDC federation (dev console: http://localhost:8080, admin/admin; test users: admin@veylant.dev/admin123, user@veylant.dev/user123)
- Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin) - Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin)
- HashiCorp Vault — secrets and API key rotation (90-day cycle) - HashiCorp Vault — secrets and API key rotation (90-day cycle)
**Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via OIDC (Keycloak); `web/src/auth/` manages the auth flow. API clients live in `web/src/api/`. **Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via local JWT (stored in localStorage, auto-logout on expiry); `web/src/auth/` manages the auth flow. API clients live in `web/src/api/`.
**Documentation site** (`http://localhost:3000/docs`): public, no auth required. Root: `web/src/pages/docs/` — sections: getting-started, installation, api-reference (8 endpoints), guides (6), deployment (3), security (2), changelog. Layout components: `DocLayout.tsx` (sidebar + content + TOC), `DocSidebar.tsx` (with search), `DocBreadcrumbs.tsx`, `DocPagination.tsx`. Shared components: `components/CodeBlock.tsx`, `Callout.tsx`, `ApiEndpoint.tsx`, `ParamTable.tsx`, `TableOfContents.tsx`. Nav structure: `web/src/pages/docs/nav.ts`. Uses `@tailwindcss/typography` (added as devDependency) for prose rendering. **Documentation site** (`http://localhost:3000/docs`): public, no auth required. Root: `web/src/pages/docs/` — sections: getting-started, installation, api-reference (8 endpoints), guides (6), deployment (3), security (2), changelog. Layout components: `DocLayout.tsx` (sidebar + content + TOC), `DocSidebar.tsx` (with search), `DocBreadcrumbs.tsx`, `DocPagination.tsx`. Shared components: `components/CodeBlock.tsx`, `Callout.tsx`, `ApiEndpoint.tsx`, `ParamTable.tsx`, `TableOfContents.tsx`. Nav structure: `web/src/pages/docs/nav.ts`. Uses `@tailwindcss/typography` (added as devDependency) for prose rendering.
@ -111,9 +111,13 @@ make deploy-rollback # Roll back traffic to ACTIVE_SLOT (e.g. make deploy-rollb
**Frontend dev server** (Vite, runs on :3000): **Frontend dev server** (Vite, runs on :3000):
```bash ```bash
cd web && npm install && npm run dev cd web && npm install && npm run dev # dev server with HMR
cd web && npm run build # tsc + vite build → web/dist/
cd web && npm run lint # ESLint (max-warnings: 0)
``` ```
**Vite dev proxy:** In dev mode, all `/v1/*` requests from the frontend are proxied to `localhost:8090` (the Go proxy). No CORS issues during development.
**Run a single Go test:** **Run a single Go test:**
```bash ```bash
go test -run TestName ./internal/module/ go test -run TestName ./internal/module/
@ -128,12 +132,21 @@ pytest services/pii/tests/test_file.py::test_function
**Config override:** Any config key can be overridden via env var with the `VEYLANT_` prefix and `.``_` replacement. Example: `VEYLANT_SERVER_PORT=9090` overrides `server.port`. **Config override:** Any config key can be overridden via env var with the `VEYLANT_` prefix and `.``_` replacement. Example: `VEYLANT_SERVER_PORT=9090` overrides `server.port`.
**Auth config:** `auth.jwt_secret` (env: `VEYLANT_AUTH_JWT_SECRET`) and `auth.jwt_ttl_hours`. Login endpoint: `POST /v1/auth/login` (public). Dev credentials: `admin@veylant.dev` / `admin123`. Tokens are HS256-signed JWTs; users stored in `users` table with bcrypt password hashes (migration 000010).
**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()`).
**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`):
```bash
deploy/onboarding/onboard-tenant.sh # creates admin, seeds 4 routing templates, configures rate limits
deploy/onboarding/import-users.sh # bulk import from CSV (email, first_name, last_name, department, role)
```
## Development Mode Graceful Degradation ## Development Mode Graceful Degradation
When `server.env=development`, the proxy degrades gracefully instead of crashing: When `server.env=development`, the proxy degrades gracefully instead of crashing:
- **Keycloak unreachable** → falls back to `MockVerifier` (JWT auth bypassed; dev user injected as `admin` role)
- **PostgreSQL unreachable** → routing engine and feature flags disabled; flag store uses in-memory fallback - **PostgreSQL unreachable** → routing engine and feature flags disabled; flag store uses in-memory fallback
- **ClickHouse unreachable** → audit logging disabled - **ClickHouse unreachable** → audit logging disabled
- **PII service unreachable** → PII disabled if `pii.fail_open=true` (default) - **PII service unreachable** → PII disabled if `pii.fail_open=true` (default)
@ -191,8 +204,8 @@ These are enforced in CI and represent project-specific guardrails:
- API keys stored as SHA-256 hashes only; prefix kept for display (e.g. `sk-vyl_ab12cd34`) - API keys stored as SHA-256 hashes only; prefix kept for display (e.g. `sk-vyl_ab12cd34`)
- RBAC roles: `admin`, `manager`, `user`, `auditor` — per-model and per-department permissions. `admin`/`manager` have unrestricted model access; `user` is limited to `rbac.user_allowed_models`; `auditor` cannot call `/v1/chat/completions` by default. - RBAC roles: `admin`, `manager`, `user`, `auditor` — per-model and per-department permissions. `admin`/`manager` have unrestricted model access; `user` is limited to `rbac.user_allowed_models`; `auditor` cannot call `/v1/chat/completions` by default.
- Audit-of-the-audit: all accesses to audit logs are themselves logged - Audit-of-the-audit: all accesses to audit logs are themselves logged
- CI pipeline: Semgrep (SAST), Trivy (image scanning, CRITICAL/HIGH blocking), gitleaks (secret detection), OWASP ZAP DAST (non-blocking, main branch only) - CI pipeline (`.github/workflows/ci.yml`): Go build/test/lint, Python format/lint/test, Semgrep SAST, Trivy container scan (CRITICAL/HIGH blocking), gitleaks, OWASP ZAP DAST (non-blocking, main only), k6 smoke test + blue/green Helm staging deploy (main only)
- Release pipeline (`v*` tag push): multi-arch Docker image (amd64/arm64) → GHCR, Helm chart → GHCR OCI, GitHub Release with notes extracted from CHANGELOG.md - Release pipeline (`.github/workflows/release.yml`, on `v*` tag): multi-arch Docker image (amd64/arm64) → GHCR, Helm chart → GHCR OCI, GitHub Release with notes extracted from CHANGELOG.md
## MVP Scope (V1) ## MVP Scope (V1)

View File

@ -20,6 +20,7 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" // register pgx driver _ "github.com/jackc/pgx/v5/stdlib" // register pgx driver
"github.com/veylant/ia-gateway/internal/admin" "github.com/veylant/ia-gateway/internal/admin"
"github.com/veylant/ia-gateway/internal/auth"
"github.com/veylant/ia-gateway/internal/auditlog" "github.com/veylant/ia-gateway/internal/auditlog"
"github.com/veylant/ia-gateway/internal/circuitbreaker" "github.com/veylant/ia-gateway/internal/circuitbreaker"
"github.com/veylant/ia-gateway/internal/compliance" "github.com/veylant/ia-gateway/internal/compliance"
@ -52,21 +53,10 @@ func main() {
logger := buildLogger(cfg.Log.Level, cfg.Log.Format) logger := buildLogger(cfg.Log.Level, cfg.Log.Format)
defer logger.Sync() //nolint:errcheck defer logger.Sync() //nolint:errcheck
// ── JWT / OIDC verifier ─────────────────────────────────────────────────── // ── Local JWT verifier (email/password auth — replaces Keycloak OIDC) ────
issuerURL := fmt.Sprintf("%s/realms/%s", cfg.Keycloak.BaseURL, cfg.Keycloak.Realm)
logger.Info("initialising OIDC verifier", zap.String("issuer", issuerURL))
ctx := context.Background() ctx := context.Background()
oidcVerifier, err := middleware.NewOIDCVerifier(ctx, issuerURL, cfg.Keycloak.ClientID) jwtVerifier := auth.NewLocalJWTVerifier(cfg.Auth.JWTSecret)
if err != nil { logger.Info("local JWT verifier initialised")
if cfg.Server.Env == "development" {
logger.Warn("OIDC verifier unavailable — JWT auth will reject all requests",
zap.Error(err))
oidcVerifier = nil
} else {
logger.Fatal("failed to initialise OIDC verifier", zap.Error(err))
}
}
// ── LLM provider adapters ───────────────────────────────────────────────── // ── LLM provider adapters ─────────────────────────────────────────────────
adapters := map[string]provider.Adapter{} adapters := map[string]provider.Adapter{}
@ -199,6 +189,11 @@ func main() {
logger.Warn("VEYLANT_CRYPTO_AES_KEY_BASE64 not set — prompt encryption disabled") logger.Warn("VEYLANT_CRYPTO_AES_KEY_BASE64 not set — prompt encryption disabled")
} }
// ── Load provider configs from DB (supplements static config-based adapters) ─
if db != nil {
loadProvidersFromDB(ctx, db, providerRouter, encryptor, logger)
}
// ── ClickHouse audit logger (optional) ──────────────────────────────────── // ── ClickHouse audit logger (optional) ────────────────────────────────────
var auditLogger auditlog.Logger var auditLogger auditlog.Logger
if cfg.ClickHouse.DSN != "" { if cfg.ClickHouse.DSN != "" {
@ -298,23 +293,13 @@ func main() {
r.Get(cfg.Metrics.Path, promhttp.Handler().ServeHTTP) r.Get(cfg.Metrics.Path, promhttp.Handler().ServeHTTP)
} }
// Public login endpoint — must be registered before the auth middleware below.
loginHandler := auth.NewLoginHandler(db, cfg.Auth.JWTSecret, cfg.Auth.JWTTTLHours, logger)
r.Post("/v1/auth/login", loginHandler.ServeHTTP)
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Use(middleware.CORS(cfg.Server.AllowedOrigins)) r.Use(middleware.CORS(cfg.Server.AllowedOrigins))
var authMW func(http.Handler) http.Handler r.Use(middleware.Auth(jwtVerifier))
if oidcVerifier != nil {
authMW = middleware.Auth(oidcVerifier)
} else {
authMW = middleware.Auth(&middleware.MockVerifier{
Claims: &middleware.UserClaims{
UserID: "dev-user",
TenantID: "00000000-0000-0000-0000-000000000001",
Email: "dev@veylant.local",
Roles: []string{"admin"},
},
})
logger.Warn("running in DEV mode — JWT validation is DISABLED")
}
r.Use(authMW)
r.Use(middleware.RateLimit(rateLimiter)) r.Use(middleware.RateLimit(rateLimiter))
r.Post("/chat/completions", proxyHandler.ServeHTTP) r.Post("/chat/completions", proxyHandler.ServeHTTP)
@ -340,8 +325,8 @@ func main() {
logger, logger,
) )
} }
// Wire db, router, rate limiter, and feature flags (Sprint 8 + Sprint 10 + Sprint 11). // Wire db, router, rate limiter, feature flags, and encryptor.
adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore) adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)
r.Route("/admin", adminHandler.Routes) r.Route("/admin", adminHandler.Routes)
} }
@ -375,7 +360,7 @@ func main() {
zap.String("addr", addr), zap.String("addr", addr),
zap.String("env", cfg.Server.Env), zap.String("env", cfg.Server.Env),
zap.Bool("metrics", cfg.Metrics.Enabled), zap.Bool("metrics", cfg.Metrics.Enabled),
zap.String("oidc_issuer", issuerURL), zap.String("auth", "local-jwt"),
zap.Bool("audit_logging", auditLogger != nil), zap.Bool("audit_logging", auditLogger != nil),
zap.Bool("encryption", encryptor != nil), zap.Bool("encryption", encryptor != nil),
) )
@ -402,6 +387,61 @@ func main() {
logger.Info("server stopped cleanly") logger.Info("server stopped cleanly")
} }
// loadProvidersFromDB reads active provider_configs from the database and
// registers/updates adapters on the router. Called once at startup so DB-saved
// configs take precedence over (or extend) the static config.yaml ones.
func loadProvidersFromDB(ctx context.Context, db *sql.DB, providerRouter *router.Router, enc *crypto.Encryptor, logger *zap.Logger) {
rows, err := db.QueryContext(ctx,
`SELECT provider, api_key_enc, base_url, resource_name, deployment_id,
api_version, timeout_sec, max_conns, model_patterns
FROM provider_configs WHERE is_active = TRUE`)
if err != nil {
logger.Warn("failed to load provider configs from DB", zap.Error(err))
return
}
defer rows.Close()
count := 0
for rows.Next() {
var (
prov, apiKeyEnc, baseURL, resourceName, deploymentID, apiVersion string
timeoutSec, maxConns int
patternsRaw string
)
if err := rows.Scan(&prov, &apiKeyEnc, &baseURL, &resourceName,
&deploymentID, &apiVersion, &timeoutSec, &maxConns, &patternsRaw); err != nil {
logger.Warn("failed to scan provider config row", zap.Error(err))
continue
}
// Decrypt API key
apiKey := apiKeyEnc
if enc != nil && apiKeyEnc != "" {
if dec, decErr := enc.Decrypt(apiKeyEnc); decErr == nil {
apiKey = dec
}
}
cfg := &admin.ProviderConfig{
Provider: prov,
BaseURL: baseURL,
ResourceName: resourceName,
DeploymentID: deploymentID,
APIVersion: apiVersion,
TimeoutSec: timeoutSec,
MaxConns: maxConns,
}
adapter := admin.BuildProviderAdapter(cfg, apiKey)
if adapter != nil {
providerRouter.UpdateAdapter(prov, adapter)
count++
}
}
if count > 0 {
logger.Info("provider configs loaded from DB", zap.Int("count", count))
}
}
func buildLogger(level, format string) *zap.Logger { func buildLogger(level, format string) *zap.Logger {
lvl := zap.InfoLevel lvl := zap.InfoLevel
if err := lvl.UnmarshalText([]byte(level)); err != nil { if err := lvl.UnmarshalText([]byte(level)); err != nil {

View File

@ -17,10 +17,11 @@ database:
redis: redis:
url: "redis://localhost:6379" url: "redis://localhost:6379"
keycloak: # Local JWT authentication (email/password — replaces Keycloak).
base_url: "http://localhost:8080" # Override jwt_secret in production via VEYLANT_AUTH_JWT_SECRET.
realm: "veylant" auth:
client_id: "veylant-proxy" jwt_secret: "change-me-in-production-use-VEYLANT_AUTH_JWT_SECRET"
jwt_ttl_hours: 24
pii: pii:
enabled: true enabled: true

View File

@ -63,30 +63,6 @@ services:
soft: 262144 soft: 262144
hard: 262144 hard: 262144
# ─────────────────────────────────────────────
# Keycloak 24 — IAM, OIDC, SAML 2.0
# start-dev: in-memory DB, no TLS — development only
# Realm is auto-imported from deploy/keycloak/realm-export.json
# ─────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: dev-mem
KC_HEALTH_ENABLED: "true"
ports:
- "8080:8080"
volumes:
- ./deploy/keycloak:/opt/keycloak/data/import:ro
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/ready || exit 1"]
interval: 10s
timeout: 10s
retries: 20
start_period: 30s # Keycloak takes ~20s to start in dev mode
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Veylant proxy — Go application # Veylant proxy — Go application
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@ -101,9 +77,9 @@ services:
VEYLANT_SERVER_ENV: "development" VEYLANT_SERVER_ENV: "development"
VEYLANT_DATABASE_URL: "postgres://veylant:veylant_dev@postgres:5432/veylant?sslmode=disable" VEYLANT_DATABASE_URL: "postgres://veylant:veylant_dev@postgres:5432/veylant?sslmode=disable"
VEYLANT_REDIS_URL: "redis://redis:6379" VEYLANT_REDIS_URL: "redis://redis:6379"
VEYLANT_KEYCLOAK_BASE_URL: "http://keycloak:8080" # Local JWT secret — override in production: VEYLANT_AUTH_JWT_SECRET=<strong-secret>
VEYLANT_KEYCLOAK_REALM: "veylant" VEYLANT_AUTH_JWT_SECRET: "${VEYLANT_AUTH_JWT_SECRET:-dev-jwt-secret-change-in-prod}"
VEYLANT_KEYCLOAK_CLIENT_ID: "veylant-proxy" VEYLANT_AUTH_JWT_TTL_HOURS: "24"
VEYLANT_PII_ENABLED: "true" VEYLANT_PII_ENABLED: "true"
VEYLANT_PII_SERVICE_ADDR: "pii:50051" VEYLANT_PII_SERVICE_ADDR: "pii:50051"
VEYLANT_PII_TIMEOUT_MS: "100" VEYLANT_PII_TIMEOUT_MS: "100"
@ -155,10 +131,10 @@ services:
PII_HTTP_PORT: "8000" PII_HTTP_PORT: "8000"
PII_REDIS_URL: "redis://redis:6379" PII_REDIS_URL: "redis://redis:6379"
# PII_ENCRYPTION_KEY must be set to a 32-byte base64-encoded key in production. # PII_ENCRYPTION_KEY must be set to a 32-byte base64-encoded key in production.
# The default dev key is used if unset (NOT safe for production). # Dev default = 32 zero bytes (NOT safe for production).
PII_ENCRYPTION_KEY: "${PII_ENCRYPTION_KEY:-}" PII_ENCRYPTION_KEY: "${PII_ENCRYPTION_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}"
PII_NER_ENABLED: "true" PII_NER_ENABLED: "true"
PII_NER_CONFIDENCE: "0.85" PII_NER_CONFIDENCE: "0.65"
PII_TTL_SECONDS: "3600" PII_TTL_SECONDS: "3600"
depends_on: depends_on:
redis: redis:
@ -221,8 +197,8 @@ services:
- ./web:/app - ./web:/app
- /app/node_modules - /app/node_modules
environment: environment:
VITE_AUTH_MODE: "dev" VITE_AUTH_MODE: "email"
VITE_KEYCLOAK_URL: "http://localhost:8080/realms/veylant" VITE_PROXY_TARGET: "http://proxy:8090"
depends_on: depends_on:
proxy: proxy:
condition: service_started condition: service_started

1
go.mod
View File

@ -44,6 +44,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-pdf/fpdf v0.9.0 // indirect github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

2
go.sum
View File

@ -70,6 +70,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

View File

@ -17,6 +17,7 @@ import (
"github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/auditlog" "github.com/veylant/ia-gateway/internal/auditlog"
"github.com/veylant/ia-gateway/internal/circuitbreaker" "github.com/veylant/ia-gateway/internal/circuitbreaker"
"github.com/veylant/ia-gateway/internal/crypto"
"github.com/veylant/ia-gateway/internal/flags" "github.com/veylant/ia-gateway/internal/flags"
"github.com/veylant/ia-gateway/internal/middleware" "github.com/veylant/ia-gateway/internal/middleware"
"github.com/veylant/ia-gateway/internal/ratelimit" "github.com/veylant/ia-gateway/internal/ratelimit"
@ -33,15 +34,17 @@ type ProviderRouter interface {
// read-only access to audit logs and cost aggregations, user management, // read-only access to audit logs and cost aggregations, user management,
// provider circuit breaker status, rate limit configuration, and feature flags. // provider circuit breaker status, rate limit configuration, and feature flags.
type Handler struct { type Handler struct {
store routing.RuleStore store routing.RuleStore
cache *routing.RuleCache cache *routing.RuleCache
auditLogger auditlog.Logger // nil = logs/costs endpoints return 501 auditLogger auditlog.Logger // nil = logs/costs endpoints return 501
db *sql.DB // nil = users endpoints return 501 db *sql.DB // nil = users endpoints return 501
router ProviderRouter // nil = providers/status returns 501 router ProviderRouter // nil = providers/status returns 501
rateLimiter *ratelimit.Limiter // nil = rate-limits endpoints return 501 adapterRouter ProviderAdapterRouter // nil = provider CRUD hot-reload disabled
rlStore *ratelimit.Store // nil if db is nil encryptor *crypto.Encryptor // nil = API keys stored in plaintext (dev only)
flagStore flags.FlagStore // nil = flags endpoints return 501 rateLimiter *ratelimit.Limiter // nil = rate-limits endpoints return 501
logger *zap.Logger rlStore *ratelimit.Store // nil if db is nil
flagStore flags.FlagStore // nil = flags endpoints return 501
logger *zap.Logger
} }
// New creates a Handler. // New creates a Handler.
@ -65,6 +68,16 @@ func (h *Handler) WithDB(db *sql.DB) *Handler {
// WithRouter adds provider router for circuit breaker status. // WithRouter adds provider router for circuit breaker status.
func (h *Handler) WithRouter(r ProviderRouter) *Handler { func (h *Handler) WithRouter(r ProviderRouter) *Handler {
h.router = r h.router = r
// If the router also supports adapter hot-reload, wire it up.
if ar, ok := r.(ProviderAdapterRouter); ok {
h.adapterRouter = ar
}
return h
}
// WithEncryptor adds an AES-256-GCM encryptor for provider API key storage.
func (h *Handler) WithEncryptor(enc *crypto.Encryptor) *Handler {
h.encryptor = enc
return h return h
} }
@ -108,6 +121,13 @@ func (h *Handler) Routes(r chi.Router) {
// Provider circuit breaker status (E2-09 / E2-10). // Provider circuit breaker status (E2-09 / E2-10).
r.Get("/providers/status", h.getProviderStatus) r.Get("/providers/status", h.getProviderStatus)
// Provider config CRUD — stored in database, API keys encrypted at rest.
r.Get("/providers", h.listProviderConfigs)
r.Post("/providers", h.createProviderConfig)
r.Put("/providers/{id}", h.updateProviderConfig)
r.Delete("/providers/{id}", h.deleteProviderConfig)
r.Post("/providers/{id}/test", h.testProviderConfig)
// Rate limit configuration (E10-09). // Rate limit configuration (E10-09).
r.Get("/rate-limits", h.listRateLimits) r.Get("/rate-limits", h.listRateLimits)
r.Get("/rate-limits/{tenant_id}", h.getRateLimit) r.Get("/rate-limits/{tenant_id}", h.getRateLimit)

View File

@ -0,0 +1,611 @@
package admin
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/crypto"
"github.com/veylant/ia-gateway/internal/middleware"
"github.com/veylant/ia-gateway/internal/provider"
"github.com/veylant/ia-gateway/internal/provider/anthropic"
"github.com/veylant/ia-gateway/internal/provider/azure"
"github.com/veylant/ia-gateway/internal/provider/mistral"
"github.com/veylant/ia-gateway/internal/provider/ollama"
"github.com/veylant/ia-gateway/internal/provider/openai"
)
// ProviderAdapterRouter is the interface used by the admin handler to update
// adapters at runtime. Defined here to avoid import cycles with internal/router.
type ProviderAdapterRouter interface {
ProviderRouter
UpdateAdapter(name string, adapter provider.Adapter)
RemoveAdapter(name string)
}
// ProviderConfig is the DB/JSON representation of a configured LLM provider.
type ProviderConfig struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
APIKeyMasked string `json:"api_key,omitempty"` // masked on read, plain on write
BaseURL string `json:"base_url,omitempty"`
ResourceName string `json:"resource_name,omitempty"`
DeploymentID string `json:"deployment_id,omitempty"`
APIVersion string `json:"api_version,omitempty"`
TimeoutSec int `json:"timeout_sec"`
MaxConns int `json:"max_conns"`
ModelPatterns []string `json:"model_patterns"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type upsertProviderRequest struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
APIKey string `json:"api_key"` // plaintext, encrypted before storage
BaseURL string `json:"base_url"`
ResourceName string `json:"resource_name"`
DeploymentID string `json:"deployment_id"`
APIVersion string `json:"api_version"`
TimeoutSec int `json:"timeout_sec"`
MaxConns int `json:"max_conns"`
ModelPatterns []string `json:"model_patterns"`
IsActive *bool `json:"is_active"`
}
// providerStore wraps *sql.DB for provider_configs CRUD.
type providerStore struct {
db *sql.DB
encryptor *crypto.Encryptor
logger *zap.Logger
}
func newProviderStore(db *sql.DB, enc *crypto.Encryptor, logger *zap.Logger) *providerStore {
return &providerStore{db: db, encryptor: enc, logger: logger}
}
func (s *providerStore) list(ctx context.Context, tenantID string) ([]ProviderConfig, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, provider, display_name, api_key_enc, base_url,
resource_name, deployment_id, api_version, timeout_sec, max_conns,
model_patterns, is_active, created_at, updated_at
FROM provider_configs
WHERE tenant_id = $1
ORDER BY created_at ASC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []ProviderConfig
for rows.Next() {
var c ProviderConfig
var apiKeyEnc string
var patterns []string
if err := rows.Scan(&c.ID, &c.TenantID, &c.Provider, &c.DisplayName,
&apiKeyEnc, &c.BaseURL, &c.ResourceName, &c.DeploymentID, &c.APIVersion,
&c.TimeoutSec, &c.MaxConns, (*pqStringArray)(&patterns),
&c.IsActive, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.ModelPatterns = patterns
c.APIKeyMasked = maskAPIKey(apiKeyEnc, s.encryptor)
configs = append(configs, c)
}
return configs, rows.Err()
}
func (s *providerStore) get(ctx context.Context, id, tenantID string) (*ProviderConfig, string, error) {
var c ProviderConfig
var apiKeyEnc string
var patterns []string
err := s.db.QueryRowContext(ctx,
`SELECT id, tenant_id, provider, display_name, api_key_enc, base_url,
resource_name, deployment_id, api_version, timeout_sec, max_conns,
model_patterns, is_active, created_at, updated_at
FROM provider_configs WHERE id = $1 AND tenant_id = $2`, id, tenantID,
).Scan(&c.ID, &c.TenantID, &c.Provider, &c.DisplayName,
&apiKeyEnc, &c.BaseURL, &c.ResourceName, &c.DeploymentID, &c.APIVersion,
&c.TimeoutSec, &c.MaxConns, (*pqStringArray)(&patterns),
&c.IsActive, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, "", nil
}
c.ModelPatterns = patterns
return &c, apiKeyEnc, err
}
func (s *providerStore) create(ctx context.Context, tenantID string, req upsertProviderRequest) (*ProviderConfig, error) {
apiKeyEnc := encryptKey(req.APIKey, s.encryptor)
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
timeoutSec := req.TimeoutSec
if timeoutSec == 0 {
timeoutSec = defaultTimeout(req.Provider)
}
maxConns := req.MaxConns
if maxConns == 0 {
maxConns = 100
}
apiVersion := req.APIVersion
if apiVersion == "" {
apiVersion = "2024-02-01"
}
var c ProviderConfig
var storedPatterns []string
err := s.db.QueryRowContext(ctx,
`INSERT INTO provider_configs
(tenant_id, provider, display_name, api_key_enc, base_url, resource_name,
deployment_id, api_version, timeout_sec, max_conns, model_patterns, is_active)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::text[],$12)
ON CONFLICT (tenant_id, provider) DO UPDATE SET
display_name = EXCLUDED.display_name,
api_key_enc = CASE WHEN EXCLUDED.api_key_enc = '' THEN provider_configs.api_key_enc ELSE EXCLUDED.api_key_enc END,
base_url = EXCLUDED.base_url,
resource_name = EXCLUDED.resource_name,
deployment_id = EXCLUDED.deployment_id,
api_version = EXCLUDED.api_version,
timeout_sec = EXCLUDED.timeout_sec,
max_conns = EXCLUDED.max_conns,
model_patterns= EXCLUDED.model_patterns,
is_active = EXCLUDED.is_active,
updated_at = NOW()
RETURNING id, tenant_id, provider, display_name, api_key_enc, base_url,
resource_name, deployment_id, api_version, timeout_sec, max_conns,
model_patterns, is_active, created_at, updated_at`,
tenantID, req.Provider, displayName(req), apiKeyEnc, req.BaseURL, req.ResourceName,
req.DeploymentID, apiVersion, timeoutSec, maxConns,
modelPatternsLiteral(req.ModelPatterns), isActive,
).Scan(&c.ID, &c.TenantID, &c.Provider, &c.DisplayName,
&apiKeyEnc, &c.BaseURL, &c.ResourceName, &c.DeploymentID, &c.APIVersion,
&c.TimeoutSec, &c.MaxConns, (*pqStringArray)(&storedPatterns),
&c.IsActive, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
c.ModelPatterns = storedPatterns
c.APIKeyMasked = maskAPIKey(apiKeyEnc, s.encryptor)
return &c, nil
}
func (s *providerStore) delete(ctx context.Context, id, tenantID string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM provider_configs WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// ─── HTTP handlers ────────────────────────────────────────────────────────────
func (h *Handler) listProviderConfigs(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFromCtx(w, r)
if !ok {
return
}
if h.db == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
return
}
ps := newProviderStore(h.db, h.encryptor, h.logger)
configs, err := ps.list(r.Context(), tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to list providers: "+err.Error()))
return
}
if configs == nil {
configs = []ProviderConfig{}
}
// Enrich with circuit breaker status if available
type enriched struct {
ProviderConfig
State string `json:"state,omitempty"`
Failures int `json:"failures,omitempty"`
}
statuses := map[string]string{}
failures := map[string]int{}
if h.router != nil {
for _, s := range h.router.ProviderStatuses() {
statuses[s.Provider] = s.State
failures[s.Provider] = s.Failures
}
}
result := make([]enriched, len(configs))
for i, c := range configs {
result[i] = enriched{
ProviderConfig: c,
State: statuses[c.Provider],
Failures: failures[c.Provider],
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"data": result})
}
func (h *Handler) createProviderConfig(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFromCtx(w, r)
if !ok {
return
}
if h.db == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
return
}
var req upsertProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
return
}
if !validProvider(req.Provider) {
apierror.WriteError(w, apierror.NewBadRequestError("provider must be one of: openai, anthropic, azure, mistral, ollama"))
return
}
ps := newProviderStore(h.db, h.encryptor, h.logger)
cfg, err := ps.create(r.Context(), tenantID, req)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to save provider: "+err.Error()))
return
}
// Rebuild and hot-reload the adapter
h.reloadAdapter(r.Context(), cfg, req.APIKey)
h.logger.Info("provider config saved", zap.String("provider", cfg.Provider), zap.String("tenant", tenantID))
writeJSON(w, http.StatusCreated, cfg)
}
func (h *Handler) updateProviderConfig(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFromCtx(w, r)
if !ok {
return
}
if h.db == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
return
}
id := chi.URLParam(r, "id")
var req upsertProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
return
}
ps := newProviderStore(h.db, h.encryptor, h.logger)
// Fetch existing to get provider name if not supplied
existing, existingKeyEnc, err := ps.get(r.Context(), id, tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
return
}
if existing == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "provider not found", HTTPStatus: http.StatusNotFound})
return
}
if req.Provider == "" {
req.Provider = existing.Provider
}
// If no new API key provided, keep the existing encrypted one by passing empty string
// (the upsert handles this via the CASE statement)
decryptedKey := req.APIKey
if decryptedKey == "" && existingKeyEnc != "" {
// Decrypt existing key to pass to adapter rebuild
if h.encryptor != nil {
if dec, decErr := h.encryptor.Decrypt(existingKeyEnc); decErr == nil {
decryptedKey = dec
}
} else {
decryptedKey = existingKeyEnc
}
}
cfg, err := ps.create(r.Context(), tenantID, req) // upsert
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to update provider: "+err.Error()))
return
}
h.reloadAdapter(r.Context(), cfg, decryptedKey)
h.logger.Info("provider config updated", zap.String("provider", cfg.Provider), zap.String("id", id))
writeJSON(w, http.StatusOK, cfg)
}
func (h *Handler) deleteProviderConfig(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFromCtx(w, r)
if !ok {
return
}
if h.db == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
return
}
id := chi.URLParam(r, "id")
ps := newProviderStore(h.db, h.encryptor, h.logger)
existing, _, err := ps.get(r.Context(), id, tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
return
}
if existing == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "provider not found", HTTPStatus: http.StatusNotFound})
return
}
if err := ps.delete(r.Context(), id, tenantID); err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to delete provider: "+err.Error()))
return
}
// Remove adapter from router (config-based providers survive if they have an API key in env)
if h.adapterRouter != nil {
h.adapterRouter.RemoveAdapter(existing.Provider)
}
h.logger.Info("provider config deleted", zap.String("provider", existing.Provider))
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) testProviderConfig(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFromCtx(w, r)
if !ok {
return
}
if h.db == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
return
}
id := chi.URLParam(r, "id")
ps := newProviderStore(h.db, h.encryptor, h.logger)
existing, apiKeyEnc, err := ps.get(r.Context(), id, tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
return
}
if existing == nil {
apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "provider not found", HTTPStatus: http.StatusNotFound})
return
}
apiKey := ""
if h.encryptor != nil && apiKeyEnc != "" {
if dec, decErr := h.encryptor.Decrypt(apiKeyEnc); decErr == nil {
apiKey = dec
}
} else {
apiKey = apiKeyEnc
}
adapter := buildAdapter(existing, apiKey)
if adapter == nil {
apierror.WriteError(w, apierror.NewBadRequestError("cannot build adapter for provider "+existing.Provider))
return
}
if err := adapter.HealthCheck(r.Context()); err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"healthy": false,
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"healthy": true})
}
// ─── Adapter helpers ──────────────────────────────────────────────────────────
// reloadAdapter rebuilds the provider adapter from cfg and registers it on the router.
func (h *Handler) reloadAdapter(_ context.Context, cfg *ProviderConfig, apiKey string) {
if h.adapterRouter == nil {
return
}
adapter := buildAdapter(cfg, apiKey)
if adapter != nil {
h.adapterRouter.UpdateAdapter(cfg.Provider, adapter)
}
}
// BuildProviderAdapter constructs the correct provider.Adapter from a ProviderConfig.
// Exported so main.go can call it when loading provider configs from DB at startup.
func BuildProviderAdapter(cfg *ProviderConfig, apiKey string) provider.Adapter {
return buildAdapter(cfg, apiKey)
}
// buildAdapter constructs the correct provider.Adapter from a ProviderConfig.
func buildAdapter(cfg *ProviderConfig, apiKey string) provider.Adapter {
switch cfg.Provider {
case "openai":
return openai.New(openai.Config{
APIKey: apiKey,
BaseURL: orDefault(cfg.BaseURL, "https://api.openai.com/v1"),
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
MaxConns: orDefaultInt(cfg.MaxConns, 100),
})
case "anthropic":
return anthropic.New(anthropic.Config{
APIKey: apiKey,
BaseURL: orDefault(cfg.BaseURL, "https://api.anthropic.com/v1"),
Version: "2023-06-01",
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
MaxConns: orDefaultInt(cfg.MaxConns, 100),
})
case "azure":
return azure.New(azure.Config{
APIKey: apiKey,
ResourceName: cfg.ResourceName,
DeploymentID: cfg.DeploymentID,
APIVersion: orDefault(cfg.APIVersion, "2024-02-01"),
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
MaxConns: orDefaultInt(cfg.MaxConns, 100),
})
case "mistral":
return mistral.New(mistral.Config{
APIKey: apiKey,
BaseURL: orDefault(cfg.BaseURL, "https://api.mistral.ai/v1"),
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
MaxConns: orDefaultInt(cfg.MaxConns, 100),
})
case "ollama":
return ollama.New(ollama.Config{
BaseURL: orDefault(cfg.BaseURL, "http://localhost:11434/v1"),
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 120),
MaxConns: orDefaultInt(cfg.MaxConns, 10),
})
}
return nil
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func validProvider(p string) bool {
switch p {
case "openai", "anthropic", "azure", "mistral", "ollama":
return true
}
return false
}
func defaultTimeout(provider string) int {
if provider == "ollama" {
return 120
}
return 30
}
func displayName(req upsertProviderRequest) string {
if req.DisplayName != "" {
return req.DisplayName
}
return strings.Title(req.Provider) //nolint:staticcheck
}
func encryptKey(key string, enc *crypto.Encryptor) string {
if key == "" {
return ""
}
if enc == nil {
return key // dev mode: store plaintext
}
encrypted, err := enc.Encrypt(key)
if err != nil {
return key // fallback to plaintext on error
}
return encrypted
}
func maskAPIKey(apiKeyEnc string, enc *crypto.Encryptor) string {
if apiKeyEnc == "" {
return ""
}
// Decrypt to get the real key, then mask it
plaintext := apiKeyEnc
if enc != nil {
if dec, err := enc.Decrypt(apiKeyEnc); err == nil {
plaintext = dec
}
}
if len(plaintext) <= 8 {
return strings.Repeat("•", len(plaintext))
}
return plaintext[:8] + "••••••••"
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func orDefaultInt(v, def int) int {
if v == 0 {
return def
}
return v
}
// pqStringArray is a simple []string scanner for PostgreSQL TEXT[] columns.
type pqStringArray []string
func (a *pqStringArray) Scan(src interface{}) error {
if src == nil {
*a = []string{}
return nil
}
// PostgreSQL returns TEXT[] as "{val1,val2,...}" string
s, ok := src.(string)
if !ok {
if b, ok := src.([]byte); ok {
s = string(b)
} else {
*a = []string{}
return nil
}
}
s = strings.TrimPrefix(s, "{")
s = strings.TrimSuffix(s, "}")
if s == "" {
*a = []string{}
return nil
}
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.Trim(p, `"`)
if p != "" {
result = append(result, p)
}
}
*a = result
return nil
}
// Value implements driver.Valuer for pqStringArray (for INSERT).
func (a pqStringArray) Value() (interface{}, error) {
if len(a) == 0 {
return "{}", nil
}
parts := make([]string, len(a))
for i, v := range a {
parts[i] = `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
}
return "{" + strings.Join(parts, ",") + "}", nil
}
// modelPatternsLiteral converts a []string to a PostgreSQL array literal string
// (e.g. {"a","b"}) to be used with $N::text[] in queries.
// This avoids driver.Valuer issues with the pgx stdlib adapter.
func modelPatternsLiteral(patterns []string) string {
if len(patterns) == 0 {
return "{}"
}
parts := make([]string, len(patterns))
for i, v := range patterns {
parts[i] = `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
}
return "{" + strings.Join(parts, ",") + "}"
}
// claimsFromRequest is a local helper (avoids import of middleware in this file).
func claimsFromRequest(r *http.Request) *middleware.UserClaims {
claims, _ := middleware.ClaimsFromContext(r.Context())
return claims
}

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/middleware" "github.com/veylant/ia-gateway/internal/middleware"
@ -32,6 +33,7 @@ type createUserRequest struct {
Department string `json:"department"` Department string `json:"department"`
Role string `json:"role"` Role string `json:"role"`
IsActive *bool `json:"is_active"` IsActive *bool `json:"is_active"`
Password string `json:"password"` // optional; bcrypt-hashed before storage
} }
// userStore wraps a *sql.DB to perform user CRUD operations. // userStore wraps a *sql.DB to perform user CRUD operations.
@ -77,27 +79,38 @@ func (s *userStore) get(id, tenantID string) (*User, error) {
return &u, err return &u, err
} }
func (s *userStore) create(u User) (*User, error) { func (s *userStore) create(u User, passwordHash string) (*User, error) {
var created User var created User
err := s.db.QueryRow( err := s.db.QueryRow(
`INSERT INTO users (tenant_id, email, name, department, role, is_active) `INSERT INTO users (tenant_id, email, name, department, role, is_active, password_hash)
VALUES ($1,$2,$3,$4,$5,$6) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`, RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
u.TenantID, u.Email, u.Name, u.Department, u.Role, u.IsActive, u.TenantID, u.Email, u.Name, u.Department, u.Role, u.IsActive, passwordHash,
).Scan(&created.ID, &created.TenantID, &created.Email, &created.Name, &created.Department, ).Scan(&created.ID, &created.TenantID, &created.Email, &created.Name, &created.Department,
&created.Role, &created.IsActive, &created.CreatedAt, &created.UpdatedAt) &created.Role, &created.IsActive, &created.CreatedAt, &created.UpdatedAt)
return &created, err return &created, err
} }
func (s *userStore) update(u User) (*User, error) { func (s *userStore) update(u User, passwordHash string) (*User, error) {
var updated User var updated User
err := s.db.QueryRow( var err error
`UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, updated_at=NOW() if passwordHash != "" {
WHERE id=$6 AND tenant_id=$7 err = s.db.QueryRow(
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`, `UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, password_hash=$6, updated_at=NOW()
u.Email, u.Name, u.Department, u.Role, u.IsActive, u.ID, u.TenantID, WHERE id=$7 AND tenant_id=$8
).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department, RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
&updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt) u.Email, u.Name, u.Department, u.Role, u.IsActive, passwordHash, u.ID, u.TenantID,
).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department,
&updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt)
} else {
err = s.db.QueryRow(
`UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, updated_at=NOW()
WHERE id=$6 AND tenant_id=$7
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
u.Email, u.Name, u.Department, u.Role, u.IsActive, u.ID, u.TenantID,
).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department,
&updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt)
}
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -173,6 +186,16 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
isActive = *req.IsActive isActive = *req.IsActive
} }
passwordHash := ""
if req.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to hash password"))
return
}
passwordHash = string(hash)
}
us := newUserStore(h.db, h.logger) us := newUserStore(h.db, h.logger)
created, err := us.create(User{ created, err := us.create(User{
TenantID: tenantID, TenantID: tenantID,
@ -181,7 +204,7 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
Department: req.Department, Department: req.Department,
Role: role, Role: role,
IsActive: isActive, IsActive: isActive,
}) }, passwordHash)
if err != nil { if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to create user: "+err.Error())) apierror.WriteError(w, apierror.NewUpstreamError("failed to create user: "+err.Error()))
return return
@ -233,6 +256,16 @@ func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) {
isActive = *req.IsActive isActive = *req.IsActive
} }
passwordHash := ""
if req.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to hash password"))
return
}
passwordHash = string(hash)
}
us := newUserStore(h.db, h.logger) us := newUserStore(h.db, h.logger)
updated, err := us.update(User{ updated, err := us.update(User{
ID: id, ID: id,
@ -242,7 +275,7 @@ func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) {
Department: req.Department, Department: req.Department,
Role: req.Role, Role: req.Role,
IsActive: isActive, IsActive: isActive,
}) }, passwordHash)
if err != nil { if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError(err.Error())) apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
return return

80
internal/auth/jwt.go Normal file
View File

@ -0,0 +1,80 @@
// Package auth provides local JWT-based authentication replacing Keycloak OIDC.
// Tokens are signed with HMAC-SHA256 using a secret configured via VEYLANT_AUTH_JWT_SECRET.
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/veylant/ia-gateway/internal/middleware"
)
// LocalJWTVerifier implements middleware.TokenVerifier using self-signed HS256 tokens.
type LocalJWTVerifier struct {
secret []byte
}
// NewLocalJWTVerifier creates a verifier that validates tokens signed with secret.
func NewLocalJWTVerifier(secret string) *LocalJWTVerifier {
return &LocalJWTVerifier{secret: []byte(secret)}
}
// veylantClaims is the JWT payload structure for tokens we issue.
type veylantClaims struct {
Email string `json:"email"`
TenantID string `json:"tenant_id"`
Roles []string `json:"roles"`
Department string `json:"department,omitempty"`
Name string `json:"name,omitempty"`
jwt.RegisteredClaims
}
// Verify implements middleware.TokenVerifier.
func (v *LocalJWTVerifier) Verify(_ context.Context, rawToken string) (*middleware.UserClaims, error) {
token, err := jwt.ParseWithClaims(rawToken, &veylantClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return v.secret, nil
})
if err != nil {
return nil, fmt.Errorf("token verification failed: %w", err)
}
claims, ok := token.Claims.(*veylantClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return &middleware.UserClaims{
UserID: claims.Subject,
TenantID: claims.TenantID,
Email: claims.Email,
Roles: claims.Roles,
Department: claims.Department,
}, nil
}
// GenerateToken creates a signed JWT for the given claims.
func GenerateToken(claims *middleware.UserClaims, name, secret string, ttlHours int) (string, error) {
now := time.Now()
jwtClaims := veylantClaims{
Email: claims.Email,
TenantID: claims.TenantID,
Roles: claims.Roles,
Department: claims.Department,
Name: name,
RegisteredClaims: jwt.RegisteredClaims{
Subject: claims.UserID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlHours) * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims)
return token.SignedString([]byte(secret))
}

116
internal/auth/login.go Normal file
View File

@ -0,0 +1,116 @@
package auth
import (
"database/sql"
"encoding/json"
"net/http"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/middleware"
)
// LoginHandler handles POST /v1/auth/login (public — no JWT required).
type LoginHandler struct {
db *sql.DB
jwtSecret string
jwtTTLHours int
logger *zap.Logger
}
// NewLoginHandler creates a LoginHandler.
func NewLoginHandler(db *sql.DB, jwtSecret string, jwtTTLHours int, logger *zap.Logger) *LoginHandler {
return &LoginHandler{db: db, jwtSecret: jwtSecret, jwtTTLHours: jwtTTLHours, logger: logger}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type loginResponse struct {
Token string `json:"token"`
User userInfo `json:"user"`
}
type userInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
TenantID string `json:"tenant_id"`
Department string `json:"department,omitempty"`
}
// ServeHTTP handles the login request.
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.db == nil {
apierror.WriteError(w, &apierror.APIError{
Type: "not_implemented",
Message: "database not configured",
HTTPStatus: http.StatusNotImplemented,
})
return
}
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON"))
return
}
if req.Email == "" || req.Password == "" {
apierror.WriteError(w, apierror.NewBadRequestError("email and password are required"))
return
}
// Look up user by email (cross-tenant — each email is unique per tenant, but
// for V1 single-tenant we look globally).
var u userInfo
var passwordHash string
err := h.db.QueryRowContext(r.Context(),
`SELECT id, tenant_id, email, email, role, COALESCE(department,''), password_hash
FROM users
WHERE email = $1 AND is_active = TRUE
LIMIT 1`,
req.Email,
).Scan(&u.ID, &u.TenantID, &u.Email, &u.Name, &u.Role, &u.Department, &passwordHash)
if err == sql.ErrNoRows {
// Use same error to avoid user enumeration
apierror.WriteError(w, apierror.NewAuthError("invalid credentials"))
return
}
if err != nil {
h.logger.Error("login db query failed", zap.Error(err))
apierror.WriteError(w, apierror.NewUpstreamError("login failed"))
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
apierror.WriteError(w, apierror.NewAuthError("invalid credentials"))
return
}
claims := &middleware.UserClaims{
UserID: u.ID,
TenantID: u.TenantID,
Email: u.Email,
Roles: []string{u.Role},
Department: u.Department,
}
token, err := GenerateToken(claims, u.Name, h.jwtSecret, h.jwtTTLHours)
if err != nil {
h.logger.Error("token generation failed", zap.Error(err))
apierror.WriteError(w, apierror.NewUpstreamError("failed to generate token"))
return
}
h.logger.Info("user logged in", zap.String("email", u.Email), zap.String("tenant_id", u.TenantID))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(loginResponse{Token: token, User: u})
}

View File

@ -14,7 +14,7 @@ type Config struct {
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"` Redis RedisConfig `mapstructure:"redis"`
Keycloak KeycloakConfig `mapstructure:"keycloak"` Auth AuthConfig `mapstructure:"auth"`
PII PIIConfig `mapstructure:"pii"` PII PIIConfig `mapstructure:"pii"`
Log LogConfig `mapstructure:"log"` Log LogConfig `mapstructure:"log"`
Providers ProvidersConfig `mapstructure:"providers"` Providers ProvidersConfig `mapstructure:"providers"`
@ -145,10 +145,11 @@ type RedisConfig struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`
} }
type KeycloakConfig struct { // AuthConfig holds local JWT authentication settings.
BaseURL string `mapstructure:"base_url"` // Override via VEYLANT_AUTH_JWT_SECRET and VEYLANT_AUTH_JWT_TTL_HOURS.
Realm string `mapstructure:"realm"` type AuthConfig struct {
ClientID string `mapstructure:"client_id"` JWTSecret string `mapstructure:"jwt_secret"`
JWTTTLHours int `mapstructure:"jwt_ttl_hours"`
} }
type PIIConfig struct { type PIIConfig struct {
@ -186,6 +187,8 @@ func Load() (*Config, error) {
v.SetDefault("database.max_open_conns", 25) v.SetDefault("database.max_open_conns", 25)
v.SetDefault("database.max_idle_conns", 5) v.SetDefault("database.max_idle_conns", 5)
v.SetDefault("database.migrations_path", "migrations") v.SetDefault("database.migrations_path", "migrations")
v.SetDefault("auth.jwt_secret", "change-me-in-production-use-VEYLANT_AUTH_JWT_SECRET")
v.SetDefault("auth.jwt_ttl_hours", 24)
v.SetDefault("pii.enabled", false) v.SetDefault("pii.enabled", false)
v.SetDefault("pii.service_addr", "localhost:50051") v.SetDefault("pii.service_addr", "localhost:50051")
v.SetDefault("pii.timeout_ms", 100) v.SetDefault("pii.timeout_ms", 100)

View File

@ -101,7 +101,7 @@ func (c *Client) Detect(
RequestId: requestID, RequestId: requestID,
Options: &piiv1.PiiOptions{ Options: &piiv1.PiiOptions{
EnableNer: enableNER, EnableNer: enableNER,
ConfidenceThreshold: 0.85, ConfidenceThreshold: 0.65,
ZeroRetention: zeroRetention, ZeroRetention: zeroRetention,
}, },
}) })

View File

@ -2,7 +2,11 @@ package pii
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"regexp"
"sort"
"strings"
"go.uber.org/zap" "go.uber.org/zap"
@ -30,14 +34,14 @@ type AnalyzeResponse struct {
} }
// AnalyzeHandler wraps a pii.Client as an HTTP handler for the playground. // AnalyzeHandler wraps a pii.Client as an HTTP handler for the playground.
// It is safe to call when client is nil: returns the original text unchanged. // It is safe to call when client is nil: falls back to regex detection.
type AnalyzeHandler struct { type AnalyzeHandler struct {
client *Client client *Client
logger *zap.Logger logger *zap.Logger
} }
// NewAnalyzeHandler creates a new AnalyzeHandler. // NewAnalyzeHandler creates a new AnalyzeHandler.
// client may be nil (PII service disabled) — the handler degrades gracefully. // client may be nil (PII service disabled) — the handler falls back to regex.
func NewAnalyzeHandler(client *Client, logger *zap.Logger) *AnalyzeHandler { func NewAnalyzeHandler(client *Client, logger *zap.Logger) *AnalyzeHandler {
return &AnalyzeHandler{client: client, logger: logger} return &AnalyzeHandler{client: client, logger: logger}
} }
@ -55,45 +59,126 @@ func (h *AnalyzeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// PII service disabled — return text unchanged. // Attempt real PII detection if service is available.
if h.client == nil { // NOTE: when fail_open=true, Detect() returns (result, nil) even on RPC
w.Header().Set("Content-Type", "application/json") // failure, but sets result.Entities to nil to signal the degraded path.
w.WriteHeader(http.StatusOK) // A real successful response always has a non-nil Entities slice.
_ = json.NewEncoder(w).Encode(AnalyzeResponse{ if h.client != nil {
Anonymized: req.Text, resp, err := h.client.Detect(r.Context(), req.Text, "playground", "playground-analyze", true, false)
Entities: []AnalyzeEntity{}, if err == nil && resp.Entities != nil {
}) // Real PII service response (may be empty if no PII detected).
return entities := make([]AnalyzeEntity, 0, len(resp.Entities))
} for _, e := range resp.Entities {
entities = append(entities, AnalyzeEntity{
resp, err := h.client.Detect(r.Context(), req.Text, "playground", "playground-analyze", true, false) Type: e.EntityType,
if err != nil { Start: int(e.Start),
h.logger.Warn("PII analyze failed", zap.Error(err)) End: int(e.End),
// Fail-open: return text unchanged rather than erroring. Confidence: float64(e.Confidence),
w.Header().Set("Content-Type", "application/json") Layer: e.DetectionLayer,
w.WriteHeader(http.StatusOK) })
_ = json.NewEncoder(w).Encode(AnalyzeResponse{ }
Anonymized: req.Text, w.Header().Set("Content-Type", "application/json")
Entities: []AnalyzeEntity{}, w.WriteHeader(http.StatusOK)
}) _ = json.NewEncoder(w).Encode(AnalyzeResponse{
return Anonymized: resp.AnonymizedText,
} Entities: entities,
})
entities := make([]AnalyzeEntity, 0, len(resp.Entities)) return
for _, e := range resp.Entities { }
entities = append(entities, AnalyzeEntity{ if err != nil {
Type: e.EntityType, h.logger.Warn("PII service error — falling back to regex detection", zap.Error(err))
Start: int(e.Start), } else {
End: int(e.End), h.logger.Debug("PII service unavailable (fail-open) — falling back to regex detection")
Confidence: float64(e.Confidence), }
Layer: e.DetectionLayer,
})
} }
// Fallback: local regex detection so the playground stays useful when the
// PII sidecar is not running (dev mode, demo environments).
result := regexDetect(req.Text)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(AnalyzeResponse{ _ = json.NewEncoder(w).Encode(result)
Anonymized: resp.AnonymizedText, }
Entities: entities,
}) // ── Regex-based local detection ───────────────────────────────────────────────
// Package-level compiled regexes (compiled once at startup).
var (
rePiiIBAN = regexp.MustCompile(`(?i)FR\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{3}`)
rePiiEmail = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
rePiiPhone = regexp.MustCompile(`(?:\+33|0033|0)[\s]?[1-9](?:[\s.\-]?\d{2}){4}`)
rePiiCC = regexp.MustCompile(`(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2})(?:[ \-]?\d{4}){3}`)
rePiiSSN = regexp.MustCompile(`\b[12][\s]?\d{2}[\s]?\d{2}[\s]?\d{2}[\s]?\d{3}[\s]?\d{3}[\s]?\d{2}\b`)
)
type regexPattern struct {
re *regexp.Regexp
typ string
}
var piiPatterns = []regexPattern{
{rePiiIBAN, "IBAN_CODE"},
{rePiiEmail, "EMAIL_ADDRESS"},
{rePiiPhone, "PHONE_NUMBER"},
{rePiiCC, "CREDIT_CARD"},
{rePiiSSN, "FR_SSN"},
}
type rawMatch struct {
typ string
start int
end int
}
// regexDetect runs a set of compiled regexes over text and returns matched
// entities with their byte offsets, plus an anonymized version of the text.
func regexDetect(text string) AnalyzeResponse {
var raw []rawMatch
for _, p := range piiPatterns {
for _, loc := range p.re.FindAllStringIndex(text, -1) {
raw = append(raw, rawMatch{typ: p.typ, start: loc[0], end: loc[1]})
}
}
// Sort by start position.
sort.Slice(raw, func(i, j int) bool { return raw[i].start < raw[j].start })
// Remove overlapping matches (keep the first / longest-starting one).
filtered := raw[:0]
cursor := 0
for _, m := range raw {
if m.start >= cursor {
filtered = append(filtered, m)
cursor = m.end
}
}
if len(filtered) == 0 {
return AnalyzeResponse{Anonymized: text, Entities: []AnalyzeEntity{}}
}
// Build anonymized text and entity list simultaneously.
var sb strings.Builder
counters := map[string]int{}
entities := make([]AnalyzeEntity, 0, len(filtered))
cursor = 0
for _, m := range filtered {
sb.WriteString(text[cursor:m.start])
counters[m.typ]++
sb.WriteString(fmt.Sprintf("[%s_%d]", m.typ, counters[m.typ]))
entities = append(entities, AnalyzeEntity{
Type: m.typ,
Start: m.start,
End: m.end,
Confidence: 0.95,
Layer: "regex-local",
})
cursor = m.end
}
sb.WriteString(text[cursor:])
return AnalyzeResponse{
Anonymized: sb.String(),
Entities: entities,
}
} }

View File

@ -25,16 +25,36 @@ type modelRule struct {
// defaultModelRules maps model name prefixes to provider names. // defaultModelRules maps model name prefixes to provider names.
// Rules are evaluated in order; first match wins. // Rules are evaluated in order; first match wins.
// Ollama rules cover common self-hosted model families available via `ollama pull`.
var defaultModelRules = []modelRule{ var defaultModelRules = []modelRule{
// Cloud providers (matched before generic local names)
{"gpt-", "openai"}, {"gpt-", "openai"},
{"o1-", "openai"}, {"o1-", "openai"},
{"o3-", "openai"}, {"o3-", "openai"},
{"claude-", "anthropic"}, {"claude-", "anthropic"},
{"mistral-", "mistral"}, {"mistral-", "mistral"}, // Mistral AI cloud (mistral-small, mistral-large…)
{"mixtral-", "mistral"}, {"mixtral-", "mistral"},
{"llama", "ollama"}, // Ollama / self-hosted models
{"phi", "ollama"}, {"llama", "ollama"}, // llama3, llama3.2, llama2…
{"qwen", "ollama"}, {"phi", "ollama"}, // phi3, phi4, phi3.5…
{"qwen", "ollama"}, // qwen2, qwen2.5, qwen-72b…
{"gemma", "ollama"}, // gemma2, gemma3 (Google)
{"deepseek", "ollama"}, // deepseek-r1, deepseek-coder…
{"llava", "ollama"}, // llava, llava-phi3 (multimodal)
{"tinyllama", "ollama"},
{"codellama", "ollama"},
{"yi-", "ollama"}, // yi-coder, yi-34b…
{"vicuna", "ollama"},
{"starcoder", "ollama"}, // starcoder2…
{"solar", "ollama"},
{"falcon", "ollama"},
{"orca", "ollama"}, // orca-mini…
{"nous", "ollama"}, // nous-hermes…
{"hermes", "ollama"},
{"wizard", "ollama"},
{"stable", "ollama"}, // stablelm…
{"command-r", "ollama"}, // Cohere Command-R via Ollama
{"neural", "ollama"},
} }
// Router implements provider.Adapter. It selects the correct upstream adapter // Router implements provider.Adapter. It selects the correct upstream adapter
@ -92,6 +112,25 @@ func (r *Router) WithFlagStore(fs flags.FlagStore) *Router {
return r return r
} }
// UpdateAdapter registers or replaces the adapter for the given provider name.
// This allows dynamic reloading from the database without a proxy restart.
func (r *Router) UpdateAdapter(name string, adapter provider.Adapter) {
r.adapters[name] = adapter
r.logger.Info("provider adapter updated", zap.String("provider", name))
}
// RemoveAdapter removes the adapter for the given provider name.
func (r *Router) RemoveAdapter(name string) {
delete(r.adapters, name)
r.logger.Info("provider adapter removed", zap.String("provider", name))
}
// AddModelRules prepends extra prefix rules (from DB provider configs).
// Called after loading provider_configs so custom patterns take precedence.
func (r *Router) AddModelRules(rules []modelRule) {
r.modelRules = append(rules, r.modelRules...)
}
// ProviderStatuses returns circuit breaker status for all known providers. // ProviderStatuses returns circuit breaker status for all known providers.
// Returns an empty slice when no circuit breaker is configured. // Returns an empty slice when no circuit breaker is configured.
func (r *Router) ProviderStatuses() []circuitbreaker.Status { func (r *Router) ProviderStatuses() []circuitbreaker.Status {

View File

@ -0,0 +1,2 @@
-- Rollback 000010
ALTER TABLE users DROP COLUMN IF EXISTS password_hash;

View File

@ -0,0 +1,10 @@
-- Migration 000010: Local email/password authentication
-- Adds password_hash column to users table (replaces Keycloak external_id auth).
-- Seeds dev users with bcrypt hashes.
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT '';
-- Update seed dev admin user with bcrypt("admin123")
UPDATE users
SET password_hash = '$2a$10$P6IygH9MpDmKrrACsnYeCeAsTEgVUrFWbHksa4KS7FHSCBcExDYXy'
WHERE email = 'admin@veylant.dev';

View File

@ -0,0 +1,2 @@
-- Rollback 000011
DROP TABLE IF EXISTS provider_configs;

View File

@ -0,0 +1,29 @@
-- Migration 000011: Provider configurations stored in database
-- Replaces environment-variable-only provider config with DB-driven CRUD.
-- API keys are stored encrypted (AES-256-GCM via crypto.Encryptor).
CREATE TABLE provider_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id TEXT NOT NULL,
provider TEXT NOT NULL
CHECK (provider IN ('openai','anthropic','azure','mistral','ollama')),
display_name TEXT NOT NULL DEFAULT '',
api_key_enc TEXT NOT NULL DEFAULT '', -- AES-256-GCM encrypted, empty for Ollama
base_url TEXT NOT NULL DEFAULT '',
resource_name TEXT NOT NULL DEFAULT '', -- Azure only
deployment_id TEXT NOT NULL DEFAULT '', -- Azure only
api_version TEXT NOT NULL DEFAULT '2024-02-01',
timeout_sec INT NOT NULL DEFAULT 30,
max_conns INT NOT NULL DEFAULT 100,
model_patterns TEXT[] NOT NULL DEFAULT '{}', -- extra model name prefixes routed here
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, provider)
);
CREATE INDEX idx_provider_configs_tenant ON provider_configs(tenant_id, is_active);
CREATE TRIGGER provider_configs_updated_at
BEFORE UPDATE ON provider_configs
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

View File

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS name;

View File

@ -0,0 +1,6 @@
-- Migration 000012: Add name column to users table.
-- Migration 000001 created users without a name column; migration 000006 used
-- CREATE TABLE IF NOT EXISTS which was a no-op since the table already existed.
-- This migration adds the missing column retroactively.
ALTER TABLE users ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';

BIN
proxy

Binary file not shown.

View File

@ -2,6 +2,9 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# gen/ must be in PYTHONPATH so grpc stubs can resolve 'from pii.v1 import ...'
ENV PYTHONPATH=/app/gen
# Install system dependencies for spaCy compilation # Install system dependencies for spaCy compilation
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \

View File

@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
from layers.regex_layer import DetectedEntity from layers.regex_layer import DetectedEntity
if TYPE_CHECKING: if TYPE_CHECKING:
from presidio_analyzer import AnalyzerEngine from presidio_analyzer import AnalyzerEngine # noqa: F401 — type hint only
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -95,15 +95,25 @@ class NERLayer:
def _build_analyzer(self) -> "AnalyzerEngine": def _build_analyzer(self) -> "AnalyzerEngine":
from presidio_analyzer import AnalyzerEngine from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider from presidio_analyzer.nlp_engine import NerModelConfiguration, SpacyNlpEngine
configuration = { # The default NerModelConfiguration incorrectly places 'ORG' and 'ORGANIZATION'
"nlp_engine_name": "spacy", # in labels_to_ignore, which prevents ORGANIZATION entities from being returned.
"models": [ # We build a custom configuration that keeps those labels.
default_cfg = NerModelConfiguration()
custom_labels_to_ignore = default_cfg.labels_to_ignore - {"ORG", "ORGANIZATION"}
ner_config = NerModelConfiguration(
model_to_presidio_entity_mapping=default_cfg.model_to_presidio_entity_mapping,
low_score_entity_names=default_cfg.low_score_entity_names,
labels_to_ignore=custom_labels_to_ignore,
)
nlp_engine = SpacyNlpEngine(
models=[
{"lang_code": "fr", "model_name": self._fr_model}, {"lang_code": "fr", "model_name": self._fr_model},
{"lang_code": "en", "model_name": self._en_model}, {"lang_code": "en", "model_name": self._en_model},
], ],
} ner_model_configuration=ner_config,
provider = NlpEngineProvider(nlp_configuration=configuration) )
nlp_engine = provider.create_engine()
return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["fr", "en"]) return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["fr", "en"])

View File

@ -67,9 +67,9 @@ _RE_EMAIL = re.compile(
r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b" r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b"
) )
# French mobile: 06/07, with optional +33 or 0033 prefix, various separators # French phone (mobile 06/07 + landlines 01-05, 08, 09), optional +33/0033 prefix
_RE_PHONE_FR = re.compile( _RE_PHONE_FR = re.compile(
r"(?:(?:\+|00)33\s?|0)[67](?:[\s.\-]?\d{2}){4}" r"(?:(?:\+|00)33\s?|0)[1-9](?:[\s.\-]?\d{2}){4}"
) )
# International phone: + followed by 1-3 digit country code + 6-12 digits # International phone: + followed by 1-3 digit country code + 6-12 digits
@ -77,10 +77,10 @@ _RE_PHONE_INTL = re.compile(
r"(?<!\d)\+[1-9]\d{0,2}[\s.\-]?\d{4,14}(?!\d)" r"(?<!\d)\+[1-9]\d{0,2}[\s.\-]?\d{4,14}(?!\d)"
) )
# French SSN (NIR): 13 digits + 2-digit key, with optional separators # French SSN (NIR): 13 digits + 2-digit key, with optional spaces/hyphens between groups
# Format: [12] YY MM DEP COM NNN CC # Format: [12] YY MM DEP COM NNN CC — e.g. "1 78 07 75 115 423 45" or "1780775115 42345"
_RE_FR_SSN = re.compile( _RE_FR_SSN = re.compile(
r"\b([12]\d{2}(?:0[1-9]|1[0-2]|20)\d{2}\d{3}\d{3}\d{2})\b" r"\b([12][\s\-]?\d{2}[\s\-]?(?:0[1-9]|1[0-2]|20)[\s\-]?\d{2}[\s\-]?\d{3}[\s\-]?\d{3}[\s\-]?\d{2})\b"
) )
# Credit card: 16 digits in groups of 4 (Visa, Mastercard, etc.) # Credit card: 16 digits in groups of 4 (Visa, Mastercard, etc.)
@ -117,15 +117,18 @@ class RegexLayer:
results = [] results = []
for m in _RE_IBAN.finditer(text): for m in _RE_IBAN.finditer(text):
value = m.group(1) value = m.group(1)
if _iban_valid(value): # Accept IBANs with invalid MOD-97 checksum at lower confidence (useful for
results.append( # test/demo data where checksums may not be real).
DetectedEntity( confidence = 1.0 if _iban_valid(value) else 0.75
entity_type="IBAN", results.append(
original_value=value, DetectedEntity(
start=m.start(1), entity_type="IBAN",
end=m.end(1), original_value=value,
) start=m.start(1),
end=m.end(1),
confidence=confidence,
) )
)
return results return results
def _find_emails(self, text: str) -> list[DetectedEntity]: def _find_emails(self, text: str) -> list[DetectedEntity]:
@ -186,13 +189,16 @@ class RegexLayer:
results = [] results = []
for m in _RE_CREDIT_CARD.finditer(text): for m in _RE_CREDIT_CARD.finditer(text):
digits_only = re.sub(r"[\s\-]", "", m.group(1)) digits_only = re.sub(r"[\s\-]", "", m.group(1))
if _luhn_valid(digits_only): # Accept cards that fail Luhn at lower confidence (test/demo card numbers
results.append( # often don't have a valid Luhn checksum).
DetectedEntity( confidence = 1.0 if _luhn_valid(digits_only) else 0.75
entity_type="CREDIT_CARD", results.append(
original_value=m.group(1), DetectedEntity(
start=m.start(1), entity_type="CREDIT_CARD",
end=m.end(1), original_value=m.group(1),
) start=m.start(1),
end=m.end(1),
confidence=confidence,
) )
)
return results return results

View File

@ -2,10 +2,11 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.32.1 uvicorn[standard]==0.32.1
# gRPC # gRPC — versions must match the buf-generated stubs in gen/pii/v1/
grpcio==1.68.1 grpcio==1.78.1
grpcio-tools==1.68.1 grpcio-tools==1.78.1
grpcio-health-checking==1.68.1 grpcio-health-checking==1.78.1
protobuf==6.31.1
# PII detection (Sprint 3) # PII detection (Sprint 3)
presidio-analyzer==2.2.356 presidio-analyzer==2.2.356

View File

@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
import type { ProviderStatus } from "@/types/api"; import type { CreateProviderRequest, ProviderConfig, ProviderStatus } from "@/types/api";
// ─── Circuit breaker status (legacy, still used by overview) ──────────────────
export function useProviderStatuses() { export function useProviderStatuses() {
return useQuery({ return useQuery({
@ -9,3 +11,58 @@ export function useProviderStatuses() {
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
} }
// ─── Provider config CRUD ─────────────────────────────────────────────────────
export function useProviderConfigs() {
return useQuery({
queryKey: ["providers", "configs"],
queryFn: async () => {
const res = await apiFetch<{ data: ProviderConfig[] }>("/v1/admin/providers");
return res.data ?? [];
},
refetchInterval: 30_000,
});
}
export function useCreateProvider() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateProviderRequest) =>
apiFetch<ProviderConfig>("/v1/admin/providers", {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }),
});
}
export function useUpdateProvider() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateProviderRequest }) =>
apiFetch<ProviderConfig>(`/v1/admin/providers/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }),
});
}
export function useDeleteProvider() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiFetch<void>(`/v1/admin/providers/${id}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }),
});
}
export function useTestProvider() {
return useMutation({
mutationFn: (id: string) =>
apiFetch<{ healthy: boolean; error?: string }>(`/v1/admin/providers/${id}/test`, {
method: "POST",
}),
});
}

View File

@ -2,6 +2,21 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
import type { User } from "@/types/api"; import type { User } from "@/types/api";
export type UserPayload = {
email: string;
name: string;
department: string;
role: string;
is_active: boolean;
password?: string;
};
export type ImportResult = {
success: number;
failed: number;
errors: string[];
};
export function useListUsers() { export function useListUsers() {
return useQuery({ return useQuery({
queryKey: ["users"], queryKey: ["users"],
@ -12,7 +27,7 @@ export function useListUsers() {
export function useCreateUser() { export function useCreateUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (body: Omit<User, "id" | "tenant_id" | "created_at" | "updated_at">) => mutationFn: (body: UserPayload) =>
apiFetch<User>("/v1/admin/users", { method: "POST", body: JSON.stringify(body) }), apiFetch<User>("/v1/admin/users", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
}); });
@ -21,7 +36,7 @@ export function useCreateUser() {
export function useUpdateUser() { export function useUpdateUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<User> & { id: string }) => mutationFn: ({ id, ...body }: UserPayload & { id: string }) =>
apiFetch<User>(`/v1/admin/users/${id}`, { method: "PUT", body: JSON.stringify(body) }), apiFetch<User>(`/v1/admin/users/${id}`, { method: "PUT", body: JSON.stringify(body) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
}); });
@ -35,3 +50,23 @@ export function useDeleteUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
}); });
} }
export function useImportUsers() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (users: UserPayload[]): Promise<ImportResult> => {
const result: ImportResult = { success: 0, failed: 0, errors: [] };
for (const user of users) {
try {
await apiFetch<User>("/v1/admin/users", { method: "POST", body: JSON.stringify(user) });
result.success++;
} catch {
result.failed++;
result.errors.push(user.email);
}
}
return result;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}

View File

@ -1,9 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { AuthProvider as OidcAuthProvider, useAuth as useOidcAuth } from "react-oidc-context";
const AUTH_MODE = import.meta.env.VITE_AUTH_MODE ?? "dev"; // ─── Types ────────────────────────────────────────────────────────────────────
// ─── Shared user type ─────────────────────────────────────────────────────────
export interface AuthUser { export interface AuthUser {
id: string; id: string;
@ -13,140 +10,129 @@ export interface AuthUser {
token: string; token: string;
} }
// ─── Dev mode auth ──────────────────────────────────────────────────────────── interface AuthContextValue {
interface DevAuthContextValue {
user: AuthUser | null; user: AuthUser | null;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
login: () => void; login: (email: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
} }
const DevAuthContext = createContext<DevAuthContextValue | null>(null); // ─── Context ──────────────────────────────────────────────────────────────────
const DEV_USER: AuthUser = { const AuthContext = createContext<AuthContextValue | null>(null);
id: "dev-user",
email: "dev@veylant.local",
name: "Dev Admin",
roles: ["admin"],
token: "dev-token",
};
function DevAuthProvider({ children }: { children: React.ReactNode }) { const TOKEN_KEY = "veylant_token";
// Decode JWT payload (no verification — just read claims for UI).
function decodeJWT(token: string): Record<string, unknown> | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(payload) as Record<string, unknown>;
} catch {
return null;
}
}
function userFromToken(token: string): AuthUser | null {
const payload = decodeJWT(token);
if (!payload) return null;
// Check expiry
const exp = payload["exp"] as number | undefined;
if (exp && exp * 1000 < Date.now()) {
localStorage.removeItem(TOKEN_KEY);
return null;
}
const roles = Array.isArray(payload["roles"])
? (payload["roles"] as string[])
: [];
return {
id: (payload["sub"] as string) ?? "",
email: (payload["email"] as string) ?? "",
name: (payload["name"] as string) ?? (payload["email"] as string) ?? "",
roles,
token,
};
}
// ─── Provider ─────────────────────────────────────────────────────────────────
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => { const [user, setUser] = useState<AuthUser | null>(() => {
return sessionStorage.getItem("dev-authed") ? DEV_USER : null; const stored = localStorage.getItem(TOKEN_KEY);
return stored ? userFromToken(stored) : null;
}); });
const [isLoading, setIsLoading] = useState(false);
const login = useCallback(() => { const login = useCallback(async (email: string, password: string) => {
sessionStorage.setItem("dev-authed", "1"); setIsLoading(true);
setUser(DEV_USER); try {
const res = await fetch("/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = (body as { error?: { message?: string } }).error?.message ?? "Identifiants invalides";
throw new Error(msg);
}
const { token } = await res.json() as { token: string };
localStorage.setItem(TOKEN_KEY, token);
const u = userFromToken(token);
setUser(u);
} finally {
setIsLoading(false);
}
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {
sessionStorage.removeItem("dev-authed"); localStorage.removeItem(TOKEN_KEY);
setUser(null); setUser(null);
}, []); }, []);
// Auto-logout when token expires
useEffect(() => {
if (!user) return;
const payload = decodeJWT(user.token);
if (!payload) return;
const exp = payload["exp"] as number | undefined;
if (!exp) return;
const msUntilExpiry = exp * 1000 - Date.now();
if (msUntilExpiry <= 0) {
logout();
return;
}
const timer = setTimeout(logout, msUntilExpiry);
return () => clearTimeout(timer);
}, [user, logout]);
return ( return (
<DevAuthContext.Provider <AuthContext.Provider
value={{ user, isLoading: false, isAuthenticated: !!user, login, logout }} value={{ user, isLoading, isAuthenticated: !!user, login, logout }}
> >
{children} {children}
</DevAuthContext.Provider> </AuthContext.Provider>
); );
} }
// ─── OIDC mode adapter ──────────────────────────────────────────────────────── // ─── Hook ─────────────────────────────────────────────────────────────────────
interface UnifiedAuthContextValue { export function useAuth(): AuthContextValue {
user: AuthUser | null; const ctx = useContext(AuthContext);
isLoading: boolean;
isAuthenticated: boolean;
login: () => void;
logout: () => void;
}
const UnifiedAuthContext = createContext<UnifiedAuthContextValue | null>(null);
function OidcAdapter({ children }: { children: React.ReactNode }) {
const auth = useOidcAuth();
const user: AuthUser | null = auth.user
? {
id: auth.user.profile.sub,
email: auth.user.profile.email ?? "",
name: auth.user.profile.name ?? auth.user.profile.email ?? "",
roles: (auth.user.profile as Record<string, unknown>)["realm_access"]
? ((auth.user.profile as Record<string, unknown>)["realm_access"] as { roles: string[] }).roles
: [],
token: auth.user.access_token,
}
: null;
return (
<UnifiedAuthContext.Provider
value={{
user,
isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated,
login: () => void auth.signinRedirect(),
logout: () => void auth.signoutRedirect(),
}}
>
{children}
</UnifiedAuthContext.Provider>
);
}
// ─── Public hook ──────────────────────────────────────────────────────────────
export function useAuth(): UnifiedAuthContextValue {
if (AUTH_MODE === "dev") {
// eslint-disable-next-line react-hooks/rules-of-hooks
const ctx = useContext(DevAuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const ctx = useContext(UnifiedAuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx; return ctx;
} }
// ─── Root AuthProvider ──────────────────────────────────────────────────────── // ─── Token accessor (for API client outside React tree) ───────────────────────
const oidcConfig = {
authority: import.meta.env.VITE_KEYCLOAK_URL ?? "http://localhost:8080/realms/veylant",
client_id: "veylant-dashboard",
redirect_uri: `${window.location.origin}/callback`,
scope: "openid profile email",
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
},
};
export function AuthProvider({ children }: { children: React.ReactNode }) {
if (AUTH_MODE === "dev") {
return <DevAuthProvider>{children}</DevAuthProvider>;
}
return (
<OidcAuthProvider {...oidcConfig}>
<OidcAdapter>
<UnifiedAuthContext.Consumer>
{(ctx) =>
ctx ? (
<UnifiedAuthContext.Provider value={ctx}>{children}</UnifiedAuthContext.Provider>
) : null
}
</UnifiedAuthContext.Consumer>
</OidcAdapter>
</OidcAuthProvider>
);
}
// Token accessor for API client (outside React tree)
let _getToken: (() => string | null) | null = null; let _getToken: (() => string | null) | null = null;
export function setTokenAccessor(fn: () => string | null) { export function setTokenAccessor(fn: () => string | null) {
@ -154,10 +140,14 @@ export function setTokenAccessor(fn: () => string | null) {
} }
export function getToken(): string | null { export function getToken(): string | null {
return _getToken ? _getToken() : null; // Try module-level accessor first, then localStorage directly
if (_getToken) return _getToken();
const stored = localStorage.getItem(TOKEN_KEY);
if (!stored) return null;
const u = userFromToken(stored);
return u?.token ?? null;
} }
// Hook to register token accessor in context
export function useRegisterTokenAccessor() { export function useRegisterTokenAccessor() {
const auth = useAuth(); const auth = useAuth();
useEffect(() => { useEffect(() => {

View File

@ -1,14 +1,32 @@
import type { ReactNode } from "react";
import type { PiiEntity } from "@/types/api"; import type { PiiEntity } from "@/types/api";
const ENTITY_COLORS: Record<string, { bg: string; text: string; label: string }> = { const ENTITY_COLORS: Record<string, { bg: string; text: string; label: string }> = {
// IBAN
IBAN_CODE: { bg: "bg-red-100", text: "text-red-800", label: "IBAN" }, IBAN_CODE: { bg: "bg-red-100", text: "text-red-800", label: "IBAN" },
IBAN: { bg: "bg-red-100", text: "text-red-800", label: "IBAN" },
// Credit card
CREDIT_CARD: { bg: "bg-red-100", text: "text-red-800", label: "CB" }, CREDIT_CARD: { bg: "bg-red-100", text: "text-red-800", label: "CB" },
// Email
EMAIL_ADDRESS: { bg: "bg-orange-100", text: "text-orange-800", label: "Email" },
EMAIL: { bg: "bg-orange-100", text: "text-orange-800", label: "Email" }, EMAIL: { bg: "bg-orange-100", text: "text-orange-800", label: "Email" },
// Phone
PHONE_NUMBER: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél." }, PHONE_NUMBER: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél." },
PHONE_FR: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél." },
PHONE_INTL: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél. Intl." },
// Person
PERSON: { bg: "bg-blue-100", text: "text-blue-800", label: "Personne" }, PERSON: { bg: "bg-blue-100", text: "text-blue-800", label: "Personne" },
PER: { bg: "bg-blue-100", text: "text-blue-800", label: "Personne" },
// SSN
FR_SSN: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" }, FR_SSN: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" },
FR_NIF: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" },
// Location
LOCATION: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" }, LOCATION: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
LOC: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
GPE: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
// Organisation
ORGANIZATION: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." }, ORGANIZATION: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." },
ORG: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." },
}; };
const DEFAULT_COLOR = { bg: "bg-yellow-100", text: "text-yellow-800", label: "PII" }; const DEFAULT_COLOR = { bg: "bg-yellow-100", text: "text-yellow-800", label: "PII" };
@ -24,34 +42,46 @@ export function PiiHighlight({ text, entities, className }: Props) {
return <span className={className}>{text}</span>; return <span className={className}>{text}</span>;
} }
// Sort by start position // Sort by start position and remove overlapping spans.
const sorted = [...entities].sort((a, b) => a.start - b.start); const sorted = [...entities]
.sort((a, b) => a.start - b.start)
.reduce<PiiEntity[]>((acc, e) => {
if (acc.length === 0 || e.start >= acc[acc.length - 1].end) {
acc.push(e);
}
return acc;
}, []);
const parts: React.ReactNode[] = []; const parts: ReactNode[] = [];
let cursor = 0; let cursor = 0;
for (const entity of sorted) { for (const entity of sorted) {
if (entity.start > cursor) { // Guard against out-of-bounds offsets from the API.
const start = Math.max(0, Math.min(entity.start, text.length));
const end = Math.max(start, Math.min(entity.end, text.length));
if (start > cursor) {
parts.push(<span key={`t-${cursor}`}>{text.slice(cursor, start)}</span>);
}
if (start < end) {
const color = ENTITY_COLORS[entity.type] ?? DEFAULT_COLOR;
const layerNote = entity.layer === "regex" ? " (détection locale)" : "";
parts.push( parts.push(
<span key={`text-${cursor}`}>{text.slice(cursor, entity.start)}</span> <mark
key={`e-${start}`}
className={`${color.bg} ${color.text} rounded px-0.5 py-px text-xs font-medium cursor-help`}
title={`${entity.type} — confiance: ${Math.round(entity.confidence * 100)}%${layerNote}`}
>
{text.slice(start, end)}
<sup className="ml-0.5 text-[9px] opacity-70">{color.label}</sup>
</mark>
); );
} }
const color = ENTITY_COLORS[entity.type] ?? DEFAULT_COLOR; cursor = end;
parts.push(
<mark
key={`entity-${entity.start}`}
className={`${color.bg} ${color.text} rounded px-0.5 py-px text-xs font-medium cursor-help`}
title={`${entity.type} — confiance: ${Math.round(entity.confidence * 100)}%`}
>
{text.slice(entity.start, entity.end)}
<sup className="ml-0.5 text-[9px] opacity-70">{color.label}</sup>
</mark>
);
cursor = entity.end;
} }
if (cursor < text.length) { if (cursor < text.length) {
parts.push(<span key={`text-end`}>{text.slice(cursor)}</span>); parts.push(<span key="t-end">{text.slice(cursor)}</span>);
} }
return <span className={className}>{parts}</span>; return <span className={className}>{parts}</span>;

View File

@ -0,0 +1,137 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { User } from "@/types/api";
import type { UserPayload } from "@/api/users";
const ROLES = ["admin", "manager", "user", "auditor"] as const;
const userSchema = z.object({
email: z.string().email("Email invalide"),
name: z.string().min(1, "Le nom est requis"),
department: z.string(),
role: z.enum(ROLES),
is_active: z.boolean(),
password: z.string().optional(),
});
type UserFormData = z.infer<typeof userSchema>;
interface UserFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSubmit: (payload: UserPayload) => void;
isPending: boolean;
}
export function UserForm({ open, onOpenChange, user, onSubmit, isPending }: UserFormProps) {
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: { email: "", name: "", department: "", role: "user", is_active: true, password: "" },
});
useEffect(() => {
if (open) {
reset(
user
? { email: user.email, name: user.name, department: user.department, role: user.role as typeof ROLES[number], is_active: user.is_active, password: "" }
: { email: "", name: "", department: "", role: "user", is_active: true, password: "" }
);
}
}, [user, open, reset]);
const isActive = watch("is_active");
const role = watch("role");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{user ? "Modifier l'utilisateur" : "Nouvel utilisateur"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-2">
<div className="space-y-1">
<Label>Email *</Label>
<Input {...register("email")} type="email" placeholder="prenom.nom@entreprise.com" />
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-1">
<Label>Nom complet *</Label>
<Input {...register("name")} placeholder="Prénom Nom" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<Label>Département</Label>
<Input {...register("department")} placeholder="ex: finance, engineering, hr" />
</div>
<div className="space-y-1">
<Label>Rôle</Label>
<Select value={role} onValueChange={(v) => setValue("role", v as typeof ROLES[number])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r} value={r} className="capitalize">{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>
{user ? "Nouveau mot de passe (laisser vide pour ne pas changer)" : "Mot de passe (optionnel)"}
</Label>
<Input {...register("password")} type="password" placeholder="••••••••" autoComplete="new-password" />
</div>
<div className="flex items-center gap-3 pt-1">
<Switch checked={isActive} onCheckedChange={(v) => setValue("is_active", v)} />
<Label className="cursor-pointer" onClick={() => setValue("is_active", !isActive)}>
Compte actif
</Label>
</div>
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Enregistrement..." : user ? "Mettre à jour" : "Créer"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,23 +1,38 @@
import { useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Shield, LogIn } from "lucide-react"; import { Shield, LogIn, Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/auth/AuthProvider"; import { useAuth } from "@/auth/AuthProvider";
const AUTH_MODE = import.meta.env.VITE_AUTH_MODE ?? "dev";
export function LoginPage() { export function LoginPage() {
const { login, isAuthenticated, isLoading } = useAuth(); const { login, isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? "/dashboard"; const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
navigate(from, { replace: true }); navigate(from, { replace: true });
} }
}, [isAuthenticated, navigate, from]); }, [isAuthenticated, navigate, from]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await login(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur de connexion");
}
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-8 px-4"> <div className="w-full max-w-sm space-y-8 px-4">
@ -34,36 +49,65 @@ export function LoginPage() {
<div className="rounded-xl border bg-card p-8 shadow-sm space-y-6"> <div className="rounded-xl border bg-card p-8 shadow-sm space-y-6">
<div className="text-center"> <div className="text-center">
<h2 className="text-lg font-semibold">Connexion</h2> <h2 className="text-lg font-semibold">Connexion</h2>
{AUTH_MODE === "dev" ? ( <p className="text-sm text-muted-foreground mt-1">
<p className="text-sm text-muted-foreground mt-1"> Entrez vos identifiants pour accéder au dashboard
Mode développement authentification simulée </p>
</p>
) : (
<p className="text-sm text-muted-foreground mt-1">
Connectez-vous via votre compte Keycloak
</p>
)}
</div> </div>
<Button <form onSubmit={handleSubmit} className="space-y-4">
className="w-full" <div className="space-y-1">
size="lg" <Label htmlFor="email">Email</Label>
onClick={login} <Input
disabled={isLoading} id="email"
> type="email"
<LogIn className="h-4 w-4 mr-2" /> autoComplete="email"
{AUTH_MODE === "dev" ? "Se connecter (mode dev)" : "Se connecter avec Keycloak"} placeholder="admin@veylant.dev"
</Button> value={email}
onChange={(e) => setEmail(e.target.value)}
{AUTH_MODE === "dev" && ( disabled={isLoading}
<div className="rounded-lg bg-muted p-3 text-xs text-muted-foreground space-y-1"> required
<p className="font-medium">Utilisateurs de test disponibles :</p> />
<p>admin@veylant.dev / admin123</p>
<p>manager@veylant.dev / manager123</p>
<p>user@veylant.dev / user123</p>
<p>auditor@veylant.dev / auditor123</p>
</div> </div>
)}
<div className="space-y-1">
<Label htmlFor="password">Mot de passe</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
required
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
<Button type="submit" className="w-full" size="lg" disabled={isLoading}>
<LogIn className="h-4 w-4 mr-2" />
{isLoading ? "Connexion…" : "Se connecter"}
</Button>
</form>
<div className="rounded-lg bg-muted p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium">Compte de test :</p>
<p>admin@veylant.dev / admin123</p>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { Send, FlaskConical, ShieldAlert } from "lucide-react"; import { Send, FlaskConical, ShieldAlert, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -26,6 +26,18 @@ const MODELS = [
const DEBOUNCE_MS = 600; const DEBOUNCE_MS = 600;
// Legend mapping — covers both Python PII service types and Go regex-local fallback types.
const ENTITY_LEGEND: Record<string, string> = {
IBAN_CODE: "bg-red-100 text-red-800", IBAN: "bg-red-100 text-red-800",
CREDIT_CARD: "bg-red-100 text-red-800",
EMAIL_ADDRESS: "bg-orange-100 text-orange-800", EMAIL: "bg-orange-100 text-orange-800",
PHONE_NUMBER: "bg-purple-100 text-purple-800", PHONE_FR: "bg-purple-100 text-purple-800", PHONE_INTL: "bg-purple-100 text-purple-800",
PERSON: "bg-blue-100 text-blue-800", PER: "bg-blue-100 text-blue-800",
FR_SSN: "bg-rose-100 text-rose-800", FR_NIF: "bg-rose-100 text-rose-800",
LOCATION: "bg-green-100 text-green-800", LOC: "bg-green-100 text-green-800", GPE: "bg-green-100 text-green-800",
ORGANIZATION: "bg-teal-100 text-teal-800", ORG: "bg-teal-100 text-teal-800",
};
interface ChatMessage { interface ChatMessage {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
@ -36,37 +48,48 @@ export function PlaygroundPage() {
const [model, setModel] = useState(MODELS[0].value); const [model, setModel] = useState(MODELS[0].value);
const [entities, setEntities] = useState<PiiEntity[]>([]); const [entities, setEntities] = useState<PiiEntity[]>([]);
const [anonymized, setAnonymized] = useState(""); const [anonymized, setAnonymized] = useState("");
const [piiLayer, setPiiLayer] = useState<"service" | "regex" | "none">("none");
const [aiResponse, setAiResponse] = useState(""); const [aiResponse, setAiResponse] = useState("");
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── PII mutation ──────────────────────────────────────────────────────────
// useMutation returns a new object every render; store it in a ref so that
// handleTextChange (empty deps) always calls the latest version without
// being recreated on every render (which would cancel the debounce).
const piiMutation = usePiiAnalyze(); const piiMutation = usePiiAnalyze();
const piiMutationRef = useRef(piiMutation);
piiMutationRef.current = piiMutation;
// Debounced PII analysis on text change const handleTextChange = useCallback((value: string) => {
const handleTextChange = useCallback( setText(value);
(value: string) => { if (debounceRef.current) clearTimeout(debounceRef.current);
setText(value); if (!value.trim()) {
if (debounceRef.current) clearTimeout(debounceRef.current); setEntities([]);
if (!value.trim()) { setAnonymized("");
setEntities([]); setPiiLayer("none");
setAnonymized(""); return;
return; }
} debounceRef.current = setTimeout(() => {
debounceRef.current = setTimeout(() => { piiMutationRef.current.mutate(value, {
piiMutation.mutate(value, { onSuccess: (result) => {
onSuccess: (result) => { setEntities(result.entities);
setEntities(result.entities); setAnonymized(result.anonymized);
setAnonymized(result.anonymized); const layer = result.entities.some((e) => e.layer === "regex-local")
}, ? "regex"
onError: () => { : result.entities.length > 0
setEntities([]); ? "service"
setAnonymized(value); : "none";
}, setPiiLayer(layer);
}); },
}, DEBOUNCE_MS); onError: () => {
}, setEntities([]);
[piiMutation] setAnonymized(value);
); setPiiLayer("none");
},
});
}, DEBOUNCE_MS);
}, []); // stable — uses ref internally
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -74,24 +97,20 @@ export function PlaygroundPage() {
}; };
}, []); }, []);
// ── LLM send ─────────────────────────────────────────────────────────────
const handleSend = async () => { const handleSend = async () => {
if (!text.trim() || isSending) return; if (!text.trim() || isSending) return;
setIsSending(true); setIsSending(true);
setAiResponse(""); setAiResponse("");
try { try {
const messages: ChatMessage[] = [{ role: "user", content: text }]; const messages: ChatMessage[] = [{ role: "user", content: text }];
const response = await apiFetch<{ choices: { message: { content: string } }[] }>( const response = await apiFetch<{ choices: { message: { content: string } }[] }>(
"/v1/chat/completions", "/v1/chat/completions",
{ { method: "POST", body: JSON.stringify({ model, messages, stream: false }) }
method: "POST",
body: JSON.stringify({ model, messages, stream: false }),
}
); );
const content = response.choices?.[0]?.message?.content ?? ""; setAiResponse(response.choices?.[0]?.message?.content ?? "");
setAiResponse(content);
} catch (err) { } catch (err) {
setAiResponse(`Erreur: ${err instanceof Error ? err.message : "Requête échouée"}`); setAiResponse(`Erreur : ${err instanceof Error ? err.message : "Requête échouée"}`);
} finally { } finally {
setIsSending(false); setIsSending(false);
} }
@ -99,9 +118,11 @@ export function PlaygroundPage() {
const piiCount = entities.length; const piiCount = entities.length;
const hasText = text.trim().length > 0; const hasText = text.trim().length > 0;
const uniqueTypes = [...new Set(entities.map((e) => e.type))];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold flex items-center gap-2"> <h2 className="text-xl font-bold flex items-center gap-2">
@ -135,22 +156,29 @@ export function PlaygroundPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Input */} {/* Left: Input */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between min-h-[24px]">
<label className="text-sm font-medium">Votre prompt</label> <label className="text-sm font-medium">Votre prompt</label>
{piiMutation.isPending && ( <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground animate-pulse">Analyse PII</span> {piiMutation.isPending && (
)} <span className="text-xs text-muted-foreground animate-pulse">
{piiCount > 0 && ( Analyse PII
<Badge variant="warning" className="gap-1"> </span>
<ShieldAlert className="h-3 w-3" /> )}
{piiCount} donnée{piiCount > 1 ? "s" : ""} sensible{piiCount > 1 ? "s" : ""} {piiCount > 0 && (
</Badge> <Badge
)} className="gap-1 bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100"
>
<ShieldAlert className="h-3 w-3" />
{piiCount} donnée{piiCount > 1 ? "s" : ""} sensible
{piiCount > 1 ? "s" : ""}
</Badge>
)}
</div>
</div> </div>
<Textarea <Textarea
value={text} value={text}
onChange={(e) => handleTextChange(e.target.value)} onChange={(e) => handleTextChange(e.target.value)}
placeholder="Tapez votre prompt ici… Ex: Mon IBAN est FR7630006000011234567890189 et je m'appelle Jean Dupont." placeholder="Tapez votre prompt ici… Ex : Mon IBAN est FR7630006000011234567890189 et je m'appelle Jean Dupont."
className="min-h-[200px] font-mono text-sm resize-y" className="min-h-[200px] font-mono text-sm resize-y"
/> />
</div> </div>
@ -159,8 +187,19 @@ export function PlaygroundPage() {
<div className="space-y-3"> <div className="space-y-3">
{/* PII highlight panel */} {/* PII highlight panel */}
<div> <div>
<label className="text-sm font-medium">Analyse PII (temps réel)</label> <div className="flex items-center justify-between mb-1">
<div className="mt-1 min-h-[80px] rounded-md border bg-muted/30 p-3 text-sm leading-relaxed whitespace-pre-wrap"> <label className="text-sm font-medium">Analyse PII (temps réel)</label>
{piiLayer === "regex" && (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertCircle className="h-3 w-3" />
Détection locale (service PII non disponible)
</span>
)}
{piiLayer === "service" && (
<span className="text-xs text-green-600">Service PII actif</span>
)}
</div>
<div className="min-h-[200px] rounded-md border bg-muted/30 p-3 text-sm leading-relaxed whitespace-pre-wrap">
{hasText ? ( {hasText ? (
<PiiHighlight text={text} entities={entities} /> <PiiHighlight text={text} entities={entities} />
) : ( ) : (
@ -207,26 +246,16 @@ export function PlaygroundPage() {
{piiCount > 0 && ( {piiCount > 0 && (
<div className="flex flex-wrap gap-2 pt-2 border-t"> <div className="flex flex-wrap gap-2 pt-2 border-t">
<span className="text-xs text-muted-foreground">Légende :</span> <span className="text-xs text-muted-foreground">Légende :</span>
{[...new Set(entities.map((e) => e.type))].map((type) => { {uniqueTypes.map((type) => (
const colors: Record<string, string> = { <span
IBAN_CODE: "bg-red-100 text-red-800", key={type}
CREDIT_CARD: "bg-red-100 text-red-800", className={`text-xs px-2 py-0.5 rounded font-medium ${
EMAIL: "bg-orange-100 text-orange-800", ENTITY_LEGEND[type] ?? "bg-yellow-100 text-yellow-800"
PHONE_NUMBER: "bg-purple-100 text-purple-800", }`}
PERSON: "bg-blue-100 text-blue-800", >
FR_SSN: "bg-rose-100 text-rose-800", {type}
LOCATION: "bg-green-100 text-green-800", </span>
ORGANIZATION: "bg-teal-100 text-teal-800", ))}
};
return (
<span
key={type}
className={`text-xs px-2 py-0.5 rounded font-medium ${colors[type] ?? "bg-yellow-100 text-yellow-800"}`}
>
{type}
</span>
);
})}
</div> </div>
)} )}
</div> </div>

View File

@ -1,312 +1,484 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronRight, Check, Plug } from "lucide-react"; import {
ChevronRight, Check, Plus, Trash2, Pencil, Zap, ZapOff, RefreshCw, Loader2,
} from "lucide-react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
useProviderConfigs, useCreateProvider, useUpdateProvider,
useDeleteProvider, useTestProvider,
} from "@/api/providers";
import type { CreateProviderRequest, ProviderConfig } from "@/types/api";
// ─── Provider metadata ────────────────────────────────────────────────────────
const PROVIDER_INFO: Record<string, {
label: string; description: string; needsApiKey: boolean;
needsResource: boolean; needsBaseUrl: boolean;
}> = {
openai: { label: "OpenAI", description: "GPT-4o, GPT-4o-mini, o1…", needsApiKey: true, needsResource: false, needsBaseUrl: false },
anthropic: { label: "Anthropic", description: "Claude 3.5 Sonnet, Claude 3 Opus…", needsApiKey: true, needsResource: false, needsBaseUrl: false },
azure: { label: "Azure OpenAI", description: "Modèles OpenAI hébergés sur Azure", needsApiKey: true, needsResource: true, needsBaseUrl: false },
mistral: { label: "Mistral AI", description: "Mistral Small, Mistral Large…", needsApiKey: true, needsResource: false, needsBaseUrl: false },
ollama: { label: "Ollama", description: "Modèles locaux auto-hébergés", needsApiKey: false, needsResource: false, needsBaseUrl: true },
};
// ─── Form schemas ─────────────────────────────────────────────────────────────
const providerSchema = z.object({
provider: z.enum(["openai", "anthropic", "azure", "mistral", "ollama"]),
display_name: z.string().optional(),
api_key: z.string().optional(),
base_url: z.string().optional(),
resource_name: z.string().optional(),
deployment_id: z.string().optional(),
api_version: z.string().optional(),
model_patterns: z.string().optional(), // comma-separated
});
type ProviderFormValues = z.infer<typeof providerSchema>;
const STEPS = ["Fournisseur", "Connexion", "Confirmation"]; const STEPS = ["Fournisseur", "Connexion", "Confirmation"];
const step1Schema = z.object({ // ─── Wizard ───────────────────────────────────────────────────────────────────
provider: z.enum(["openai", "anthropic", "azure", "mistral", "ollama"]),
});
const step2Schema = z.object({
api_key: z.string().min(1, "La clé API est requise"),
resource_name: z.string().optional(),
deployment_id: z.string().optional(),
base_url: z.string().optional(),
});
type Step1 = z.infer<typeof step1Schema>; interface WizardProps {
type Step2 = z.infer<typeof step2Schema>; editTarget?: ProviderConfig;
onClose: () => void;
}
const PROVIDER_INFO: Record< function ProviderWizard({ editTarget, onClose }: WizardProps) {
string, const [step, setStep] = useState(editTarget ? 1 : 0);
{ label: string; description: string; needsResource: boolean; needsBaseUrl: boolean } const createMutation = useCreateProvider();
> = { const updateMutation = useUpdateProvider();
openai: { const isLoading = createMutation.isPending || updateMutation.isPending;
label: "OpenAI", const [error, setError] = useState<string | null>(null);
description: "GPT-4o, GPT-4o-mini, GPT-3.5-turbo",
needsResource: false,
needsBaseUrl: false,
},
anthropic: {
label: "Anthropic",
description: "Claude 3.5 Sonnet, Claude 3 Opus, Claude 3 Haiku",
needsResource: false,
needsBaseUrl: false,
},
azure: {
label: "Azure OpenAI",
description: "Modèles OpenAI hébergés sur Azure",
needsResource: true,
needsBaseUrl: false,
},
mistral: {
label: "Mistral AI",
description: "Mistral Small, Mistral Large",
needsResource: false,
needsBaseUrl: false,
},
ollama: {
label: "Ollama (local)",
description: "Modèles locaux auto-hébergés",
needsResource: false,
needsBaseUrl: true,
},
};
export function ProvidersPage() { const form = useForm<ProviderFormValues>({
const [step, setStep] = useState(0); resolver: zodResolver(providerSchema),
const [step1Data, setStep1Data] = useState<Step1 | null>(null); defaultValues: {
const [step2Data, setStep2Data] = useState<Step2 | null>(null); provider: editTarget?.provider ?? "openai",
const [completed, setCompleted] = useState(false); display_name: editTarget?.display_name ?? "",
api_key: "", // never pre-fill API key
const form1 = useForm<Step1>({ base_url: editTarget?.base_url ?? "",
resolver: zodResolver(step1Schema), resource_name: editTarget?.resource_name ?? "",
defaultValues: { provider: "openai" }, deployment_id: editTarget?.deployment_id ?? "",
api_version: editTarget?.api_version ?? "",
model_patterns: editTarget?.model_patterns?.join(", ") ?? "",
},
}); });
const form2 = useForm<Step2>({ const selectedProvider = form.watch("provider");
resolver: zodResolver(step2Schema),
defaultValues: { api_key: "" },
});
const selectedProvider = form1.watch("provider");
const info = PROVIDER_INFO[selectedProvider]; const info = PROVIDER_INFO[selectedProvider];
const formValues = form.watch();
const handleStep1 = form1.handleSubmit((data) => { const handleSubmit = form.handleSubmit(async (data) => {
setStep1Data(data); setError(null);
setStep(1); const patterns = data.model_patterns
? data.model_patterns.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const payload: CreateProviderRequest = {
provider: data.provider,
display_name: data.display_name ?? "",
api_key: data.api_key ?? "",
base_url: data.base_url ?? "",
resource_name: data.resource_name ?? "",
deployment_id: data.deployment_id ?? "",
api_version: data.api_version ?? "",
model_patterns: patterns,
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, data: payload });
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de la sauvegarde");
}
}); });
const handleStep2 = form2.handleSubmit((data) => { return (
setStep2Data(data); <div className="space-y-6 max-w-2xl">
setStep(2); {/* Step indicator */}
}); {!editTarget && (
<div className="flex items-center gap-2">
{STEPS.map((label, idx) => (
<div key={label} className="flex items-center gap-2">
<div className={cn(
"h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold",
idx < step ? "bg-green-100 text-green-700"
: idx === step ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{idx < step ? <Check className="h-3.5 w-3.5" /> : idx + 1}
</div>
<span className={cn("text-sm", idx === step ? "font-medium" : "text-muted-foreground")}>
{label}
</span>
{idx < STEPS.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
)}
const handleConfirm = () => { <form onSubmit={handleSubmit}>
// Backend provider config API not yet implemented {/* Step 0: Choose provider */}
setCompleted(true); {step === 0 && (
<div className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">Choisir un fournisseur</h3>
<div className="grid grid-cols-1 gap-3">
{Object.entries(PROVIDER_INFO).map(([key, pinfo]) => (
<label key={key} className={cn(
"flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors",
selectedProvider === key ? "border-primary bg-primary/5" : "hover:border-muted-foreground/40"
)}>
<input type="radio" value={key} {...form.register("provider")} className="accent-primary" />
<div>
<p className="font-medium text-sm">{pinfo.label}</p>
<p className="text-xs text-muted-foreground">{pinfo.description}</p>
</div>
{selectedProvider === key && <Badge variant="outline" className="ml-auto">Sélectionné</Badge>}
</label>
))}
</div>
<Button type="button" className="w-full" onClick={() => setStep(1)}>
Suivant <ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
)}
{/* Step 1: Connection details */}
{step === 1 && (
<div className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">
Paramètres {PROVIDER_INFO[selectedProvider]?.label}
</h3>
<div className="space-y-1">
<Label htmlFor="display_name">Nom d&apos;affichage (optionnel)</Label>
<Input id="display_name" {...form.register("display_name")} placeholder="Mon OpenAI" />
</div>
{info?.needsApiKey && (
<div className="space-y-1">
<Label htmlFor="api_key">
Clé API {editTarget && <span className="text-muted-foreground">(laisser vide pour conserver)</span>}
</Label>
<Input id="api_key" type="password" {...form.register("api_key")} placeholder="sk-..." />
</div>
)}
{info?.needsBaseUrl && (
<div className="space-y-1">
<Label htmlFor="base_url">URL de base</Label>
<Input id="base_url" {...form.register("base_url")} placeholder="http://localhost:11434/v1" />
</div>
)}
{info?.needsResource && (
<>
<div className="space-y-1">
<Label htmlFor="resource_name">Resource Name (Azure)</Label>
<Input id="resource_name" {...form.register("resource_name")} placeholder="my-azure-resource" />
</div>
<div className="space-y-1">
<Label htmlFor="deployment_id">Deployment ID</Label>
<Input id="deployment_id" {...form.register("deployment_id")} placeholder="gpt-4o" />
</div>
</>
)}
<div className="space-y-1">
<Label htmlFor="model_patterns">Patterns de modèles (optionnel, séparés par virgule)</Label>
<Input id="model_patterns" {...form.register("model_patterns")}
placeholder="tinyllama, deepseek-local, mon-modele" />
<p className="text-xs text-muted-foreground">
Noms de modèles supplémentaires à router vers ce fournisseur
</p>
</div>
<div className="flex gap-2">
{!editTarget && (
<Button type="button" variant="outline" onClick={() => setStep(0)}>Retour</Button>
)}
{editTarget && (
<Button type="button" variant="outline" onClick={onClose}>Annuler</Button>
)}
<Button type="button" className="flex-1" onClick={() => {
if (editTarget) {
void handleSubmit();
} else {
setStep(2);
}
}}>
{editTarget ? "Sauvegarder" : "Suivant"}
{!editTarget && <ChevronRight className="h-4 w-4 ml-1" />}
</Button>
</div>
</div>
)}
{/* Step 2: Confirmation */}
{step === 2 && !editTarget && (
<div className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">Confirmation</h3>
<div className="space-y-2 text-sm">
<Row label="Fournisseur" value={PROVIDER_INFO[formValues.provider]?.label} />
{formValues.display_name && <Row label="Nom" value={formValues.display_name} />}
{formValues.api_key && <Row label="Clé API" value={formValues.api_key.slice(0, 8) + "••••••••"} mono />}
{formValues.base_url && <Row label="URL" value={formValues.base_url} mono />}
{formValues.resource_name && <Row label="Resource" value={formValues.resource_name} mono />}
{formValues.deployment_id && <Row label="Deployment" value={formValues.deployment_id} mono />}
{formValues.model_patterns && <Row label="Patterns" value={formValues.model_patterns} mono />}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setStep(1)}>Retour</Button>
<Button type="submit" className="flex-1" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Check className="h-4 w-4 mr-2" />}
Confirmer
</Button>
</div>
</div>
)}
</form>
</div>
);
}
function Row({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
if (!value) return null;
return (
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">{label}</span>
<span className={mono ? "font-mono" : "font-medium"}>{value}</span>
</div>
);
}
// ─── Provider card ─────────────────────────────────────────────────────────────
interface ProviderCardProps {
config: ProviderConfig;
onEdit: () => void;
onDelete: () => void;
}
function ProviderCard({ config, onEdit, onDelete }: ProviderCardProps) {
const testMutation = useTestProvider();
const [testResult, setTestResult] = useState<{ healthy: boolean; error?: string } | null>(null);
const handleTest = async () => {
setTestResult(null);
try {
const result = await testMutation.mutateAsync(config.id);
setTestResult(result);
} catch {
setTestResult({ healthy: false, error: "Connexion échouée" });
}
}; };
if (completed) { const stateColor: Record<string, string> = {
return ( closed: "bg-green-100 text-green-700",
<div className="flex flex-col items-center justify-center h-64 text-center space-y-4"> open: "bg-red-100 text-red-700",
<div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"> half_open: "bg-yellow-100 text-yellow-700",
<Check className="h-6 w-6 text-green-600" /> };
const stateLabel: Record<string, string> = {
closed: "Opérationnel",
open: "En panne",
half_open: "Récupération",
};
const info = PROVIDER_INFO[config.provider];
return (
<div className="rounded-lg border bg-card p-4 flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">
{config.display_name || info?.label || config.provider}
</span>
<Badge variant="outline" className="text-xs capitalize">{config.provider}</Badge>
{config.state && (
<span className={cn("text-xs px-2 py-0.5 rounded-full font-medium", stateColor[config.state])}>
{stateLabel[config.state] ?? config.state}
{config.failures ? ` (${config.failures} échecs)` : ""}
</span>
)}
{!config.is_active && (
<Badge variant="secondary" className="text-xs">Inactif</Badge>
)}
</div> </div>
{config.base_url && (
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">{config.base_url}</p>
)}
{config.api_key && (
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{config.api_key}</p>
)}
{config.model_patterns.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5">
Patterns : {config.model_patterns.join(", ")}
</p>
)}
{testResult && (
<p className={cn("text-xs mt-1 font-medium", testResult.healthy ? "text-green-600" : "text-destructive")}>
{testResult.healthy ? "✓ Connexion réussie" : `${testResult.error ?? "Échec"}`}
</p>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost" size="sm"
onClick={handleTest}
disabled={testMutation.isPending}
title="Tester la connexion"
>
{testMutation.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
: testResult?.healthy
? <Zap className="h-4 w-4 text-green-600" />
: testResult
? <ZapOff className="h-4 w-4 text-destructive" />
: <RefreshCw className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" onClick={onEdit} title="Modifier">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onDelete} className="text-destructive hover:text-destructive" title="Supprimer">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function ProvidersPage() {
const { data: configs = [], isLoading } = useProviderConfigs();
const deleteMutation = useDeleteProvider();
const [showWizard, setShowWizard] = useState(false);
const [editTarget, setEditTarget] = useState<ProviderConfig | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<ProviderConfig | null>(null);
const handleEdit = (cfg: ProviderConfig) => {
setEditTarget(cfg);
setShowWizard(true);
};
const handleCloseWizard = () => {
setShowWizard(false);
setEditTarget(undefined);
};
const handleConfirmDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
} finally {
setDeleteTarget(null);
}
};
if (showWizard) {
return (
<div className="space-y-4">
<div> <div>
<p className="font-semibold text-lg">Configuration sauvegardée</p> <h2 className="text-xl font-bold">
<p className="text-sm text-muted-foreground mt-1"> {editTarget ? `Modifier — ${PROVIDER_INFO[editTarget.provider]?.label}` : "Ajouter un fournisseur IA"}
Le fournisseur sera actif au prochain redémarrage du proxy. </h2>
<p className="text-sm text-muted-foreground">
{editTarget ? "Mettez à jour la configuration du fournisseur" : "Configurez une connexion LLM"}
</p> </p>
</div> </div>
<Button onClick={() => { setStep(0); setCompleted(false); form1.reset(); form2.reset(); }}> <ProviderWizard editTarget={editTarget} onClose={handleCloseWizard} />
Configurer un autre fournisseur
</Button>
</div> </div>
); );
} }
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
<div> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Fournisseurs IA</h2> <div>
<p className="text-sm text-muted-foreground"> <h2 className="text-xl font-bold">Fournisseurs IA</h2>
Configurez vos connexions aux fournisseurs LLM <p className="text-sm text-muted-foreground">
</p> {configs.length} fournisseur{configs.length !== 1 ? "s" : ""} configuré{configs.length !== 1 ? "s" : ""}
</p>
</div>
<Button onClick={() => { setEditTarget(undefined); setShowWizard(true); }}>
<Plus className="h-4 w-4 mr-2" /> Ajouter
</Button>
</div> </div>
<Alert> {isLoading && (
<Plug className="h-4 w-4" /> <div className="flex items-center justify-center py-12 text-muted-foreground">
<AlertTitle>Configuration via variables d&apos;environnement</AlertTitle> <Loader2 className="h-5 w-5 animate-spin mr-2" /> Chargement
<AlertDescription> </div>
Les clés API sont actuellement gérées via des variables d&apos;environnement dans )}
docker-compose.yml. L&apos;interface de gestion dynamique sera disponible dans un prochain sprint.
</AlertDescription>
</Alert>
{/* Step indicator */} {!isLoading && configs.length === 0 && (
<div className="flex items-center gap-2"> <div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
{STEPS.map((label, idx) => ( <p className="font-medium">Aucun fournisseur configuré</p>
<div key={label} className="flex items-center gap-2"> <p className="text-sm mt-1">Cliquez sur «&nbsp;Ajouter&nbsp;» pour configurer votre premier fournisseur LLM.</p>
<div <Button className="mt-4" onClick={() => setShowWizard(true)}>
className={cn( <Plus className="h-4 w-4 mr-2" /> Ajouter un fournisseur
"h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold", </Button>
idx < step </div>
? "bg-green-100 text-green-700" )}
: idx === step
? "bg-primary text-primary-foreground" <div className="space-y-3">
: "bg-muted text-muted-foreground" {configs.map((cfg) => (
)} <ProviderCard
> key={cfg.id}
{idx < step ? <Check className="h-3.5 w-3.5" /> : idx + 1} config={cfg}
</div> onEdit={() => handleEdit(cfg)}
<span className={cn("text-sm", idx === step ? "font-medium" : "text-muted-foreground")}> onDelete={() => setDeleteTarget(cfg)}
{label} />
</span>
{idx < STEPS.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</div>
))} ))}
</div> </div>
{/* Step 1: Choose provider */} {/* Delete confirmation dialog */}
{step === 0 && ( <AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<form onSubmit={handleStep1} className="space-y-4 rounded-lg border p-6"> <AlertDialogContent>
<h3 className="font-semibold">Choisir un fournisseur</h3> <AlertDialogHeader>
<div className="grid grid-cols-1 gap-3"> <AlertDialogTitle>Supprimer le fournisseur ?</AlertDialogTitle>
{Object.entries(PROVIDER_INFO).map(([key, pinfo]) => ( <AlertDialogDescription>
<label La configuration de{" "}
key={key} <strong>{deleteTarget ? PROVIDER_INFO[deleteTarget.provider]?.label : ""}</strong> sera supprimée.
className={cn( Le proxy reviendra aux paramètres par défaut (variables d&apos;environnement).
"flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors", </AlertDialogDescription>
selectedProvider === key </AlertDialogHeader>
? "border-primary bg-primary/5" <AlertDialogFooter>
: "hover:border-muted-foreground/40" <AlertDialogCancel>Annuler</AlertDialogCancel>
)} <AlertDialogAction
> onClick={handleConfirmDelete}
<input className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
type="radio" >
value={key} Supprimer
{...form1.register("provider")} </AlertDialogAction>
className="accent-primary" </AlertDialogFooter>
/> </AlertDialogContent>
<div> </AlertDialog>
<p className="font-medium text-sm">{pinfo.label}</p>
<p className="text-xs text-muted-foreground">{pinfo.description}</p>
</div>
{selectedProvider === key && (
<Badge variant="outline" className="ml-auto">Sélectionné</Badge>
)}
</label>
))}
</div>
<Button type="submit" className="w-full">
Suivant <ChevronRight className="h-4 w-4 ml-1" />
</Button>
</form>
)}
{/* Step 2: Connection details */}
{step === 1 && step1Data && (
<form onSubmit={handleStep2} className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">Paramètres de connexion {info.label}</h3>
{info.needsBaseUrl ? (
<div className="space-y-1">
<Label htmlFor="base_url">URL de base</Label>
<Input
id="base_url"
{...form2.register("base_url")}
placeholder="http://localhost:11434/v1"
/>
</div>
) : (
<div className="space-y-1">
<Label htmlFor="api_key">Clé API *</Label>
<Input
id="api_key"
type="password"
{...form2.register("api_key")}
placeholder="sk-..."
/>
{form2.formState.errors.api_key && (
<p className="text-xs text-destructive">
{form2.formState.errors.api_key.message}
</p>
)}
</div>
)}
{info.needsResource && (
<>
<div className="space-y-1">
<Label htmlFor="resource_name">Resource Name (Azure)</Label>
<Input
id="resource_name"
{...form2.register("resource_name")}
placeholder="my-azure-resource"
/>
</div>
<div className="space-y-1">
<Label htmlFor="deployment_id">Deployment ID</Label>
<Input
id="deployment_id"
{...form2.register("deployment_id")}
placeholder="gpt-4o"
/>
</div>
</>
)}
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setStep(0)}>
Retour
</Button>
<Button type="submit" className="flex-1">
Suivant <ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</form>
)}
{/* Step 3: Confirmation */}
{step === 2 && step1Data && step2Data && (
<div className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">Confirmation</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Fournisseur</span>
<span className="font-medium">{PROVIDER_INFO[step1Data.provider].label}</span>
</div>
{step2Data.api_key && (
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Clé API</span>
<span className="font-mono">{step2Data.api_key.slice(0, 8)}</span>
</div>
)}
{step2Data.base_url && (
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">URL</span>
<span className="font-mono">{step2Data.base_url}</span>
</div>
)}
{step2Data.resource_name && (
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Resource</span>
<span className="font-mono">{step2Data.resource_name}</span>
</div>
)}
</div>
<Alert variant="warning">
<AlertDescription>
La configuration sera appliquée via une variable d&apos;environnement. Vous devrez
redémarrer le service proxy pour l&apos;activer.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setStep(1)}>
Retour
</Button>
<Button onClick={handleConfirm} className="flex-1">
<Check className="h-4 w-4 mr-2" /> Confirmer
</Button>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,6 +1,9 @@
import { Users, UserCheck, UserX } from "lucide-react"; import { useRef, useState } from "react";
import { Plus, Upload, Pencil, Trash2, UserCheck, UserX, Users, AlertCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
Table, Table,
TableBody, TableBody,
@ -9,7 +12,17 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useListUsers } from "@/api/users"; import {
useListUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
useImportUsers,
type UserPayload,
type ImportResult,
} from "@/api/users";
import { UserForm } from "@/components/UserForm";
import type { User } from "@/types/api";
const roleVariant: Record<string, "default" | "secondary" | "outline" | "warning"> = { const roleVariant: Record<string, "default" | "secondary" | "outline" | "warning"> = {
admin: "default", admin: "default",
@ -18,30 +31,176 @@ const roleVariant: Record<string, "default" | "secondary" | "outline" | "warning
auditor: "warning", auditor: "warning",
}; };
// ─── CSV parsing ──────────────────────────────────────────────────────────────
function parseCSV(text: string): UserPayload[] {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const hasFirstLast = headers.includes("first_name") && headers.includes("last_name");
return lines.slice(1).flatMap((line) => {
const cols = line.split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
const get = (key: string) => cols[headers.indexOf(key)] ?? "";
const email = get("email");
if (!email) return [];
const name = hasFirstLast
? `${get("first_name")} ${get("last_name")}`.trim()
: get("name");
return [{
email,
name: name || email,
department: get("department"),
role: get("role") || "user",
is_active: true,
}];
});
}
// ─── Component ────────────────────────────────────────────────────────────────
export function UsersPage() { export function UsersPage() {
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | undefined>();
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const csvInputRef = useRef<HTMLInputElement>(null);
const { data: users = [], isLoading } = useListUsers(); const { data: users = [], isLoading } = useListUsers();
const createMutation = useCreateUser();
const updateMutation = useUpdateUser();
const deleteMutation = useDeleteUser();
const importMutation = useImportUsers();
const activeCount = users.filter((u) => u.is_active).length; const activeCount = users.filter((u) => u.is_active).length;
const inactiveCount = users.length - activeCount; const inactiveCount = users.length - activeCount;
const handleSubmit = async (payload: UserPayload) => {
try {
if (editingUser) {
await updateMutation.mutateAsync({ id: editingUser.id, ...payload });
} else {
await createMutation.mutateAsync(payload);
}
setFormOpen(false);
setEditingUser(undefined);
setErrorMsg(null);
} catch (e) {
setErrorMsg(e instanceof Error ? e.message : "Une erreur est survenue");
}
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormOpen(true);
};
const handleOpenCreate = () => {
setEditingUser(undefined);
setFormOpen(true);
};
const handleToggleActive = (user: User) => {
setErrorMsg(null);
updateMutation.mutate(
{ id: user.id, email: user.email, name: user.name, department: user.department, role: user.role, is_active: !user.is_active },
{ onError: (e) => setErrorMsg(e instanceof Error ? e.message : "Erreur lors de la mise à jour") }
);
};
const handleDelete = (user: User) => {
if (confirm(`Supprimer définitivement "${user.name}" (${user.email}) ?`)) {
setErrorMsg(null);
deleteMutation.mutate(user.id, {
onError: (e) => setErrorMsg(e instanceof Error ? e.message : "Erreur lors de la suppression"),
});
}
};
const handleCSVFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
setErrorMsg(null);
setImportResult(null);
const text = await file.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setErrorMsg("Aucun utilisateur trouvé dans le fichier. Format attendu : email,name,department,role ou email,first_name,last_name,department,role");
return;
}
try {
const result = await importMutation.mutateAsync(rows);
setImportResult(result);
} catch (e) {
setErrorMsg(e instanceof Error ? e.message : "Erreur lors de l'import");
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold">Utilisateurs</h2> <h2 className="text-xl font-bold">Utilisateurs</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Gérez les accès et les rôles des utilisateurs</p>
Gérez les accès et les rôles des utilisateurs </div>
</p> <div className="flex gap-2">
<Button variant="outline" onClick={() => csvInputRef.current?.click()} disabled={importMutation.isPending}>
<Upload className="h-4 w-4 mr-2" />
{importMutation.isPending ? "Import en cours..." : "Importer CSV"}
</Button>
<input
ref={csvInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleCSVFile}
/>
<Button onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-2" />
Nouvel utilisateur
</Button>
</div> </div>
</div> </div>
{/* Error banner */}
{errorMsg && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>{errorMsg}</span>
<button className="ml-4 opacity-60 hover:opacity-100" onClick={() => setErrorMsg(null)}></button>
</AlertDescription>
</Alert>
)}
{/* Import result banner */}
{importResult && (
<div className={`rounded-lg border p-3 text-sm flex items-center justify-between ${importResult.failed === 0 ? "bg-green-50 border-green-200 text-green-800" : "bg-yellow-50 border-yellow-200 text-yellow-800"}`}>
<span>
Import terminé <strong>{importResult.success}</strong> créé(s)
{importResult.failed > 0 && <>, <strong>{importResult.failed}</strong> échoué(s) : {importResult.errors.join(", ")}</>}
</span>
<button className="ml-4 opacity-60 hover:opacity-100" onClick={() => setImportResult(null)}></button>
</div>
)}
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="rounded-lg border bg-card p-4 flex items-center gap-3"> <div className="rounded-lg border bg-card p-4 flex items-center gap-3">
<Users className="h-8 w-8 text-muted-foreground" /> <Users className="h-8 w-8 text-muted-foreground" />
<div> <div>
<p className="text-2xl font-bold">{isLoading ? "—" : users.length}</p> <p className="text-2xl font-bold">{isLoading ? "—" : users.length}</p>
<p className="text-xs text-muted-foreground">Utilisateurs</p> <p className="text-xs text-muted-foreground">Total</p>
</div> </div>
</div> </div>
<div className="rounded-lg border bg-card p-4 flex items-center gap-3"> <div className="rounded-lg border bg-card p-4 flex items-center gap-3">
@ -60,6 +219,7 @@ export function UsersPage() {
</div> </div>
</div> </div>
{/* Table */}
{isLoading ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
@ -76,18 +236,19 @@ export function UsersPage() {
<TableHead>Rôle</TableHead> <TableHead>Rôle</TableHead>
<TableHead>Statut</TableHead> <TableHead>Statut</TableHead>
<TableHead>Créé le</TableHead> <TableHead>Créé le</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.length === 0 ? ( {users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-10"> <TableCell colSpan={6} className="text-center text-muted-foreground py-10">
Aucun utilisateur trouvé Aucun utilisateur créez-en un ou importez un CSV.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
users.map((user) => ( users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id} className={!user.is_active ? "opacity-50" : undefined}>
<TableCell> <TableCell>
<div> <div>
<p className="font-medium text-sm">{user.name}</p> <p className="font-medium text-sm">{user.name}</p>
@ -101,16 +262,36 @@ export function UsersPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <button
variant={user.is_active ? "success" : "secondary"} onClick={() => handleToggleActive(user)}
className="capitalize" disabled={updateMutation.isPending}
title={user.is_active ? "Désactiver" : "Réactiver"}
className="cursor-pointer"
> >
{user.is_active ? "Actif" : "Inactif"} <Badge variant={user.is_active ? "success" : "secondary"}>
</Badge> {user.is_active ? "Actif" : "Inactif"}
</Badge>
</button>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(user.created_at).toLocaleDateString("fr-FR")} {new Date(user.created_at).toLocaleDateString("fr-FR")}
</TableCell> </TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => handleEdit(user)} title="Modifier">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user)}
disabled={deleteMutation.isPending}
title="Supprimer"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
@ -118,6 +299,17 @@ export function UsersPage() {
</Table> </Table>
</div> </div>
)} )}
<UserForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) setEditingUser(undefined);
}}
user={editingUser}
onSubmit={handleSubmit}
isPending={isPending}
/>
</div> </div>
); );
} }

View File

@ -93,6 +93,41 @@ export interface ProviderStatus {
opened_at?: string; opened_at?: string;
} }
export interface ProviderConfig {
id: string;
tenant_id: string;
provider: "openai" | "anthropic" | "azure" | "mistral" | "ollama";
display_name: string;
api_key?: string; // masked on read (e.g. "sk-abc123••••••••"), plaintext on write
base_url?: string;
resource_name?: string;
deployment_id?: string;
api_version?: string;
timeout_sec: number;
max_conns: number;
model_patterns: string[];
is_active: boolean;
created_at: string;
updated_at: string;
// enriched by list endpoint
state?: "closed" | "open" | "half_open";
failures?: number;
}
export interface CreateProviderRequest {
provider: "openai" | "anthropic" | "azure" | "mistral" | "ollama";
display_name?: string;
api_key?: string;
base_url?: string;
resource_name?: string;
deployment_id?: string;
api_version?: string;
timeout_sec?: number;
max_conns?: number;
model_patterns?: string[];
is_active?: boolean;
}
export interface User { export interface User {
id: string; id: string;
tenant_id: string; tenant_id: string;

View File

@ -13,7 +13,7 @@ export default defineConfig({
port: 3000, port: 3000,
proxy: { proxy: {
"/v1": { "/v1": {
target: "http://localhost:8090", target: process.env.VITE_PROXY_TARGET ?? "http://localhost:8090",
changeOrigin: true, changeOrigin: true,
}, },
}, },