summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--cmd/aigw/main.go3
-rw-r--r--config.control.example.json4
-rw-r--r--docs/architecture.md4
-rw-r--r--internal/adminapi/api.go269
-rw-r--r--internal/adminui/assets/app.js44
-rw-r--r--internal/adminui/assets/index.html48
-rw-r--r--internal/adminui/assets/style.css5
-rw-r--r--internal/config/config.go16
-rw-r--r--internal/controlplane/access.go249
-rw-r--r--internal/controlplane/access_test.go2
-rw-r--r--internal/controlplane/audit.go6
-rw-r--r--internal/controlplane/manager.go4
-rw-r--r--internal/controlplane/manager_test.go37
-rw-r--r--internal/controlplane/schema.sql44
-rw-r--r--internal/controlplane/types.go30
-rw-r--r--internal/security/password.go60
-rw-r--r--internal/security/password_test.go24
18 files changed, 771 insertions, 84 deletions
diff --git a/README.md b/README.md
index 50be52b..a266de9 100644
--- a/README.md
+++ b/README.md
@@ -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)
+ }
+ }
+}