diff --git a/CLAUDE.md b/CLAUDE.md index e53f67d..ec48c3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. -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 @@ -18,7 +18,8 @@ Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month API Gateway (Traefik) │ 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/routing/ Rules engine (PostgreSQL JSONB, in-memory cache, priority ASC) ├── 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`) - ClickHouse — analytics and immutable audit logs - 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) - 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. @@ -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): ```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:** ```bash 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`. +**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`. +**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 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 - **ClickHouse unreachable** → audit logging disabled - **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`) - 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 -- CI pipeline: Semgrep (SAST), Trivy (image scanning, CRITICAL/HIGH blocking), gitleaks (secret detection), OWASP ZAP DAST (non-blocking, main branch 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 +- 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 (`.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) diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 09e2d64..5b7e958 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -20,6 +20,7 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" // register pgx driver "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/circuitbreaker" "github.com/veylant/ia-gateway/internal/compliance" @@ -52,21 +53,10 @@ func main() { logger := buildLogger(cfg.Log.Level, cfg.Log.Format) defer logger.Sync() //nolint:errcheck - // ── JWT / OIDC verifier ─────────────────────────────────────────────────── - issuerURL := fmt.Sprintf("%s/realms/%s", cfg.Keycloak.BaseURL, cfg.Keycloak.Realm) - logger.Info("initialising OIDC verifier", zap.String("issuer", issuerURL)) - + // ── Local JWT verifier (email/password auth — replaces Keycloak OIDC) ──── ctx := context.Background() - oidcVerifier, err := middleware.NewOIDCVerifier(ctx, issuerURL, cfg.Keycloak.ClientID) - if err != nil { - 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)) - } - } + jwtVerifier := auth.NewLocalJWTVerifier(cfg.Auth.JWTSecret) + logger.Info("local JWT verifier initialised") // ── LLM provider adapters ───────────────────────────────────────────────── adapters := map[string]provider.Adapter{} @@ -199,6 +189,11 @@ func main() { 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) ──────────────────────────────────── var auditLogger auditlog.Logger if cfg.ClickHouse.DSN != "" { @@ -298,23 +293,13 @@ func main() { 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.Use(middleware.CORS(cfg.Server.AllowedOrigins)) - var authMW func(http.Handler) http.Handler - 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.Auth(jwtVerifier)) r.Use(middleware.RateLimit(rateLimiter)) r.Post("/chat/completions", proxyHandler.ServeHTTP) @@ -340,8 +325,8 @@ func main() { logger, ) } - // Wire db, router, rate limiter, and feature flags (Sprint 8 + Sprint 10 + Sprint 11). - adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore) + // Wire db, router, rate limiter, feature flags, and encryptor. + adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor) r.Route("/admin", adminHandler.Routes) } @@ -375,7 +360,7 @@ func main() { zap.String("addr", addr), zap.String("env", cfg.Server.Env), zap.Bool("metrics", cfg.Metrics.Enabled), - zap.String("oidc_issuer", issuerURL), + zap.String("auth", "local-jwt"), zap.Bool("audit_logging", auditLogger != nil), zap.Bool("encryption", encryptor != nil), ) @@ -402,6 +387,61 @@ func main() { 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 { lvl := zap.InfoLevel if err := lvl.UnmarshalText([]byte(level)); err != nil { diff --git a/config.yaml b/config.yaml index 8ad1509..82a6c5c 100644 --- a/config.yaml +++ b/config.yaml @@ -17,10 +17,11 @@ database: redis: url: "redis://localhost:6379" -keycloak: - base_url: "http://localhost:8080" - realm: "veylant" - client_id: "veylant-proxy" +# Local JWT authentication (email/password — replaces Keycloak). +# Override jwt_secret in production via VEYLANT_AUTH_JWT_SECRET. +auth: + jwt_secret: "change-me-in-production-use-VEYLANT_AUTH_JWT_SECRET" + jwt_ttl_hours: 24 pii: enabled: true diff --git a/docker-compose.yml b/docker-compose.yml index df5c1c7..95d324f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,30 +63,6 @@ services: soft: 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 # ───────────────────────────────────────────── @@ -101,9 +77,9 @@ services: VEYLANT_SERVER_ENV: "development" VEYLANT_DATABASE_URL: "postgres://veylant:veylant_dev@postgres:5432/veylant?sslmode=disable" VEYLANT_REDIS_URL: "redis://redis:6379" - VEYLANT_KEYCLOAK_BASE_URL: "http://keycloak:8080" - VEYLANT_KEYCLOAK_REALM: "veylant" - VEYLANT_KEYCLOAK_CLIENT_ID: "veylant-proxy" + # Local JWT secret — override in production: VEYLANT_AUTH_JWT_SECRET= + VEYLANT_AUTH_JWT_SECRET: "${VEYLANT_AUTH_JWT_SECRET:-dev-jwt-secret-change-in-prod}" + VEYLANT_AUTH_JWT_TTL_HOURS: "24" VEYLANT_PII_ENABLED: "true" VEYLANT_PII_SERVICE_ADDR: "pii:50051" VEYLANT_PII_TIMEOUT_MS: "100" @@ -155,10 +131,10 @@ services: PII_HTTP_PORT: "8000" PII_REDIS_URL: "redis://redis:6379" # 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). - PII_ENCRYPTION_KEY: "${PII_ENCRYPTION_KEY:-}" + # Dev default = 32 zero bytes (NOT safe for production). + PII_ENCRYPTION_KEY: "${PII_ENCRYPTION_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}" PII_NER_ENABLED: "true" - PII_NER_CONFIDENCE: "0.85" + PII_NER_CONFIDENCE: "0.65" PII_TTL_SECONDS: "3600" depends_on: redis: @@ -221,8 +197,8 @@ services: - ./web:/app - /app/node_modules environment: - VITE_AUTH_MODE: "dev" - VITE_KEYCLOAK_URL: "http://localhost:8080/realms/veylant" + VITE_AUTH_MODE: "email" + VITE_PROXY_TARGET: "http://proxy:8090" depends_on: proxy: condition: service_started diff --git a/go.mod b/go.mod index 722e0f3..6e15abd 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // 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/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index e812ba4..ef0f035 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= 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/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/internal/admin/handler.go b/internal/admin/handler.go index ba9e7b3..c2834f5 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -17,6 +17,7 @@ import ( "github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/auditlog" "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/middleware" "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, // provider circuit breaker status, rate limit configuration, and feature flags. type Handler struct { - store routing.RuleStore - cache *routing.RuleCache - auditLogger auditlog.Logger // nil = logs/costs endpoints return 501 - db *sql.DB // nil = users endpoints return 501 - router ProviderRouter // nil = providers/status returns 501 - rateLimiter *ratelimit.Limiter // nil = rate-limits endpoints return 501 - rlStore *ratelimit.Store // nil if db is nil - flagStore flags.FlagStore // nil = flags endpoints return 501 - logger *zap.Logger + store routing.RuleStore + cache *routing.RuleCache + auditLogger auditlog.Logger // nil = logs/costs endpoints return 501 + db *sql.DB // nil = users endpoints return 501 + router ProviderRouter // nil = providers/status returns 501 + adapterRouter ProviderAdapterRouter // nil = provider CRUD hot-reload disabled + encryptor *crypto.Encryptor // nil = API keys stored in plaintext (dev only) + rateLimiter *ratelimit.Limiter // nil = rate-limits endpoints return 501 + rlStore *ratelimit.Store // nil if db is nil + flagStore flags.FlagStore // nil = flags endpoints return 501 + logger *zap.Logger } // New creates a Handler. @@ -65,6 +68,16 @@ func (h *Handler) WithDB(db *sql.DB) *Handler { // WithRouter adds provider router for circuit breaker status. func (h *Handler) WithRouter(r ProviderRouter) *Handler { 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 } @@ -108,6 +121,13 @@ func (h *Handler) Routes(r chi.Router) { // Provider circuit breaker status (E2-09 / E2-10). 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). r.Get("/rate-limits", h.listRateLimits) r.Get("/rate-limits/{tenant_id}", h.getRateLimit) diff --git a/internal/admin/provider_configs.go b/internal/admin/provider_configs.go new file mode 100644 index 0000000..7fa2d16 --- /dev/null +++ b/internal/admin/provider_configs.go @@ -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 +} diff --git a/internal/admin/users.go b/internal/admin/users.go index a2feccb..6ca000b 100644 --- a/internal/admin/users.go +++ b/internal/admin/users.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" "github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/middleware" @@ -32,6 +33,7 @@ type createUserRequest struct { Department string `json:"department"` Role string `json:"role"` IsActive *bool `json:"is_active"` + Password string `json:"password"` // optional; bcrypt-hashed before storage } // 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 } -func (s *userStore) create(u User) (*User, error) { +func (s *userStore) create(u User, passwordHash string) (*User, error) { var created User err := s.db.QueryRow( - `INSERT INTO users (tenant_id, email, name, department, role, is_active) - VALUES ($1,$2,$3,$4,$5,$6) + `INSERT INTO users (tenant_id, email, name, department, role, is_active, password_hash) + VALUES ($1,$2,$3,$4,$5,$6,$7) 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, &created.Role, &created.IsActive, &created.CreatedAt, &created.UpdatedAt) return &created, err } -func (s *userStore) update(u User) (*User, error) { +func (s *userStore) update(u User, passwordHash string) (*User, error) { var updated User - 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) + var err error + if passwordHash != "" { + err = s.db.QueryRow( + `UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, password_hash=$6, updated_at=NOW() + WHERE id=$7 AND tenant_id=$8 + 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, 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 { return nil, nil } @@ -173,6 +186,16 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { 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) created, err := us.create(User{ TenantID: tenantID, @@ -181,7 +204,7 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { Department: req.Department, Role: role, IsActive: isActive, - }) + }, passwordHash) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError("failed to create user: "+err.Error())) return @@ -233,6 +256,16 @@ func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { 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) updated, err := us.update(User{ ID: id, @@ -242,7 +275,7 @@ func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { Department: req.Department, Role: req.Role, IsActive: isActive, - }) + }, passwordHash) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError(err.Error())) return diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..3dfe2e3 --- /dev/null +++ b/internal/auth/jwt.go @@ -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)) +} diff --git a/internal/auth/login.go b/internal/auth/login.go new file mode 100644 index 0000000..e203066 --- /dev/null +++ b/internal/auth/login.go @@ -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}) +} diff --git a/internal/config/config.go b/internal/config/config.go index d8f78a4..7283eb9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` - Keycloak KeycloakConfig `mapstructure:"keycloak"` + Auth AuthConfig `mapstructure:"auth"` PII PIIConfig `mapstructure:"pii"` Log LogConfig `mapstructure:"log"` Providers ProvidersConfig `mapstructure:"providers"` @@ -145,10 +145,11 @@ type RedisConfig struct { URL string `mapstructure:"url"` } -type KeycloakConfig struct { - BaseURL string `mapstructure:"base_url"` - Realm string `mapstructure:"realm"` - ClientID string `mapstructure:"client_id"` +// AuthConfig holds local JWT authentication settings. +// Override via VEYLANT_AUTH_JWT_SECRET and VEYLANT_AUTH_JWT_TTL_HOURS. +type AuthConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` + JWTTTLHours int `mapstructure:"jwt_ttl_hours"` } type PIIConfig struct { @@ -186,6 +187,8 @@ func Load() (*Config, error) { v.SetDefault("database.max_open_conns", 25) v.SetDefault("database.max_idle_conns", 5) 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.service_addr", "localhost:50051") v.SetDefault("pii.timeout_ms", 100) diff --git a/internal/pii/client.go b/internal/pii/client.go index 1fc2f4c..2566f6a 100644 --- a/internal/pii/client.go +++ b/internal/pii/client.go @@ -101,7 +101,7 @@ func (c *Client) Detect( RequestId: requestID, Options: &piiv1.PiiOptions{ EnableNer: enableNER, - ConfidenceThreshold: 0.85, + ConfidenceThreshold: 0.65, ZeroRetention: zeroRetention, }, }) diff --git a/internal/pii/http.go b/internal/pii/http.go index 8634eb7..e2ab7b1 100644 --- a/internal/pii/http.go +++ b/internal/pii/http.go @@ -2,7 +2,11 @@ package pii import ( "encoding/json" + "fmt" "net/http" + "regexp" + "sort" + "strings" "go.uber.org/zap" @@ -30,14 +34,14 @@ type AnalyzeResponse struct { } // 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 { client *Client logger *zap.Logger } // 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 { return &AnalyzeHandler{client: client, logger: logger} } @@ -55,45 +59,126 @@ func (h *AnalyzeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // PII service disabled — return text unchanged. - if h.client == nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(AnalyzeResponse{ - Anonymized: req.Text, - Entities: []AnalyzeEntity{}, - }) - return - } - - resp, err := h.client.Detect(r.Context(), req.Text, "playground", "playground-analyze", true, false) - if err != nil { - h.logger.Warn("PII analyze failed", zap.Error(err)) - // Fail-open: return text unchanged rather than erroring. - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(AnalyzeResponse{ - Anonymized: req.Text, - Entities: []AnalyzeEntity{}, - }) - return - } - - entities := make([]AnalyzeEntity, 0, len(resp.Entities)) - for _, e := range resp.Entities { - entities = append(entities, AnalyzeEntity{ - Type: e.EntityType, - Start: int(e.Start), - End: int(e.End), - Confidence: float64(e.Confidence), - Layer: e.DetectionLayer, - }) + // Attempt real PII detection if service is available. + // NOTE: when fail_open=true, Detect() returns (result, nil) even on RPC + // failure, but sets result.Entities to nil to signal the degraded path. + // A real successful response always has a non-nil Entities slice. + if h.client != nil { + resp, err := h.client.Detect(r.Context(), req.Text, "playground", "playground-analyze", true, false) + if err == nil && resp.Entities != nil { + // Real PII service response (may be empty if no PII detected). + entities := make([]AnalyzeEntity, 0, len(resp.Entities)) + for _, e := range resp.Entities { + entities = append(entities, AnalyzeEntity{ + Type: e.EntityType, + Start: int(e.Start), + End: int(e.End), + Confidence: float64(e.Confidence), + Layer: e.DetectionLayer, + }) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(AnalyzeResponse{ + Anonymized: resp.AnonymizedText, + Entities: entities, + }) + return + } + if err != nil { + h.logger.Warn("PII service error — falling back to regex detection", zap.Error(err)) + } else { + h.logger.Debug("PII service unavailable (fail-open) — falling back to regex detection") + } } + // 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.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(AnalyzeResponse{ - Anonymized: resp.AnonymizedText, - Entities: entities, - }) + _ = json.NewEncoder(w).Encode(result) +} + +// ── 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, + } } diff --git a/internal/router/router.go b/internal/router/router.go index ada390f..0583445 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -25,16 +25,36 @@ type modelRule struct { // defaultModelRules maps model name prefixes to provider names. // Rules are evaluated in order; first match wins. +// Ollama rules cover common self-hosted model families available via `ollama pull`. var defaultModelRules = []modelRule{ + // Cloud providers (matched before generic local names) {"gpt-", "openai"}, {"o1-", "openai"}, {"o3-", "openai"}, {"claude-", "anthropic"}, - {"mistral-", "mistral"}, + {"mistral-", "mistral"}, // Mistral AI cloud (mistral-small, mistral-large…) {"mixtral-", "mistral"}, - {"llama", "ollama"}, - {"phi", "ollama"}, - {"qwen", "ollama"}, + // Ollama / self-hosted models + {"llama", "ollama"}, // llama3, llama3.2, llama2… + {"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 @@ -92,6 +112,25 @@ func (r *Router) WithFlagStore(fs flags.FlagStore) *Router { 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. // Returns an empty slice when no circuit breaker is configured. func (r *Router) ProviderStatuses() []circuitbreaker.Status { diff --git a/migrations/000010_local_auth.down.sql b/migrations/000010_local_auth.down.sql new file mode 100644 index 0000000..d5c5085 --- /dev/null +++ b/migrations/000010_local_auth.down.sql @@ -0,0 +1,2 @@ +-- Rollback 000010 +ALTER TABLE users DROP COLUMN IF EXISTS password_hash; diff --git a/migrations/000010_local_auth.up.sql b/migrations/000010_local_auth.up.sql new file mode 100644 index 0000000..fd672b6 --- /dev/null +++ b/migrations/000010_local_auth.up.sql @@ -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'; diff --git a/migrations/000011_provider_configs.down.sql b/migrations/000011_provider_configs.down.sql new file mode 100644 index 0000000..cb4252c --- /dev/null +++ b/migrations/000011_provider_configs.down.sql @@ -0,0 +1,2 @@ +-- Rollback 000011 +DROP TABLE IF EXISTS provider_configs; diff --git a/migrations/000011_provider_configs.up.sql b/migrations/000011_provider_configs.up.sql new file mode 100644 index 0000000..b75abc8 --- /dev/null +++ b/migrations/000011_provider_configs.up.sql @@ -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(); diff --git a/migrations/000012_users_add_name.down.sql b/migrations/000012_users_add_name.down.sql new file mode 100644 index 0000000..960ae7b --- /dev/null +++ b/migrations/000012_users_add_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS name; diff --git a/migrations/000012_users_add_name.up.sql b/migrations/000012_users_add_name.up.sql new file mode 100644 index 0000000..9ca02ab --- /dev/null +++ b/migrations/000012_users_add_name.up.sql @@ -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 ''; diff --git a/proxy b/proxy index 0750833..ba90b4f 100755 Binary files a/proxy and b/proxy differ diff --git a/services/pii/Dockerfile b/services/pii/Dockerfile index 6f30375..7bc3b1d 100644 --- a/services/pii/Dockerfile +++ b/services/pii/Dockerfile @@ -2,6 +2,9 @@ FROM python:3.12-slim 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 RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ diff --git a/services/pii/layers/ner_layer.py b/services/pii/layers/ner_layer.py index 1925b12..9c9d4ff 100644 --- a/services/pii/layers/ner_layer.py +++ b/services/pii/layers/ner_layer.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING from layers.regex_layer import DetectedEntity if TYPE_CHECKING: - from presidio_analyzer import AnalyzerEngine + from presidio_analyzer import AnalyzerEngine # noqa: F401 — type hint only logger = logging.getLogger(__name__) @@ -95,15 +95,25 @@ class NERLayer: def _build_analyzer(self) -> "AnalyzerEngine": from presidio_analyzer import AnalyzerEngine - from presidio_analyzer.nlp_engine import NlpEngineProvider + from presidio_analyzer.nlp_engine import NerModelConfiguration, SpacyNlpEngine - configuration = { - "nlp_engine_name": "spacy", - "models": [ + # The default NerModelConfiguration incorrectly places 'ORG' and 'ORGANIZATION' + # in labels_to_ignore, which prevents ORGANIZATION entities from being returned. + # 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": "en", "model_name": self._en_model}, ], - } - provider = NlpEngineProvider(nlp_configuration=configuration) - nlp_engine = provider.create_engine() + ner_model_configuration=ner_config, + ) return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["fr", "en"]) diff --git a/services/pii/layers/regex_layer.py b/services/pii/layers/regex_layer.py index 4fd6959..56c6645 100644 --- a/services/pii/layers/regex_layer.py +++ b/services/pii/layers/regex_layer.py @@ -67,9 +67,9 @@ _RE_EMAIL = re.compile( 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( - 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 @@ -77,10 +77,10 @@ _RE_PHONE_INTL = re.compile( r"(? list[DetectedEntity]: @@ -186,13 +189,16 @@ class RegexLayer: results = [] for m in _RE_CREDIT_CARD.finditer(text): digits_only = re.sub(r"[\s\-]", "", m.group(1)) - if _luhn_valid(digits_only): - results.append( - DetectedEntity( - entity_type="CREDIT_CARD", - original_value=m.group(1), - start=m.start(1), - end=m.end(1), - ) + # Accept cards that fail Luhn at lower confidence (test/demo card numbers + # often don't have a valid Luhn checksum). + confidence = 1.0 if _luhn_valid(digits_only) else 0.75 + results.append( + DetectedEntity( + entity_type="CREDIT_CARD", + original_value=m.group(1), + start=m.start(1), + end=m.end(1), + confidence=confidence, ) + ) return results diff --git a/services/pii/requirements.txt b/services/pii/requirements.txt index 91d875b..12f8cff 100644 --- a/services/pii/requirements.txt +++ b/services/pii/requirements.txt @@ -2,10 +2,11 @@ fastapi==0.115.6 uvicorn[standard]==0.32.1 -# gRPC -grpcio==1.68.1 -grpcio-tools==1.68.1 -grpcio-health-checking==1.68.1 +# gRPC — versions must match the buf-generated stubs in gen/pii/v1/ +grpcio==1.78.1 +grpcio-tools==1.78.1 +grpcio-health-checking==1.78.1 +protobuf==6.31.1 # PII detection (Sprint 3) presidio-analyzer==2.2.356 diff --git a/web/src/api/providers.ts b/web/src/api/providers.ts index 055b4c3..cb60fc1 100644 --- a/web/src/api/providers.ts +++ b/web/src/api/providers.ts @@ -1,6 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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() { return useQuery({ @@ -9,3 +11,58 @@ export function useProviderStatuses() { 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("/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(`/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(`/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", + }), + }); +} diff --git a/web/src/api/users.ts b/web/src/api/users.ts index d2e58eb..73859bc 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -2,6 +2,21 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "./client"; 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() { return useQuery({ queryKey: ["users"], @@ -12,7 +27,7 @@ export function useListUsers() { export function useCreateUser() { const qc = useQueryClient(); return useMutation({ - mutationFn: (body: Omit) => + mutationFn: (body: UserPayload) => apiFetch("/v1/admin/users", { method: "POST", body: JSON.stringify(body) }), onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), }); @@ -21,7 +36,7 @@ export function useCreateUser() { export function useUpdateUser() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, ...body }: Partial & { id: string }) => + mutationFn: ({ id, ...body }: UserPayload & { id: string }) => apiFetch(`/v1/admin/users/${id}`, { method: "PUT", body: JSON.stringify(body) }), onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), }); @@ -35,3 +50,23 @@ export function useDeleteUser() { onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), }); } + +export function useImportUsers() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (users: UserPayload[]): Promise => { + const result: ImportResult = { success: 0, failed: 0, errors: [] }; + for (const user of users) { + try { + await apiFetch("/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"] }), + }); +} diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx index 7ba9639..931a3c3 100644 --- a/web/src/auth/AuthProvider.tsx +++ b/web/src/auth/AuthProvider.tsx @@ -1,9 +1,6 @@ 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"; - -// ─── Shared user type ───────────────────────────────────────────────────────── +// ─── Types ──────────────────────────────────────────────────────────────────── export interface AuthUser { id: string; @@ -13,140 +10,129 @@ export interface AuthUser { token: string; } -// ─── Dev mode auth ──────────────────────────────────────────────────────────── - -interface DevAuthContextValue { +interface AuthContextValue { user: AuthUser | null; isLoading: boolean; isAuthenticated: boolean; - login: () => void; + login: (email: string, password: string) => Promise; logout: () => void; } -const DevAuthContext = createContext(null); +// ─── Context ────────────────────────────────────────────────────────────────── -const DEV_USER: AuthUser = { - id: "dev-user", - email: "dev@veylant.local", - name: "Dev Admin", - roles: ["admin"], - token: "dev-token", -}; +const AuthContext = createContext(null); -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 | 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; + } 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(() => { - 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(() => { - sessionStorage.setItem("dev-authed", "1"); - setUser(DEV_USER); + const login = useCallback(async (email: string, password: string) => { + setIsLoading(true); + 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(() => { - sessionStorage.removeItem("dev-authed"); + localStorage.removeItem(TOKEN_KEY); 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 ( - {children} - + ); } -// ─── OIDC mode adapter ──────────────────────────────────────────────────────── +// ─── Hook ───────────────────────────────────────────────────────────────────── -interface UnifiedAuthContextValue { - user: AuthUser | null; - isLoading: boolean; - isAuthenticated: boolean; - login: () => void; - logout: () => void; -} - -const UnifiedAuthContext = createContext(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)["realm_access"] - ? ((auth.user.profile as Record)["realm_access"] as { roles: string[] }).roles - : [], - token: auth.user.access_token, - } - : null; - - return ( - void auth.signinRedirect(), - logout: () => void auth.signoutRedirect(), - }} - > - {children} - - ); -} - -// ─── 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); +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); 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 {children}; - } - - return ( - - - - {(ctx) => - ctx ? ( - {children} - ) : null - } - - - - ); -} - -// Token accessor for API client (outside React tree) let _getToken: (() => string | null) | null = null; export function setTokenAccessor(fn: () => string | null) { @@ -154,10 +140,14 @@ export function setTokenAccessor(fn: () => 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() { const auth = useAuth(); useEffect(() => { diff --git a/web/src/components/PiiHighlight.tsx b/web/src/components/PiiHighlight.tsx index 11e936b..7a4c97e 100644 --- a/web/src/components/PiiHighlight.tsx +++ b/web/src/components/PiiHighlight.tsx @@ -1,14 +1,32 @@ +import type { ReactNode } from "react"; import type { PiiEntity } from "@/types/api"; const ENTITY_COLORS: Record = { + // 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" }, + // Email + EMAIL_ADDRESS: { 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_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" }, + 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_NIF: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" }, + // Location 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." }, + ORG: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." }, }; 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 {text}; } - // Sort by start position - const sorted = [...entities].sort((a, b) => a.start - b.start); + // Sort by start position and remove overlapping spans. + const sorted = [...entities] + .sort((a, b) => a.start - b.start) + .reduce((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; 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({text.slice(cursor, start)}); + } + if (start < end) { + const color = ENTITY_COLORS[entity.type] ?? DEFAULT_COLOR; + const layerNote = entity.layer === "regex" ? " (détection locale)" : ""; parts.push( - {text.slice(cursor, entity.start)} + + {text.slice(start, end)} + {color.label} + ); } - const color = ENTITY_COLORS[entity.type] ?? DEFAULT_COLOR; - parts.push( - - {text.slice(entity.start, entity.end)} - {color.label} - - ); - cursor = entity.end; + cursor = end; } if (cursor < text.length) { - parts.push({text.slice(cursor)}); + parts.push({text.slice(cursor)}); } return {parts}; diff --git a/web/src/components/UserForm.tsx b/web/src/components/UserForm.tsx new file mode 100644 index 0000000..f10968c --- /dev/null +++ b/web/src/components/UserForm.tsx @@ -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; + +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({ + 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 ( + + + + {user ? "Modifier l'utilisateur" : "Nouvel utilisateur"} + +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ setValue("is_active", v)} /> + +
+ + + + + +
+
+
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 65cca7b..95a9107 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,23 +1,38 @@ -import { useEffect } from "react"; +import { useState, useEffect } from "react"; 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 { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { useAuth } from "@/auth/AuthProvider"; -const AUTH_MODE = import.meta.env.VITE_AUTH_MODE ?? "dev"; - export function LoginPage() { const { login, isAuthenticated, isLoading } = useAuth(); const navigate = useNavigate(); const location = useLocation(); 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(null); + useEffect(() => { if (isAuthenticated) { navigate(from, { replace: true }); } }, [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 (
@@ -34,36 +49,65 @@ export function LoginPage() {

Connexion

- {AUTH_MODE === "dev" ? ( -

- Mode développement — authentification simulée -

- ) : ( -

- Connectez-vous via votre compte Keycloak -

- )} +

+ Entrez vos identifiants pour accéder au dashboard +

- - - {AUTH_MODE === "dev" && ( -
-

Utilisateurs de test disponibles :

-

admin@veylant.dev / admin123

-

manager@veylant.dev / manager123

-

user@veylant.dev / user123

-

auditor@veylant.dev / auditor123

+
+
+ + setEmail(e.target.value)} + disabled={isLoading} + required + />
- )} + +
+ +
+ setPassword(e.target.value)} + disabled={isLoading} + required + className="pr-10" + /> + +
+
+ + {error && ( +

{error}

+ )} + + +
+ +
+

Compte de test :

+

admin@veylant.dev / admin123

+
diff --git a/web/src/pages/PlaygroundPage.tsx b/web/src/pages/PlaygroundPage.tsx index 57ead85..a544a88 100644 --- a/web/src/pages/PlaygroundPage.tsx +++ b/web/src/pages/PlaygroundPage.tsx @@ -1,5 +1,5 @@ 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 { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; @@ -26,6 +26,18 @@ const MODELS = [ const DEBOUNCE_MS = 600; +// Legend mapping — covers both Python PII service types and Go regex-local fallback types. +const ENTITY_LEGEND: Record = { + 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 { role: "user" | "assistant"; content: string; @@ -36,37 +48,48 @@ export function PlaygroundPage() { const [model, setModel] = useState(MODELS[0].value); const [entities, setEntities] = useState([]); const [anonymized, setAnonymized] = useState(""); + const [piiLayer, setPiiLayer] = useState<"service" | "regex" | "none">("none"); const [aiResponse, setAiResponse] = useState(""); const [isSending, setIsSending] = useState(false); const debounceRef = useRef | 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 piiMutationRef = useRef(piiMutation); + piiMutationRef.current = piiMutation; - // Debounced PII analysis on text change - const handleTextChange = useCallback( - (value: string) => { - setText(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - if (!value.trim()) { - setEntities([]); - setAnonymized(""); - return; - } - debounceRef.current = setTimeout(() => { - piiMutation.mutate(value, { - onSuccess: (result) => { - setEntities(result.entities); - setAnonymized(result.anonymized); - }, - onError: () => { - setEntities([]); - setAnonymized(value); - }, - }); - }, DEBOUNCE_MS); - }, - [piiMutation] - ); + const handleTextChange = useCallback((value: string) => { + setText(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!value.trim()) { + setEntities([]); + setAnonymized(""); + setPiiLayer("none"); + return; + } + debounceRef.current = setTimeout(() => { + piiMutationRef.current.mutate(value, { + onSuccess: (result) => { + setEntities(result.entities); + setAnonymized(result.anonymized); + const layer = result.entities.some((e) => e.layer === "regex-local") + ? "regex" + : result.entities.length > 0 + ? "service" + : "none"; + setPiiLayer(layer); + }, + onError: () => { + setEntities([]); + setAnonymized(value); + setPiiLayer("none"); + }, + }); + }, DEBOUNCE_MS); + }, []); // stable — uses ref internally useEffect(() => { return () => { @@ -74,24 +97,20 @@ export function PlaygroundPage() { }; }, []); + // ── LLM send ───────────────────────────────────────────────────────────── const handleSend = async () => { if (!text.trim() || isSending) return; setIsSending(true); setAiResponse(""); - try { const messages: ChatMessage[] = [{ role: "user", content: text }]; const response = await apiFetch<{ choices: { message: { content: string } }[] }>( "/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(content); + setAiResponse(response.choices?.[0]?.message?.content ?? ""); } 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 { setIsSending(false); } @@ -99,9 +118,11 @@ export function PlaygroundPage() { const piiCount = entities.length; const hasText = text.trim().length > 0; + const uniqueTypes = [...new Set(entities.map((e) => e.type))]; return (
+ {/* Header */}

@@ -135,22 +156,29 @@ export function PlaygroundPage() {
{/* Left: Input */}
-
+
- {piiMutation.isPending && ( - Analyse PII… - )} - {piiCount > 0 && ( - - - {piiCount} donnée{piiCount > 1 ? "s" : ""} sensible{piiCount > 1 ? "s" : ""} - - )} +
+ {piiMutation.isPending && ( + + Analyse PII… + + )} + {piiCount > 0 && ( + + + {piiCount} donnée{piiCount > 1 ? "s" : ""} sensible + {piiCount > 1 ? "s" : ""} + + )} +