diff options
| author | Chia <Chia@93.nz> | 2026-08-05 09:26:05 +1200 |
|---|---|---|
| committer | Chia <Chia@93.nz> | 2026-08-05 09:26:05 +1200 |
| commit | 86b1f42e3c5601ff10621a9779cf0076590797a1 (patch) | |
| tree | ccf584f0404dece2e4dae2eee847b665f57c0737 | |
| parent | 1a3d7f9a8a181df48f0e911cbe17a3fad3ab9ac9 (diff) | |
add sth.
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | cmd/aigw/main.go | 3 | ||||
| -rw-r--r-- | config.control.example.json | 4 | ||||
| -rw-r--r-- | docs/architecture.md | 4 | ||||
| -rw-r--r-- | internal/adminapi/api.go | 269 | ||||
| -rw-r--r-- | internal/adminui/assets/app.js | 44 | ||||
| -rw-r--r-- | internal/adminui/assets/index.html | 48 | ||||
| -rw-r--r-- | internal/adminui/assets/style.css | 5 | ||||
| -rw-r--r-- | internal/config/config.go | 16 | ||||
| -rw-r--r-- | internal/controlplane/access.go | 249 | ||||
| -rw-r--r-- | internal/controlplane/access_test.go | 2 | ||||
| -rw-r--r-- | internal/controlplane/audit.go | 6 | ||||
| -rw-r--r-- | internal/controlplane/manager.go | 4 | ||||
| -rw-r--r-- | internal/controlplane/manager_test.go | 37 | ||||
| -rw-r--r-- | internal/controlplane/schema.sql | 44 | ||||
| -rw-r--r-- | internal/controlplane/types.go | 30 | ||||
| -rw-r--r-- | internal/security/password.go | 60 | ||||
| -rw-r--r-- | internal/security/password_test.go | 24 |
18 files changed, 771 insertions, 84 deletions
@@ -18,7 +18,7 @@ AIGW 是一个轻量、无状态的 AI API 中转后端。当前阶段聚焦上æ - PostgreSQL 预付余额、请求额度冻结、实际 token 结算和不可变账本 - Stripe 托管 Checkout 充值、签名 Webhook 与事件/订单双重幂等 - PostgreSQL Usage Ledger、月度项目汇总和单请求成本追溯 -- 平台/租户控制台令牌、六种 RBAC 角色和管理 API 审计日志 +- 注册/登录账号、数据库会话、CSRF 防护、六种 RBAC 角色和管理 API 审计日志 - 项目级 RPM、估算 TPM、并发限制和月度消费配额 ## 快速运行 @@ -119,7 +119,7 @@ curl http://127.0.0.1:8080/anthropic/v1/messages \ 源码未变化时可跳过镜像构建以快速重启:`AIGW_DEBUG_SKIP_BUILD=1 ./scripts/start-debug.sh`。默认构建使用 Docker host network;特殊环境可以通过 `AIGW_DOCKER_BUILD_NETWORK=default` 覆盖。 -然后打开 `http://127.0.0.1:8080/admin/`,输入脚本打印的 `AIGW_ADMIN_TOKEN`。这个 token 是平台管理员的 bootstrap/break-glass 凭证;日常操作应在 Team 页面签发数据库控制台令牌。第一套资源的创建顺序是:Tenant → Project → API key → Provider → Model route。客户 API Key 和控制台令牌的明文都只在创建成功时返回一次。 +然后打开 `http://127.0.0.1:8080/admin/`。启用注册时,首次用户可直接创建组织和租户管理员账号;平台管理员也可以在 Operator 页面使用脚本打印的 `AIGW_ADMIN_TOKEN` 作为 bootstrap/break-glass 凭证。日常操作使用邮箱/密码登录,服务端创建可撤销的数据库会话,所有写请求需要 CSRF token。第一套资源的创建顺序是:Tenant → Project → API key → Provider → Model route。客户 API Key 明文只在创建成功时返回一次;团队成员使用自己的账号,不共享管理员令牌。 控制台角色分为:`platform_admin`、`platform_viewer`、`tenant_admin`、`tenant_billing`、`tenant_developer`、`tenant_viewer`。租户角色的查询条件在服务端下推到 PostgreSQL,不能读取其他租户的项目、密钥、余额、Usage 或审计事件;供应商凭证和路由管理只对平台角色开放。 @@ -167,7 +167,7 @@ Webhook 至少订阅: AIGW_DATABASE_URL="postgres://..." go run ./cmd/migrate ``` -管理 API 支持 bootstrap token 和数据库控制台令牌。即使已经启用 RBAC,管理监听端口仍应放在内网、VPN 或身份感知反向代理后;bootstrap token 应只用于首次建号和故障恢复。 +管理 API 支持账号密码会话和仅用于初始化/故障恢复的 bootstrap token。即使已经启用 RBAC,管理监听端口仍应放在内网、VPN 或身份感知反向代理后;生产环境应关闭公开注册、设置 HTTPS、配置 MFA/OIDC,并把 bootstrap token 存入密钥管理服务。 完整的扩展边界见 [架构说明](docs/architecture.md)。 diff --git a/cmd/aigw/main.go b/cmd/aigw/main.go index de929de..44a22d0 100644 --- a/cmd/aigw/main.go +++ b/cmd/aigw/main.go @@ -149,7 +149,8 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error { if cfg.Admin.Enabled { adminHandler := adminapi.New(adminapi.Options{ Store: store, Manager: manager, Billing: billingService, Token: cfg.Admin.Token, - Logger: logger, Prefix: cfg.Admin.BasePath, + Logger: logger, Prefix: cfg.Admin.BasePath, RegistrationEnabled: cfg.Admin.RegistrationEnabled, + SessionTTL: time.Duration(cfg.Admin.SessionTTLHours) * time.Hour, Currency: cfg.Billing.Currency, }).Handler() root.Handle(cfg.Admin.BasePath, adminHandler) root.Handle(cfg.Admin.BasePath+"/", adminHandler) diff --git a/config.control.example.json b/config.control.example.json index d601b43..2cad589 100644 --- a/config.control.example.json +++ b/config.control.example.json @@ -22,7 +22,9 @@ "admin": { "enabled": true, "token_env": "AIGW_ADMIN_TOKEN", - "base_path": "/admin" + "base_path": "/admin", + "registration_enabled": true, + "session_ttl_hours": 12 }, "billing": { "enabled": true, diff --git a/docs/architecture.md b/docs/architecture.md index e522dd5..1193151 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ flowchart LR | 边界 | 当前实现 | 下一阶段替换 | | --- | --- | --- | | 客户身份 | PostgreSQL 快照、内存 SHA-256 索引 | SSO/OIDC、SCIM、模型 allowlist | -| 控制台权限 | 平台/租户令牌、六角色 RBAC、租户 SQL scope、审计日志 | SSO、细粒度自定义角色、审批流 | +| 控制台权限 | 注册/密码登录、数据库会话、CSRF、六角色 RBAC、租户 SQL scope、审计日志 | SSO/OIDC、MFA、细粒度自定义角色、审批流 | | 权限 | `Principal.Scopes` 中的 `inference` + 控制台 RBAC | ABAC、IP 与模型策略 | | 模型目录 | PostgreSQL 快照 + 可选 Redis generation 广播 + PG 轮询兜底 | 版本化控制面、热更新、灰度发布 | | 路由 | priority + weighted selection + failover | 健康评分、延迟 EWMA、成本/质量策略、熔断 | @@ -69,7 +69,7 @@ Stripe 充值使用 Checkout Session:本地先创建 top-up order,Stripe 请 ## 管理面安全 -bootstrap token 只映射为 `platform_admin`,用于首次签发控制台令牌和故障恢复。控制台令牌使用高熵随机值,数据库仅保存 SHA-256 摘要;平台角色没有 `tenant_id`,租户角色必须绑定一个 tenant。所有管理 API 在 handler 执行前校验 permission,租户过滤在 SQL 查询或资源所有权检查中完成,前端隐藏菜单不承担安全职责。 +bootstrap token 只映射为 `platform_admin`,用于首次建号和故障恢复,不是日常用户凭证。租户注册在同一 PostgreSQL 事务内创建租户、默认项目、钱包和 `tenant_admin` 账号;密码使用 PBKDF2-HMAC-SHA-256 哈希,服务端只保存盐和摘要。登录创建 HttpOnly、SameSite 会话 Cookie,并为所有写请求校验独立 CSRF Cookie/header;改密和撤销成员会话会立即失效旧会话。平台角色没有 `tenant_id`,租户角色必须绑定一个 tenant。所有管理 API 在 handler 执行前校验 permission,租户过滤在 SQL 查询或资源所有权检查中完成,前端隐藏菜单不承担安全职责。 每个通过认证的管理请求都写入 `audit_logs`,包含 actor、角色、tenant、action、状态码、请求 ID、IP 和 User-Agent。审计写入失败不会回滚已成功的资源事务,但会输出结构化告警。 diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go index 7f5f8bd..adbaea8 100644 --- a/internal/adminapi/api.go +++ b/internal/adminapi/api.go @@ -3,6 +3,7 @@ package adminapi import ( "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" @@ -22,12 +23,15 @@ import ( ) type API struct { - store *controlplane.Store - manager *controlplane.Manager - billing *billing.Service - token []byte - logger *slog.Logger - prefix string + store *controlplane.Store + manager *controlplane.Manager + billing *billing.Service + token []byte + logger *slog.Logger + prefix string + registrationEnabled bool + sessionTTL time.Duration + currency string } type actorKey struct{} @@ -48,12 +52,15 @@ func (w *auditWriter) Write(body []byte) (int, error) { } type Options struct { - Store *controlplane.Store - Manager *controlplane.Manager - Billing *billing.Service - Token string - Logger *slog.Logger - Prefix string + Store *controlplane.Store + Manager *controlplane.Manager + Billing *billing.Service + Token string + Logger *slog.Logger + Prefix string + RegistrationEnabled bool + SessionTTL time.Duration + Currency string } func New(options Options) *API { @@ -61,7 +68,18 @@ func New(options Options) *API { if prefix == "" { prefix = "/admin" } - return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), logger: options.Logger, prefix: prefix} + if options.Logger == nil { + options.Logger = slog.Default() + } + if options.SessionTTL <= 0 { + options.SessionTTL = 12 * time.Hour + } + if options.Currency == "" { + options.Currency = "usd" + } + return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), + logger: options.Logger, prefix: prefix, registrationEnabled: options.RegistrationEnabled, + sessionTTL: options.SessionTTL, currency: options.Currency} } func (a *API) Handler() http.Handler { @@ -71,6 +89,12 @@ func (a *API) Handler() http.Handler { http.Redirect(w, r, a.prefix+"/", http.StatusTemporaryRedirect) }) mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler())) + mux.HandleFunc("GET "+apiPrefix+"/auth/config", a.public(a.authConfig)) + mux.HandleFunc("GET "+apiPrefix+"/auth/session", a.public(a.authSession)) + mux.HandleFunc("POST "+apiPrefix+"/auth/register", a.public(a.register)) + mux.HandleFunc("POST "+apiPrefix+"/auth/login", a.public(a.login)) + mux.HandleFunc("POST "+apiPrefix+"/auth/logout", a.withAuth("overview.read", a.logout)) + mux.HandleFunc("POST "+apiPrefix+"/auth/password", a.withAuth("overview.read", a.changePassword)) mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview)) mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants)) @@ -102,34 +126,67 @@ func (a *API) Handler() http.Handler { mux.HandleFunc("POST "+apiPrefix+"/users/{id}/revoke", a.withAuth("users.write", a.revokeUser)) mux.HandleFunc("GET "+apiPrefix+"/audit", a.withAuth("audit.read", a.listAudit)) mux.HandleFunc("GET "+apiPrefix+"/me", a.withAuth("overview.read", a.me)) - return mux + return a.securityHeaders(mux) } -func (a *API) withAuth(permission string, next http.HandlerFunc) http.HandlerFunc { +func (a *API) public(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-AIGW-Request-ID") == "" { - r.Header.Set("X-AIGW-Request-ID", adminRequestID(r)) + a.prepareRequest(w, r) + next(w, r) + } +} + +func (a *API) prepareRequest(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-AIGW-Request-ID") == "" { + r.Header.Set("X-AIGW-Request-ID", adminRequestID(r)) + } + w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID")) +} + +func (a *API) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + if strings.Contains(r.URL.Path, "/api/") { + w.Header().Set("Cache-Control", "no-store") } - w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID")) + next.ServeHTTP(w, r) + }) +} + +func (a *API) withAuth(permission string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a.prepareRequest(w, r) provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) if provided == "" { provided = bearerToken(r.Header.Get("Authorization")) } actor := controlplane.ConsoleActor{} + var authErr error if len(provided) > 0 && len(a.token) > 0 && subtle.ConstantTimeCompare([]byte(provided), a.token) == 1 { actor = controlplane.ConsoleActor{Role: controlplane.RolePlatformAdmin, DisplayName: "Bootstrap administrator", Bootstrap: true} } else if provided != "" && a.store != nil { - var err error - actor, err = a.store.AuthenticateConsoleToken(r.Context(), provided) - if err != nil && !errors.Is(err, controlplane.ErrConsoleUnauthorized) { - a.logger.Error("console_authentication_failed", "error", err) - apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_unavailable", Message: "Control plane authentication is temporarily unavailable"}, requestID(r)) - return - } - if err != nil { - actor = controlplane.ConsoleActor{} + actor, authErr = a.store.AuthenticateConsoleToken(r.Context(), provided) + } else if cookie, err := r.Cookie("aigw_session"); err == nil && a.store != nil { + var csrfHash []byte + actor, csrfHash, authErr = a.store.AuthenticateConsoleSession(r.Context(), cookie.Value) + if authErr == nil && isUnsafeMethod(r.Method) { + providedCSRF := strings.TrimSpace(r.Header.Get("X-CSRF-Token")) + actualCSRF := sha256.Sum256([]byte(providedCSRF)) + if providedCSRF == "" || subtle.ConstantTimeCompare(actualCSRF[:], csrfHash) != 1 { + apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "csrf_failed", Message: "Request verification failed; reload and try again"}, requestID(r)) + return + } } } + if authErr != nil && !errors.Is(authErr, controlplane.ErrConsoleUnauthorized) { + a.logger.Error("console_authentication_failed", "error", authErr) + apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_unavailable", Message: "Control plane authentication is temporarily unavailable"}, requestID(r)) + return + } if actor.Role == "" { apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "admin_unauthorized", Message: "Administrator authentication required"}, requestID(r)) return @@ -181,6 +238,164 @@ func adminRequestID(r *http.Request) string { return "adm_unknown" } +func (a *API) authConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled}) +} + +func (a *API) authSession(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("aigw_session") + if err != nil { + writeJSON(w, map[string]any{"authenticated": false}) + return + } + actor, _, err := a.store.AuthenticateConsoleSession(r.Context(), cookie.Value) + if errors.Is(err, controlplane.ErrConsoleUnauthorized) { + a.clearSessionCookie(w, r) + writeJSON(w, map[string]any{"authenticated": false}) + return + } + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, map[string]any{"authenticated": true, "actor": actor, "permissions": actor.Permissions()}) +} + +func (a *API) register(w http.ResponseWriter, r *http.Request) { + if !a.registrationEnabled { + apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "registration_disabled", Message: "New account registration is disabled"}, requestID(r)) + return + } + var input controlplane.RegisterInput + if !decodeBody(w, r, &input) { + return + } + actor, generation, err := a.store.RegisterTenant(r.Context(), input, a.currency) + if err != nil { + a.writeAudit(r, controlplane.ConsoleActor{}, "auth.register", http.StatusBadRequest) + a.mutationError(w, r, err) + return + } + if a.manager != nil { + if err := a.manager.AfterMutation(r.Context(), generation, "tenant", actor.TenantID); err != nil { + a.logger.Warn("registration_snapshot_reload_failed", "tenant_id", actor.TenantID, "error", err) + } + } + session, err := a.store.CreateConsoleSession(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent()) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.register", http.StatusCreated) + writeStatusJSON(w, http.StatusCreated, sessionPayload(session)) +} + +func (a *API) login(w http.ResponseWriter, r *http.Request) { + var input controlplane.PasswordLoginInput + if !decodeBody(w, r, &input) { + return + } + actor, err := a.store.AuthenticateConsolePassword(r.Context(), input, remoteIP(r)) + if err != nil { + status := http.StatusUnauthorized + typeName := "invalid_credentials" + message := "Email or password is incorrect" + if errors.Is(err, controlplane.ErrConsoleRateLimited) { + status = http.StatusTooManyRequests + typeName = "login_rate_limited" + message = "Too many login attempts; try again in 15 minutes" + w.Header().Set("Retry-After", "900") + } else if !errors.Is(err, controlplane.ErrConsoleUnauthorized) { + status = http.StatusServiceUnavailable + typeName = "control_plane_unavailable" + message = "Login is temporarily unavailable" + a.logger.Error("console_password_login_failed", "error", err) + } + a.writeAudit(r, controlplane.ConsoleActor{}, "auth.login", status) + apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) + return + } + session, err := a.store.CreateConsoleSession(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent()) + if err != nil { + a.databaseError(w, r, err) + return + } + a.setSessionCookie(w, r, session) + a.writeAudit(r, actor, "auth.login", http.StatusOK) + writeJSON(w, sessionPayload(session)) +} + +func (a *API) logout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("aigw_session"); err == nil { + if err := a.store.RevokeConsoleSession(r.Context(), cookie.Value); err != nil { + a.databaseError(w, r, err) + return + } + } + a.clearSessionCookie(w, r) + writeJSON(w, map[string]any{"status": "signed_out"}) +} + +func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { + actor := a.actor(r) + if actor.ID == "" { + apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "bootstrap_account", Message: "Bootstrap access does not have a password"}, requestID(r)) + return + } + var input controlplane.PasswordChangeInput + if !decodeBody(w, r, &input) { + return + } + if err := a.store.ChangeConsolePassword(r.Context(), actor.ID, input); err != nil { + if errors.Is(err, controlplane.ErrConsoleUnauthorized) { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r)) + return + } + a.mutationError(w, r, err) + return + } + a.clearSessionCookie(w, r) + writeJSON(w, map[string]any{"status": "password_changed", "reauthentication_required": true}) +} + +func sessionPayload(session controlplane.ConsoleSession) map[string]any { + return map[string]any{ + "actor": session.Actor, "permissions": session.Actor.Permissions(), + "csrf_token": session.CSRFToken, "expires_at": session.ExpiresAt, + } +} + +func (a *API) setSessionCookie(w http.ResponseWriter, r *http.Request, session controlplane.ConsoleSession) { + maxAge := int(time.Until(session.ExpiresAt).Seconds()) + http.SetCookie(w, &http.Cookie{Name: "aigw_session", Value: session.Token, Path: a.prefix + "/", MaxAge: maxAge, + Expires: session.ExpiresAt, HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) + http.SetCookie(w, &http.Cookie{Name: "aigw_csrf", Value: session.CSRFToken, Path: a.prefix + "/", MaxAge: maxAge, + Expires: session.ExpiresAt, HttpOnly: false, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) +} + +func (a *API) clearSessionCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "aigw_session", Value: "", Path: a.prefix + "/", MaxAge: -1, + Expires: time.Unix(1, 0), HttpOnly: true, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) + http.SetCookie(w, &http.Cookie{Name: "aigw_csrf", Value: "", Path: a.prefix + "/", MaxAge: -1, + Expires: time.Unix(1, 0), HttpOnly: false, Secure: requestIsHTTPS(r), SameSite: http.SameSiteStrictMode}) +} + +func requestIsHTTPS(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} + +func isUnsafeMethod(method string) bool { + return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions +} + +func remoteIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + func (a *API) overview(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) tenantID := actor.TenantID diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js index 6b8bf87..1a7469b 100644 --- a/internal/adminui/assets/app.js +++ b/internal/adminui/assets/app.js @@ -1,5 +1,5 @@ const state = { - token: sessionStorage.getItem('aigw_admin_token') || '', actor: {}, permissions: new Set(), overview: {}, + token: '', csrf: '', actor: {}, permissions: new Set(), overview: {}, tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [], usage: [], usageSummary: [], limits: [], users: [], audit: [] }; @@ -13,15 +13,21 @@ function shortID(value) { const text = String(value || ''); return text ? `${tex function percent(part, total) { return total ? `${Math.round((part / total) * 100)}%` : '—'; } function toast(message, error = false) { const node = $('#toast'); node.textContent = message; node.className = `toast visible ${error ? 'error' : ''}`; setTimeout(() => { node.className = 'toast'; }, 3200); } async function api(path, options = {}) { - const response = await fetch(`./api${path}`, { ...options, headers: { 'Content-Type':'application/json', 'Authorization':`Bearer ${state.token}`, ...(options.headers || {}) } }); + const method = (options.method || 'GET').toUpperCase(); + const headers = { ...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {}) }; + if (state.token) headers.Authorization = `Bearer ${state.token}`; + if (!['GET','HEAD','OPTIONS'].includes(method) && state.csrf && !path.startsWith('/auth/login') && !path.startsWith('/auth/register')) headers['X-CSRF-Token'] = state.csrf; + const response = await fetch(`./api${path}`, { ...options, method, credentials:'same-origin', headers }); const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload?.error?.message || `Request failed (${response.status})`); + if (!response.ok) { const error = new Error(payload?.error?.message || `Request failed (${response.status})`); error.status=response.status; error.type=payload?.error?.type; throw error; } return payload; } function setConnected(connected) { - $('#connection-state').textContent = connected ? state.actor.role?.replaceAll('_', ' ') || 'Connected' : 'Offline'; - $('#connection-state').className = `state ${connected ? 'online' : ''}`; - $('#actor-label').textContent = connected ? state.actor.display_name || state.actor.email || '' : ''; + $('#auth-screen').classList.toggle('hidden', connected); + $('#console-app').classList.toggle('hidden', !connected); + if (!connected) return; + $('#connection-state').textContent = state.actor.role?.replaceAll('_', ' ') || 'connected'; + $('#actor-label').textContent = state.actor.display_name || state.actor.email || 'Operator'; } function formJSON(form) { return Object.fromEntries(new FormData(form).entries()); } function selectOptions(items, valueKey, labelKey, empty = 'Select…') { return `<option value="">${empty}</option>${items.map(item => `<option value="${esc(item[valueKey])}">${esc(item[labelKey])}</option>`).join('')}`; } @@ -37,12 +43,13 @@ function money(micros, currency = state.overview.billing_currency || 'usd') { re function integer(value) { return new Intl.NumberFormat().format(Number(value || 0)); } function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; } function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); } +function cookie(name) { const prefix=`${encodeURIComponent(name)}=`; const value=document.cookie.split('; ').find(item=>item.startsWith(prefix)); return value ? decodeURIComponent(value.slice(prefix.length)) : ''; } +function authError(message='') { $('#auth-error').textContent=message; } async function permitted(permission, path) { if (!can(permission)) return []; return api(path); } -async function loadAll() { - if (!state.token) { setConnected(false); return; } +async function loadAll(knownSession = null) { try { - const session = await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []); + const session = knownSession || await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []); state.overview = await api('/overview'); const results = await Promise.all([ permitted('tenants.read','/tenants'), permitted('projects.read','/projects'), permitted('keys.read','/keys'), @@ -52,14 +59,15 @@ async function loadAll() { state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [] ]); [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger] = results; - renderAll(); setConnected(true); - } catch (error) { setConnected(false); toast(error.message, true); } + renderAll(); setConnected(true); return true; + } catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; } } function applyPermissions() { $$('[data-permission]').forEach(node => node.classList.toggle('hidden', !can(node.dataset.permission))); $('#billing-tab').classList.toggle('hidden', !state.overview.billing_enabled || !can('billing.read')); $('#topup-form').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup')); + $('#account-tab').classList.toggle('hidden', !state.actor.id); const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="overview"]').click(); } function renderAll() { @@ -100,13 +108,14 @@ function renderLimits() { function renderUsers() { const roles = state.actor.tenant_id ? [['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']] : [['platform_admin','Platform admin'],['platform_viewer','Platform viewer'],['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']]; $('#user-role').innerHTML=roles.map(([value,label])=>`<option value="${value}">${label}</option>`).join(''); - $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><code>${esc(item.token_prefix)}</code></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7); + $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="badge ${item.has_password?'active':'suspended'}">${item.has_password?'password':'legacy token'}</span></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7); } function renderAudit() { $('#audit-body').innerHTML=state.audit.map(item=>`<tr><td>${date(item.created_at)}</td><td><span class="tag">${esc(item.actor_role.replaceAll('_',' '))}</span></td><td>${esc(item.action)}</td><td><code>${esc(item.method)}</code></td><td><span class="badge ${item.status_code<400?'active':'suspended'}">${item.status_code}</span></td><td><code>${shortID(item.request_id)}</code></td><td><code>${esc(item.remote_ip||'—')}</code></td></tr>`).join('')||emptyRow(7); } function renderRouteEditor() { const current=$('#route-editor');if(!current.children.length&&can('platform.write'))addRoute();$$('.route-provider').forEach(select=>{const selected=select.value;select.innerHTML=selectOptions(state.providers.filter(item=>item.enabled),'id','name','Provider…');select.value=selected;}); } function addRoute() { const wrapper=document.createElement('div');wrapper.className='route-row';wrapper.innerHTML='<select class="route-provider" required></select><input class="route-upstream" required placeholder="Upstream model"><input class="route-priority" type="number" min="0" value="0" title="Priority"><input class="route-weight" type="number" min="1" max="100" value="100" title="Weight"><button class="icon-button remove-route" type="button" aria-label="Remove route">×</button>';$('#route-editor').appendChild(wrapper);renderRouteEditor(); } document.addEventListener('click',async(event)=>{ + const authTab=event.target.closest('.auth-tab');if(authTab){$$('.auth-tab').forEach(node=>node.classList.toggle('active',node===authTab));$$('.auth-pane').forEach(node=>node.classList.toggle('active',node.id===authTab.dataset.authPane));authError();return;} const tab=event.target.closest('.tab');if(tab){$$('.tab').forEach(node=>node.classList.toggle('active',node===tab));$$('.section').forEach(node=>node.classList.toggle('active',node.id===tab.dataset.section));return;} if(event.target.id==='reload'){try{await api('/reload',{method:'POST',body:'{}'});await loadAll();toast('Snapshot reloaded');}catch(error){toast(error.message,true);}} if(event.target.id==='add-route')addRoute();if(event.target.closest('.remove-route'))event.target.closest('.route-row').remove(); @@ -118,7 +127,10 @@ document.addEventListener('click',async(event)=>{ }); $('#key-tenant').addEventListener('change',renderKeyProjects); -$('#session-form').addEventListener('submit',async(event)=>{event.preventDefault();state.token=$('#admin-token').value.trim();sessionStorage.setItem('aigw_admin_token',state.token);await loadAll();}); +$('#login-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/login',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}}); +$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}}); +$('#bootstrap-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();state.csrf='';state.token=formJSON(event.target).token.trim();if(!await loadAll()){state.token='';authError('Bootstrap token is invalid');}event.target.reset();}); +$('#sign-out').addEventListener('click',async()=>{try{if(!state.token)await api('/auth/logout',{method:'POST',body:'{}'});}catch(error){if(error.status!==401)toast(error.message,true);}state.token='';state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);}); $('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}}); $('#project-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/projects',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Project created');}catch(error){toast(error.message,true);}}); $('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});event.target.reset();showSecret('API key created',result.key);await loadAll();}catch(error){toast(error.message,true);}}); @@ -126,6 +138,8 @@ $('#provider-form').addEventListener('submit',async(event)=>{event.preventDefaul $('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}}); $('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}}); $('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}}); -$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const result=await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showSecret('Console token issued',result.token);await loadAll();}catch(error){toast(error.message,true);}}); +$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Member created');}catch(error){toast(error.message,true);}}); +$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.');}catch(error){toast(error.message,true);}}); $('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');}); -$('#admin-token').value=state.token;applyPermissions();if(state.token)loadAll(); +async function start(){try{const config=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!config.registration_enabled);if(!config.registration_enabled&&$('#register-tab').classList.contains('active'))$('.auth-tab[data-auth-pane="login-pane"]').click();state.csrf=cookie('aigw_csrf');const session=await api('/auth/session');if(session.authenticated)await loadAll(session);else setConnected(false);}catch(error){setConnected(false);authError(error.message);}} +start(); diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html index c299037..ca9f282 100644 --- a/internal/adminui/assets/index.html +++ b/internal/adminui/assets/index.html @@ -8,9 +8,43 @@ <link rel="stylesheet" href="./style.css"> </head> <body> + <section class="auth-screen" id="auth-screen"> + <div class="auth-brand"><span class="brand-mark">A</span><div><strong>AIGW</strong><small>CONTROL PLANE</small></div></div> + <div class="auth-panel"> + <div class="auth-tabs" role="tablist" aria-label="Account access"> + <button class="auth-tab active" type="button" data-auth-pane="login-pane">Sign in</button> + <button class="auth-tab" type="button" data-auth-pane="register-pane" id="register-tab">Create account</button> + <button class="auth-tab" type="button" data-auth-pane="bootstrap-pane">Operator</button> + </div> + <form class="auth-pane active" id="login-pane"> + <div><span class="eyebrow">ACCOUNT ACCESS</span><h1>Sign in</h1></div> + <label>Email<input name="email" type="email" required autocomplete="username" placeholder="you@company.com"></label> + <label>Password<input name="password" type="password" required autocomplete="current-password"></label> + <button class="button primary" type="submit">Sign in</button> + </form> + <form class="auth-pane" id="register-pane"> + <div><span class="eyebrow">NEW WORKSPACE</span><h1>Create account</h1></div> + <label>Organization<input name="organization" required autocomplete="organization" placeholder="Acme Inc."></label> + <label>Workspace slug<input name="tenant_slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label> + <label>Your name<input name="display_name" required autocomplete="name"></label> + <label>Email<input name="email" type="email" required autocomplete="email"></label> + <label>Password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label> + <button class="button primary" type="submit">Create account</button> + </form> + <form class="auth-pane" id="bootstrap-pane"> + <div><span class="eyebrow">BREAK GLASS</span><h1>Operator access</h1></div> + <input class="visually-hidden" name="username" autocomplete="username" value="aigw-operator" aria-hidden="true" tabindex="-1"> + <label>Bootstrap token<input name="token" type="password" required autocomplete="off"></label> + <button class="button primary" type="submit">Continue</button> + </form> + <p class="auth-error" id="auth-error" role="alert"></p> + </div> + </section> + + <div id="console-app" class="hidden"> <header class="topbar"> <div class="brand"><span class="brand-mark">A</span><div><strong>AIGW</strong><small>CONTROL PLANE</small></div></div> - <form class="session" id="session-form"><span id="actor-label" class="actor-label"></span><input class="visually-hidden" name="username" autocomplete="username" value="aigw-console" aria-hidden="true" tabindex="-1"><input id="admin-token" name="admin-token" type="password" placeholder="Console token" autocomplete="current-password" aria-label="Console token"><button type="submit">Connect</button><span id="connection-state" class="state">Offline</span></form> + <div class="session"><div><strong id="actor-label" class="actor-label"></strong><span id="connection-state" class="state"></span></div><button type="button" id="sign-out">Sign out</button></div> </header> <main class="shell"> <nav class="tabs" aria-label="Admin sections"> @@ -21,6 +55,7 @@ <button class="tab" data-section="keys" data-permission="keys.read">API keys</button> <button class="tab" data-section="limits" data-permission="limits.read">Limits</button> <button class="tab" data-section="team" data-permission="users.read">Team</button> + <button class="tab" data-section="account" id="account-tab">Account</button> <button class="tab" data-section="audit" data-permission="audit.read">Audit</button> <button class="tab" data-section="tenants" data-permission="tenants.read">Tenants</button> <button class="tab" data-section="providers" data-permission="platform.read">Providers</button> @@ -90,9 +125,13 @@ <section id="team" class="section"> <div class="section-heading"><div><span class="eyebrow">RBAC</span><h1>Console access</h1></div></div> - <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><button class="button primary" type="submit">Issue console token</button></form> - <div class="panel warning"><strong>Token visibility</strong><span>The console token is shown only once after creation.</span></div> - <div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Token</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div> + <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><label>Temporary password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Create member</button></form> + <div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Login</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div> + </section> + + <section id="account" class="section"> + <div class="section-heading"><div><span class="eyebrow">SECURITY</span><h1>Account</h1></div></div> + <form class="panel form-grid compact-form" id="password-form"><input class="visually-hidden" name="username" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>New password<input name="new_password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Change password</button></form> </section> <section id="audit" class="section"> @@ -100,6 +139,7 @@ <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Method</th><th>Status</th><th>Request</th><th>IP</th></tr></thead><tbody id="audit-body"></tbody></table></div> </section> </main> + </div> <div id="toast" class="toast" role="status"></div> <dialog id="secret-dialog"><div class="dialog-content"><div class="section-heading"><div><span class="eyebrow">ONE-TIME SECRET</span><h2 id="secret-title">Credential created</h2></div><button class="icon-button" id="close-dialog" aria-label="Close">×</button></div><p>Copy this credential now. It will not be shown again.</p><code id="created-secret"></code><button class="button primary" id="copy-secret">Copy credential</button></div></dialog> <script src="./app.js" defer></script> diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css index b158d5b..745378f 100644 --- a/internal/adminui/assets/style.css +++ b/internal/adminui/assets/style.css @@ -1,6 +1,7 @@ :root { --bg:#f3f5f7; --panel:#fff; --ink:#18212b; --muted:#71808e; --line:#dce3e8; --accent:#146c94; --accent-soft:#e5f2f7; --danger:#b4494d; --shadow:0 8px 24px rgba(29,47,61,.06); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } * { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select { font:inherit; } button { cursor:pointer; } -.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:8px; } .session input { width:220px; border:1px solid #3b5b6c; background:#18384b; color:#fff; padding:9px 11px; outline:none; } .session input::placeholder { color:#91acb9; } .session button { min-height:40px; border:1px solid #8fd0df; background:#b8eef7; color:#102a3a; padding:0 14px; font-weight:750; } .session button:hover { background:#d4f6fb; } .state { color:#9db0bb; font-size:12px; text-transform:capitalize; } .state.online { color:#86d5ad; } .actor-label { max-width:190px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#c6d7df; font-size:12px; } +.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,520px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,6vw,72px); overflow:auto; } .auth-tabs { display:flex; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; } +.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:12px; } .session div { display:flex; flex-direction:column; align-items:flex-end; gap:2px; } .session button { min-height:36px; border:1px solid #8fd0df; background:transparent; color:#b8eef7; padding:0 13px; font-weight:700; } .session button:hover { background:#18384b; } .state { color:#86d5ad; font-size:11px; text-transform:capitalize; } .actor-label { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#fff; font-size:12px; } .shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .section { display:none; } .section.active { display:block; } .section-heading { display:flex; justify-content:space-between; align-items:flex-end; gap:20px; margin-bottom:19px; } .eyebrow { color:var(--accent); font-size:10px; letter-spacing:0; font-weight:800; } h1 { font-size:28px; line-height:1.1; margin:7px 0 0; letter-spacing:0; } h2 { margin:4px 0 0; font-size:20px; } .metric-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:12px; } .metric { background:var(--panel); border:1px solid var(--line); padding:18px; box-shadow:var(--shadow); } .metric span,.metric small { display:block; color:var(--muted); } .metric strong { display:block; font-size:28px; margin:12px 0 3px; font-weight:750; } .metric small { font-size:11px; } @@ -13,4 +14,4 @@ .muted,.error-label { display:block; color:var(--muted); font-size:11px; margin-top:4px; } .error-label { color:var(--danger); } .limits-table input { min-width:118px; padding:8px 9px; } .limits-table .button { min-height:36px; } input:disabled,select:disabled { background:#f5f7f8; color:#697985; cursor:not-allowed; } .toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; } @media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions { grid-template-columns:1fr; } } -@media (max-width:620px) { .topbar { height:auto; padding:16px; align-items:flex-start; flex-direction:column; } .session { width:100%; display:grid; grid-template-columns:minmax(0,1fr) auto; } .session input { width:100%; min-width:0; } .session .actor-label,.session .state { grid-column:1/-1; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } } +@media (max-width:620px) { .auth-screen { grid-template-columns:1fr; background:#fff; } .auth-brand { background:#102a3a; padding:22px; } .auth-panel { padding:28px 22px 50px; } .auth-tabs { overflow:auto; } .topbar { height:auto; padding:16px; align-items:flex-start; } .session { margin-left:auto; } .session div { align-items:flex-end; max-width:150px; } .session .actor-label { max-width:150px; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } } diff --git a/internal/config/config.go b/internal/config/config.go index 8c21c6c..97ecb21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,10 +53,12 @@ type ControlPlaneConfig struct { } type AdminConfig struct { - Enabled bool `json:"enabled"` - TokenEnv string `json:"token_env"` - BasePath string `json:"base_path"` - Token string `json:"-"` + Enabled bool `json:"enabled"` + TokenEnv string `json:"token_env"` + BasePath string `json:"base_path"` + RegistrationEnabled bool `json:"registration_enabled"` + SessionTTLHours int `json:"session_ttl_hours"` + Token string `json:"-"` } type UpstreamHTTPConfig struct { @@ -188,6 +190,9 @@ func applyDefaults(cfg *Config) { if cfg.Admin.BasePath == "" { cfg.Admin.BasePath = "/admin" } + if cfg.Admin.SessionTTLHours == 0 { + cfg.Admin.SessionTTLHours = 12 + } if cfg.UpstreamHTTP.MaxIdleConnections == 0 { cfg.UpstreamHTTP.MaxIdleConnections = 4096 } @@ -285,6 +290,9 @@ func Validate(cfg Config) error { if !strings.HasPrefix(cfg.Admin.BasePath, "/") || cfg.Admin.BasePath == "/" { return errors.New("admin.base_path must start with / and cannot be /") } + if cfg.Admin.SessionTTLHours < 1 || cfg.Admin.SessionTTLHours > 720 { + return errors.New("admin.session_ttl_hours must be between 1 and 720") + } } if cfg.Billing.Enabled { if !cfg.ControlPlane.Enabled { diff --git a/internal/controlplane/access.go b/internal/controlplane/access.go index ec2d177..0792e8b 100644 --- a/internal/controlplane/access.go +++ b/internal/controlplane/access.go @@ -9,11 +9,17 @@ import ( "fmt" "net/mail" "strings" + "time" + + "aigw/internal/security" "github.com/jackc/pgx/v5" ) -var ErrConsoleUnauthorized = errors.New("invalid console token") +var ( + ErrConsoleUnauthorized = errors.New("invalid console credentials") + ErrConsoleRateLimited = errors.New("too many login attempts") +) const ( RolePlatformAdmin = "platform_admin" @@ -37,7 +43,7 @@ func (a ConsoleActor) Can(permission string) bool { case RoleTenantAdmin: switch permission { case "overview.read", "tenants.read", "projects.read", "projects.write", "keys.read", "keys.write", - "billing.read", "billing.topup", "usage.read", "audit.read", "limits.read", "users.read", "users.write": + "billing.read", "billing.topup", "usage.read", "audit.read", "limits.read", "limits.write", "users.read", "users.write": return true } return false @@ -81,7 +87,8 @@ func (s *Store) AuthenticateConsoleToken(ctx context.Context, raw string) (Conso } func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]ConsoleUser, error) { - query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at FROM console_users` + query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role, + COALESCE(token_prefix, ''), password_hash IS NOT NULL, status, last_used_at, created_at FROM console_users` args := []any{} if tenantID != "" { query += ` WHERE tenant_id = $1` @@ -96,7 +103,7 @@ func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]Consol result := make([]ConsoleUser, 0) for rows.Next() { var item ConsoleUser - if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil { + if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.HasPassword, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil { return nil, fmt.Errorf("scan console user: %w", err) } result = append(result, item) @@ -117,40 +124,246 @@ func (s *Store) CreateConsoleUser(ctx context.Context, input CreateConsoleUserIn if (!platform && !tenant) || (platform && input.TenantID != "") || (tenant && input.TenantID == "") { return CreatedConsoleUser{}, errors.New("console user role and tenant_id are inconsistent") } - random := make([]byte, 32) - if _, err := rand.Read(random); err != nil { - return CreatedConsoleUser{}, fmt.Errorf("generate console token: %w", err) + hash, salt, iterations, err := security.HashPassword(input.Password) + if err != nil { + return CreatedConsoleUser{}, err } - raw := "cu-aigw-" + base64.RawURLEncoding.EncodeToString(random) - hash := sha256.Sum256([]byte(raw)) - prefix := raw[:min(18, len(raw))] + "..." var result CreatedConsoleUser - err := s.db.QueryRow(ctx, ` - INSERT INTO console_users (tenant_id, email, display_name, role, token_prefix, token_hash) - VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6) - RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at`, - input.TenantID, input.Email, input.DisplayName, input.Role, prefix, hash[:], - ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix, &result.Status, &result.LastUsedAt, &result.CreatedAt) + err = s.db.QueryRow(ctx, ` + INSERT INTO console_users (tenant_id, email, display_name, role, password_hash, password_salt, password_iterations, password_changed_at) + VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,now()) + RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role, COALESCE(token_prefix,''), + password_hash IS NOT NULL, status, last_used_at, created_at`, + input.TenantID, input.Email, input.DisplayName, input.Role, hash, salt, iterations, + ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix, + &result.HasPassword, &result.Status, &result.LastUsedAt, &result.CreatedAt) if err != nil { return CreatedConsoleUser{}, fmt.Errorf("create console user: %w", err) } - result.Token = raw return result, nil } func (s *Store) RevokeConsoleUser(ctx context.Context, id, tenantID string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) query := `UPDATE console_users SET status='revoked', revoked_at=now() WHERE id=$1 AND status='active'` args := []any{id} if tenantID != "" { query += ` AND tenant_id=$2` args = append(args, tenantID) } - result, err := s.db.Exec(ctx, query, args...) + result, err := tx.Exec(ctx, query, args...) if err != nil { return err } if result.RowsAffected() == 0 { return ErrNotFound } + if _, err := tx.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, id); err != nil { + return fmt.Errorf("revoke console user sessions: %w", err) + } + return tx.Commit(ctx) +} + +func (s *Store) RegisterTenant(ctx context.Context, input RegisterInput, currency string) (ConsoleActor, int64, error) { + input.Organization = strings.TrimSpace(input.Organization) + input.TenantSlug = strings.ToLower(strings.TrimSpace(input.TenantSlug)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + if input.Organization == "" || input.DisplayName == "" || !slugPattern.MatchString(input.TenantSlug) || !validEmail(input.Email) { + return ConsoleActor{}, 0, errors.New("registration requires organization, a valid tenant_slug, display_name, and email") + } + if len(currency) != 3 { + return ConsoleActor{}, 0, errors.New("registration currency is invalid") + } + hash, salt, iterations, err := security.HashPassword(input.Password) + if err != nil { + return ConsoleActor{}, 0, err + } + tx, err := s.db.Begin(ctx) + if err != nil { + return ConsoleActor{}, 0, err + } + defer tx.Rollback(ctx) + var tenantID string + if err := tx.QueryRow(ctx, `INSERT INTO tenants (slug,name) VALUES ($1,$2) RETURNING id::text`, input.TenantSlug, input.Organization).Scan(&tenantID); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create registered tenant: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO projects (tenant_id,slug,name) VALUES ($1,'default','Default project')`, tenantID); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create default project: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO tenant_wallets (tenant_id,currency) VALUES ($1,$2)`, tenantID, strings.ToLower(currency)); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create tenant wallet: %w", err) + } + actor := ConsoleActor{TenantID: tenantID, Email: input.Email, DisplayName: input.DisplayName, Role: RoleTenantAdmin} + if err := tx.QueryRow(ctx, `INSERT INTO console_users + (tenant_id,email,display_name,role,password_hash,password_salt,password_iterations,password_changed_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,now()) RETURNING id::text`, tenantID, input.Email, input.DisplayName, + RoleTenantAdmin, hash, salt, iterations).Scan(&actor.ID); err != nil { + return ConsoleActor{}, 0, fmt.Errorf("create tenant administrator: %w", err) + } + generation, err := bumpGeneration(ctx, tx) + if err != nil { + return ConsoleActor{}, 0, err + } + if err := tx.Commit(ctx); err != nil { + return ConsoleActor{}, 0, err + } + return actor, generation, nil +} + +func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordLoginInput, remoteIP string) (ConsoleActor, error) { + email := strings.ToLower(strings.TrimSpace(input.Email)) + identity := sha256.Sum256([]byte(email + "\x00" + remoteIP)) + var lockedUntil *time.Time + err := s.db.QueryRow(ctx, `SELECT locked_until FROM console_login_throttles WHERE identity_hash=$1`, identity[:]).Scan(&lockedUntil) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, fmt.Errorf("read console login throttle: %w", err) + } + if lockedUntil != nil && lockedUntil.After(time.Now()) { + return ConsoleActor{}, ErrConsoleRateLimited + } + + var actor ConsoleActor + var expectedHash, salt []byte + var iterations int + err = s.db.QueryRow(ctx, `SELECT u.id::text, COALESCE(u.tenant_id::text,''), u.email, u.display_name, u.role, + u.password_hash, u.password_salt, u.password_iterations + FROM console_users u LEFT JOIN tenants t ON t.id=u.tenant_id + WHERE lower(u.email)=$1 AND u.status='active' AND u.password_hash IS NOT NULL + AND (u.tenant_id IS NULL OR t.status='active')`, email, + ).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role, &expectedHash, &salt, &iterations) + if errors.Is(err, pgx.ErrNoRows) { + dummyHash := make([]byte, security.PasswordHashBytes) + dummySalt := make([]byte, security.PasswordSaltBytes) + _ = security.VerifyPassword(input.Password, dummyHash, dummySalt, security.PasswordIterations) + if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil { + return ConsoleActor{}, failureErr + } + return ConsoleActor{}, ErrConsoleUnauthorized + } + if err != nil { + return ConsoleActor{}, fmt.Errorf("query console login: %w", err) + } + if !security.VerifyPassword(input.Password, expectedHash, salt, iterations) { + if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil { + return ConsoleActor{}, failureErr + } + return ConsoleActor{}, ErrConsoleUnauthorized + } + if _, err := s.db.Exec(ctx, `DELETE FROM console_login_throttles WHERE identity_hash=$1`, identity[:]); err != nil { + return ConsoleActor{}, fmt.Errorf("clear console login throttle: %w", err) + } + if _, err := s.db.Exec(ctx, `UPDATE console_users SET last_used_at=now() WHERE id=$1`, actor.ID); err != nil { + return ConsoleActor{}, fmt.Errorf("update console login time: %w", err) + } + return actor, nil +} + +func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte) error { + _, err := s.db.Exec(ctx, `INSERT INTO console_login_throttles (identity_hash,failures) VALUES ($1,1) + ON CONFLICT (identity_hash) DO UPDATE SET + failures=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END, + window_started_at=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN now() ELSE console_login_throttles.window_started_at END, + locked_until=CASE WHEN (CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END) >= 5 THEN now()+interval '15 minutes' ELSE NULL END, + updated_at=now()`, identityHash) + if err != nil { + return fmt.Errorf("record console login failure: %w", err) + } return nil } + +func (s *Store) CreateConsoleSession(ctx context.Context, actor ConsoleActor, ttl time.Duration, remoteIP, userAgent string) (ConsoleSession, error) { + if actor.ID == "" || ttl < time.Minute { + return ConsoleSession{}, errors.New("session user and ttl are required") + } + token, tokenHash, err := randomCredential("sess-aigw-") + if err != nil { + return ConsoleSession{}, err + } + csrf, csrfHash, err := randomCredential("") + if err != nil { + return ConsoleSession{}, err + } + expiresAt := time.Now().UTC().Add(ttl) + _, err = s.db.Exec(ctx, `INSERT INTO console_sessions (user_id,token_hash,csrf_hash,expires_at,remote_ip,user_agent) + VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet,$6)`, actor.ID, tokenHash, csrfHash, expiresAt, remoteIP, userAgent) + if err != nil { + return ConsoleSession{}, fmt.Errorf("create console session: %w", err) + } + return ConsoleSession{Actor: actor, Token: token, CSRFToken: csrf, ExpiresAt: expiresAt}, nil +} + +func (s *Store) AuthenticateConsoleSession(ctx context.Context, rawToken string) (ConsoleActor, []byte, error) { + hash := sha256.Sum256([]byte(rawToken)) + var actor ConsoleActor + var csrfHash []byte + err := s.db.QueryRow(ctx, `UPDATE console_sessions s SET last_seen_at=CASE WHEN s.last_seen_at < now()-interval '5 minutes' THEN now() ELSE s.last_seen_at END + FROM console_users u LEFT JOIN tenants t ON t.id=u.tenant_id + WHERE s.user_id=u.id AND s.token_hash=$1 AND s.revoked_at IS NULL AND s.expires_at>now() + AND u.status='active' AND (u.tenant_id IS NULL OR t.status='active') + RETURNING u.id::text,COALESCE(u.tenant_id::text,''),u.email,u.display_name,u.role,s.csrf_hash`, hash[:], + ).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role, &csrfHash) + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, nil, ErrConsoleUnauthorized + } + if err != nil { + return ConsoleActor{}, nil, fmt.Errorf("authenticate console session: %w", err) + } + return actor, csrfHash, nil +} + +func (s *Store) RevokeConsoleSession(ctx context.Context, rawToken string) error { + if rawToken == "" { + return nil + } + hash := sha256.Sum256([]byte(rawToken)) + _, err := s.db.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE token_hash=$1 AND revoked_at IS NULL`, hash[:]) + return err +} + +func (s *Store) ChangeConsolePassword(ctx context.Context, actorID string, input PasswordChangeInput) error { + var expectedHash, salt []byte + var iterations int + if err := s.db.QueryRow(ctx, `SELECT password_hash,password_salt,password_iterations FROM console_users WHERE id=$1 AND status='active'`, actorID). + Scan(&expectedHash, &salt, &iterations); err != nil { + return ErrConsoleUnauthorized + } + if !security.VerifyPassword(input.CurrentPassword, expectedHash, salt, iterations) { + return ErrConsoleUnauthorized + } + hash, newSalt, newIterations, err := security.HashPassword(input.NewPassword) + if err != nil { + return err + } + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `UPDATE console_users SET password_hash=$2,password_salt=$3,password_iterations=$4,password_changed_at=now() WHERE id=$1`, actorID, hash, newSalt, newIterations); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, actorID); err != nil { + return err + } + return tx.Commit(ctx) +} + +func randomCredential(prefix string) (string, []byte, error) { + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", nil, err + } + raw := prefix + base64.RawURLEncoding.EncodeToString(random) + hash := sha256.Sum256([]byte(raw)) + return raw, hash[:], nil +} + +func validEmail(value string) bool { + address, err := mail.ParseAddress(value) + return err == nil && address.Address == value +} diff --git a/internal/controlplane/access_test.go b/internal/controlplane/access_test.go index 96e3869..7767e0d 100644 --- a/internal/controlplane/access_test.go +++ b/internal/controlplane/access_test.go @@ -11,7 +11,7 @@ func TestConsoleRolePermissions(t *testing.T) { {RolePlatformViewer, "platform.read", true}, {RolePlatformViewer, "platform.write", false}, {RoleTenantAdmin, "keys.write", true}, - {RoleTenantAdmin, "limits.write", false}, + {RoleTenantAdmin, "limits.write", true}, {RoleTenantBilling, "billing.topup", true}, {RoleTenantBilling, "keys.read", false}, {RoleTenantDeveloper, "keys.write", true}, diff --git a/internal/controlplane/audit.go b/internal/controlplane/audit.go index 93a4f58..8cb4ffe 100644 --- a/internal/controlplane/audit.go +++ b/internal/controlplane/audit.go @@ -8,13 +8,17 @@ import ( func (s *Store) WriteAudit(ctx context.Context, input AuditInput) error { actorType := "console_user" + actorRole := input.Actor.Role if input.Actor.Bootstrap { actorType = "bootstrap" + } else if input.Actor.ID == "" { + actorType = "anonymous" + actorRole = "anonymous" } _, err := s.db.Exec(ctx, `INSERT INTO audit_logs (actor_id, actor_type, actor_role, tenant_id, request_id, method, path, action, status_code, remote_ip, user_agent) VALUES (NULLIF($1,'')::uuid,$2,$3,NULLIF($4,'')::uuid,$5,$6,$7,$8,$9,NULLIF($10,'')::inet,$11)`, - input.Actor.ID, actorType, input.Actor.Role, input.Actor.TenantID, input.RequestID, input.Method, + input.Actor.ID, actorType, actorRole, input.Actor.TenantID, input.RequestID, input.Method, input.Path, input.Action, input.StatusCode, input.RemoteIP, input.UserAgent) if err != nil { return fmt.Errorf("write audit log: %w", err) diff --git a/internal/controlplane/manager.go b/internal/controlplane/manager.go index cdafc36..b6be748 100644 --- a/internal/controlplane/manager.go +++ b/internal/controlplane/manager.go @@ -176,6 +176,10 @@ func (m *Manager) runSubscriptions(ctx context.Context) { closed = true continue } + // go-redis transparently reconnects Pub/Sub after a network outage. + // Receiving a message is the strongest signal that this subscription + // is live again, including when a concurrent publish previously failed. + m.redisConnected.Store(true) var event ChangeEvent if json.Unmarshal([]byte(message.Payload), &event) != nil || event.Generation <= m.generation.Load() { continue diff --git a/internal/controlplane/manager_test.go b/internal/controlplane/manager_test.go index ee78a00..b292060 100644 --- a/internal/controlplane/manager_test.go +++ b/internal/controlplane/manager_test.go @@ -192,6 +192,43 @@ func TestSubscriptionReconnectsAfterChannelCloses(t *testing.T) { } } +func TestSubscriptionMessageRestoresConnectedStateAfterPublishFailure(t *testing.T) { + store := newFakeManagerStore(1) + store.redisEnabled = true + store.publishErr = errors.New("redis unavailable") + messages := make(chan ChangeMessage, 1) + store.subscribe = func(_ context.Context, _ int64) (<-chan ChangeMessage, func() error, error) { + return messages, func() error { return nil }, nil + } + manager := newTestManager(store, slog.New(slog.NewTextHandler(&safeLogBuffer{}, nil)), 10*time.Millisecond) + if _, err := manager.Reload(context.Background()); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + manager.Run(ctx) + close(done) + }() + waitUntil(t, time.Second, manager.RedisConnected) + if err := manager.AfterMutation(context.Background(), 1, "model", "model-1"); err != nil { + t.Fatal(err) + } + waitUntil(t, time.Second, func() bool { return !manager.RedisConnected() }) + + store.snapshot.Store(Snapshot{Generation: 2}) + messages <- ChangeMessage{Payload: `{"generation":2,"resource":"model"}`} + waitUntil(t, time.Second, func() bool { return manager.RedisConnected() && manager.Generation() == 2 }) + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("manager did not stop") + } +} + func TestRedisCanBeDisabled(t *testing.T) { store := newFakeManagerStore(3) manager := newTestManager(store, slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), 10*time.Millisecond) diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql index a518b1d..fc7f5e7 100644 --- a/internal/controlplane/schema.sql +++ b/internal/controlplane/schema.sql @@ -195,16 +195,50 @@ CREATE TABLE IF NOT EXISTS console_users ( 'platform_admin', 'platform_viewer', 'tenant_admin', 'tenant_billing', 'tenant_developer', 'tenant_viewer' )), - token_prefix TEXT NOT NULL, - token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + token_prefix TEXT NOT NULL DEFAULT '', + token_hash BYTEA UNIQUE CHECK (token_hash IS NULL OR octet_length(token_hash) = 32), + password_hash BYTEA CHECK (password_hash IS NULL OR octet_length(password_hash) = 32), + password_salt BYTEA CHECK (password_salt IS NULL OR octet_length(password_salt) = 16), + password_iterations INTEGER CHECK (password_iterations IS NULL OR password_iterations >= 100000), + password_changed_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), last_used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, CHECK ((role LIKE 'platform_%' AND tenant_id IS NULL) OR (role LIKE 'tenant_%' AND tenant_id IS NOT NULL)) ); +ALTER TABLE console_users ALTER COLUMN token_prefix SET DEFAULT ''; +ALTER TABLE console_users ALTER COLUMN token_prefix DROP NOT NULL; +ALTER TABLE console_users ALTER COLUMN token_hash DROP NOT NULL; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_hash BYTEA; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_salt BYTEA; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_iterations INTEGER; +ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ; CREATE UNIQUE INDEX IF NOT EXISTS console_users_email_tenant_idx ON console_users (lower(email), COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid)); +CREATE UNIQUE INDEX IF NOT EXISTS console_users_login_email_idx + ON console_users (lower(email)) WHERE password_hash IS NOT NULL; + +CREATE TABLE IF NOT EXISTS console_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + csrf_hash BYTEA NOT NULL CHECK (octet_length(csrf_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + remote_ip INET, + user_agent TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS console_login_throttles ( + identity_hash BYTEA PRIMARY KEY CHECK (octet_length(identity_hash) = 32), + failures INTEGER NOT NULL DEFAULT 0, + window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + locked_until TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); CREATE TABLE IF NOT EXISTS project_limits ( project_id UUID PRIMARY KEY, @@ -237,7 +271,7 @@ CREATE TABLE IF NOT EXISTS usage_monthly_rollups ( CREATE TABLE IF NOT EXISTS audit_logs ( id BIGSERIAL PRIMARY KEY, actor_id UUID REFERENCES console_users(id) ON DELETE SET NULL, - actor_type TEXT NOT NULL CHECK (actor_type IN ('bootstrap', 'console_user')), + actor_type TEXT NOT NULL CHECK (actor_type IN ('bootstrap', 'console_user', 'anonymous')), actor_role TEXT NOT NULL, tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, request_id TEXT NOT NULL, @@ -249,6 +283,8 @@ CREATE TABLE IF NOT EXISTS audit_logs ( user_agent TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS audit_logs_actor_type_check; +ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_actor_type_check CHECK (actor_type IN ('bootstrap', 'console_user', 'anonymous')); CREATE INDEX IF NOT EXISTS billing_ledger_tenant_idx ON billing_ledger (tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS usage_events_tenant_idx ON usage_events (tenant_id, created_at DESC); @@ -257,5 +293,7 @@ CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events (public_model, CREATE INDEX IF NOT EXISTS billing_reservations_pending_idx ON billing_reservations (status, created_at) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS billing_reservations_project_pending_idx ON billing_reservations (project_id, created_at) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS console_users_tenant_idx ON console_users (tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS console_sessions_user_idx ON console_sessions (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS console_sessions_expiry_idx ON console_sessions (expires_at) WHERE revoked_at IS NULL; CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs (created_at DESC); CREATE INDEX IF NOT EXISTS audit_logs_tenant_idx ON audit_logs (tenant_id, created_at DESC); diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go index 7dfb734..959a9ee 100644 --- a/internal/controlplane/types.go +++ b/internal/controlplane/types.go @@ -164,7 +164,8 @@ type ConsoleUser struct { Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` - TokenPrefix string `json:"token_prefix"` + TokenPrefix string `json:"token_prefix,omitempty"` + HasPassword bool `json:"has_password"` Status string `json:"status"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -172,7 +173,6 @@ type ConsoleUser struct { type CreatedConsoleUser struct { ConsoleUser - Token string `json:"token"` } type CreateConsoleUserInput struct { @@ -180,6 +180,32 @@ type CreateConsoleUserInput struct { Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` + Password string `json:"password"` +} + +type RegisterInput struct { + Organization string `json:"organization"` + TenantSlug string `json:"tenant_slug"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Password string `json:"password"` +} + +type PasswordLoginInput struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type PasswordChangeInput struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +type ConsoleSession struct { + Actor ConsoleActor + Token string + CSRFToken string + ExpiresAt time.Time } type ProjectLimit struct { diff --git a/internal/security/password.go b/internal/security/password.go new file mode 100644 index 0000000..5d805ca --- /dev/null +++ b/internal/security/password.go @@ -0,0 +1,60 @@ +package security + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "errors" + "unicode" +) + +const ( + PasswordSaltBytes = 16 + PasswordHashBytes = 32 + PasswordIterations = 600_000 +) + +var ErrWeakPassword = errors.New("password must be 12-128 characters and contain letters and numbers") + +func ValidatePassword(password string) error { + runes := []rune(password) + if len(runes) < 12 || len(runes) > 128 { + return ErrWeakPassword + } + var letter, number bool + for _, value := range runes { + letter = letter || unicode.IsLetter(value) + number = number || unicode.IsNumber(value) + } + if !letter || !number { + return ErrWeakPassword + } + return nil +} + +func HashPassword(password string) (hash, salt []byte, iterations int, err error) { + if err := ValidatePassword(password); err != nil { + return nil, nil, 0, err + } + salt = make([]byte, PasswordSaltBytes) + if _, err := rand.Read(salt); err != nil { + return nil, nil, 0, err + } + hash, err = pbkdf2.Key(sha256.New, password, salt, PasswordIterations, PasswordHashBytes) + if err != nil { + return nil, nil, 0, err + } + return hash, salt, PasswordIterations, nil +} + +func VerifyPassword(password string, expectedHash, salt []byte, iterations int) bool { + if len(expectedHash) != PasswordHashBytes || len(salt) != PasswordSaltBytes || iterations < 100_000 || iterations > 10_000_000 || len([]rune(password)) > 128 { + return false + } + actual, err := pbkdf2.Key(sha256.New, password, salt, iterations, len(expectedHash)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(actual, expectedHash) == 1 +} diff --git a/internal/security/password_test.go b/internal/security/password_test.go new file mode 100644 index 0000000..8c9b774 --- /dev/null +++ b/internal/security/password_test.go @@ -0,0 +1,24 @@ +package security + +import "testing" + +func TestPasswordHashRoundTrip(t *testing.T) { + hash, salt, iterations, err := HashPassword("correct-horse-42") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword("correct-horse-42", hash, salt, iterations) { + t.Fatal("correct password was rejected") + } + if VerifyPassword("wrong-password-42", hash, salt, iterations) { + t.Fatal("wrong password was accepted") + } +} + +func TestPasswordPolicy(t *testing.T) { + for _, password := range []string{"short1", "onlyletterslong", "123456789012345"} { + if err := ValidatePassword(password); err == nil { + t.Fatalf("password %q unexpectedly passed policy", password) + } + } +} |
