package adminapi import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "log/slog" "net" "net/http" "strings" "time" "aigw/internal/adminui" "aigw/internal/apierror" "aigw/internal/billing" "aigw/internal/controlplane" "github.com/jackc/pgx/v5/pgconn" ) type API struct { 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{} type auditWriter struct { http.ResponseWriter status int } func (w *auditWriter) WriteHeader(status int) { w.status = status w.ResponseWriter.WriteHeader(status) } func (w *auditWriter) Write(body []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } return w.ResponseWriter.Write(body) } type Options struct { 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 { prefix := strings.TrimRight(options.Prefix, "/") if prefix == "" { prefix = "/admin" } 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 { mux := http.NewServeMux() apiPrefix := a.prefix + "/api" mux.HandleFunc("GET "+a.prefix, func(w http.ResponseWriter, r *http.Request) { 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)) mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth("tenants.write", a.createTenant)) mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth("projects.read", a.listProjects)) mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth("projects.write", a.createProject)) mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth("keys.read", a.listKeys)) mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth("keys.write", a.createKey)) mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth("keys.write", a.revokeKey)) mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth("platform.read", a.listProviders)) mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth("platform.write", a.createProvider)) mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth("platform.write", a.toggleProvider)) mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth("platform.read", a.listModels)) mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth("platform.write", a.createModel)) mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth("platform.write", a.toggleModel)) mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth("platform.write", a.reload)) if a.billing != nil { mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts)) mux.HandleFunc("GET "+apiPrefix+"/billing/ledger", a.withAuth("billing.read", a.listBillingLedger)) mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance)) mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession)) } mux.HandleFunc("GET "+apiPrefix+"/usage", a.withAuth("usage.read", a.listUsage)) mux.HandleFunc("GET "+apiPrefix+"/usage/summary", a.withAuth("usage.read", a.usageSummary)) mux.HandleFunc("GET "+apiPrefix+"/limits", a.withAuth("limits.read", a.listLimits)) mux.HandleFunc("POST "+apiPrefix+"/limits/{project_id}", a.withAuth("limits.write", a.setLimit)) mux.HandleFunc("GET "+apiPrefix+"/users", a.withAuth("users.read", a.listUsers)) mux.HandleFunc("POST "+apiPrefix+"/users", a.withAuth("users.write", a.createUser)) 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 a.securityHeaders(mux) } func (a *API) public(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { 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") } 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 { 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 } if !actor.Can(permission) { aw := &auditWriter{ResponseWriter: w} apierror.Write(aw, apierror.Error{Status: http.StatusForbidden, Type: "admin_forbidden", Message: "You do not have permission for this operation"}, requestID(r)) a.writeAudit(r, actor, permission, aw.status) return } request := r.WithContext(context.WithValue(r.Context(), actorKey{}, actor)) aw := &auditWriter{ResponseWriter: w} next(aw, request) if aw.status == 0 { aw.status = http.StatusOK } a.writeAudit(request, actor, permission, aw.status) } } func (a *API) actor(r *http.Request) controlplane.ConsoleActor { actor, _ := r.Context().Value(actorKey{}).(controlplane.ConsoleActor) return actor } func (a *API) writeAudit(r *http.Request, actor controlplane.ConsoleActor, action string, status int) { if a.store == nil { return } ip := r.RemoteAddr if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { ip = host } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := a.store.WriteAudit(ctx, controlplane.AuditInput{Actor: actor, RequestID: requestID(r), Method: r.Method, Path: r.URL.Path, Action: action, StatusCode: status, RemoteIP: ip, UserAgent: r.UserAgent()}); err != nil && a.logger != nil { a.logger.Warn("admin_audit_write_failed", "error", err) } } func adminRequestID(r *http.Request) string { if id := r.Header.Get("X-AIGW-Request-ID"); id != "" { return id } var b [12]byte if _, err := rand.Read(b[:]); err == nil { return "adm_" + hex.EncodeToString(b[:]) } 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 result, err := a.store.OverviewFor(r.Context(), tenantID) if err != nil { a.databaseError(w, r, err) return } result.RuntimeGeneration = a.manager.Generation() result.RedisConfigured = a.manager.RedisConfigured() result.RedisConnected = a.manager.RedisConnected() result.BillingEnabled = a.billing != nil if a.billing != nil { result.StripeEnabled = a.billing.StripeEnabled() result.BillingCurrency = a.billing.Currency() } writeJSON(w, result) } func (a *API) listBillingAccounts(w http.ResponseWriter, r *http.Request) { result, err := a.billing.ListAccounts(r.Context(), a.actor(r).TenantID) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) { tenantID := a.actor(r).TenantID if tenantID == "" { tenantID = r.URL.Query().Get("tenant_id") } result, err := a.billing.ListLedger(r.Context(), tenantID, 200) if err != nil { a.billingError(w, r, err) return } writeJSON(w, result) } func (a *API) adjustBalance(w http.ResponseWriter, r *http.Request) { var input billing.AdjustmentInput if !decodeBody(w, r, &input) { return } result, err := a.billing.AdjustBalance(r.Context(), input) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) createCheckoutSession(w http.ResponseWriter, r *http.Request) { var input billing.CheckoutInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID } result, err := a.billing.CreateCheckout(r.Context(), input) if err != nil { a.billingError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) listTenants(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListTenantsFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createTenant(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateTenantInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.CreateTenant(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "tenant", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) listProjects(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListProjectsFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createProject(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateProjectInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID } result, generation, err := a.store.CreateProject(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "project", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) listKeys(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListAPIKeysFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createKey(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateAPIKeyInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID } result, generation, err := a.store.CreateAPIKey(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "api_key", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) revokeKey(w http.ResponseWriter, r *http.Request) { if err := a.requireResourceTenant(r, "api_key", r.PathValue("id")); err != nil { a.scopeError(w, r) return } generation, err := a.store.RevokeAPIKey(r.Context(), r.PathValue("id")) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "api_key", r.PathValue("id")) { return } writeJSON(w, map[string]any{"status": "revoked"}) } func (a *API) listProviders(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListProviders(r.Context()) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createProvider(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateProviderInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.CreateProvider(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "provider", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) toggleProvider(w http.ResponseWriter, r *http.Request) { var input struct { Enabled bool `json:"enabled"` } if !decodeBody(w, r, &input) { return } id := r.PathValue("id") generation, err := a.store.SetProviderEnabled(r.Context(), id, input.Enabled) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "provider", id) { return } writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled}) } func (a *API) listModels(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListModels(r.Context()) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createModel(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateModelInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.CreateModel(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "model", result.ID) { return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) toggleModel(w http.ResponseWriter, r *http.Request) { var input struct { Enabled bool `json:"enabled"` } if !decodeBody(w, r, &input) { return } id := r.PathValue("id") generation, err := a.store.SetModelEnabled(r.Context(), id, input.Enabled) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "model", id) { return } writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled}) } func (a *API) reload(w http.ResponseWriter, r *http.Request) { generation, err := a.manager.Reload(r.Context()) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, map[string]any{"generation": generation, "status": "reloaded"}) } func (a *API) listUsage(w http.ResponseWriter, r *http.Request) { query := controlplane.UsageQuery{TenantID: a.actor(r).TenantID, ProjectID: r.URL.Query().Get("project_id"), Model: r.URL.Query().Get("model"), Limit: 200} result, err := a.store.ListUsage(r.Context(), query) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) usageSummary(w http.ResponseWriter, r *http.Request) { result, err := a.store.UsageSummary(r.Context(), a.actor(r).TenantID, r.URL.Query().Get("project_id")) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) listLimits(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListProjectLimits(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) setLimit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("project_id") if err := a.requireResourceTenant(r, "project", id); err != nil { a.scopeError(w, r) return } var input controlplane.SetProjectLimitInput if !decodeBody(w, r, &input) { return } result, generation, err := a.store.SetProjectLimit(r.Context(), id, input) if err != nil { a.mutationError(w, r, err) return } if !a.changed(w, r, generation, "project_limit", id) { return } writeJSON(w, result) } func (a *API) listUsers(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListConsoleUsers(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) createUser(w http.ResponseWriter, r *http.Request) { var input controlplane.CreateConsoleUserInput if !decodeBody(w, r, &input) { return } if tenantID := a.actor(r).TenantID; tenantID != "" { input.TenantID = tenantID if strings.HasPrefix(input.Role, "platform_") { a.scopeError(w, r) return } } result, err := a.store.CreateConsoleUser(r.Context(), input) if err != nil { a.mutationError(w, r, err) return } writeStatusJSON(w, http.StatusCreated, result) } func (a *API) revokeUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := a.requireResourceTenant(r, "console_user", id); err != nil { a.scopeError(w, r) return } if err := a.store.RevokeConsoleUser(r.Context(), id, a.actor(r).TenantID); err != nil { a.mutationError(w, r, err) return } writeJSON(w, map[string]any{"status": "revoked", "id": id}) } func (a *API) listAudit(w http.ResponseWriter, r *http.Request) { result, err := a.store.ListAudit(r.Context(), a.actor(r).TenantID, 200) if err != nil { a.databaseError(w, r, err) return } writeJSON(w, result) } func (a *API) me(w http.ResponseWriter, r *http.Request) { actor := a.actor(r) writeJSON(w, map[string]any{"actor": actor, "permissions": actor.Permissions()}) } func (a *API) requireResourceTenant(r *http.Request, resource, id string) error { actor := a.actor(r) if actor.IsPlatform() { return nil } tenantID, err := a.store.ResourceTenantID(r.Context(), resource, id) if err != nil || tenantID == "" || tenantID != actor.TenantID { return errors.New("resource is outside tenant scope") } return nil } func (a *API) scopeError(w http.ResponseWriter, r *http.Request) { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "tenant_scope", Message: "Resource is outside your tenant"}, requestID(r)) } func (a *API) changed(w http.ResponseWriter, r *http.Request, generation int64, resource, id string) bool { if err := a.manager.AfterMutation(r.Context(), generation, resource, id); err != nil { a.logger.Error("admin_control_plane_sync_failed", "resource", resource, "id", id, "error", err) apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_sync_failed", Message: "Change was persisted but runtime reload failed; retry reload"}, requestID(r)) return false } return true } func (a *API) databaseError(w http.ResponseWriter, r *http.Request, err error) { a.logger.Error("admin_database_error", "error", err) apierror.Write(w, apierror.Error{Status: http.StatusInternalServerError, Type: "control_plane_error", Message: "Control plane unavailable"}, requestID(r)) } func (a *API) mutationError(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusBadRequest typeName := "invalid_params" message := err.Error() if errors.Is(err, controlplane.ErrNotFound) { status = http.StatusNotFound typeName = "not_found" message = "Resource not found" } var pgError *pgconn.PgError if errors.As(err, &pgError) { switch pgError.Code { case "23505": status = http.StatusConflict typeName = "already_exists" message = "Resource already exists" case "23503": status = http.StatusBadRequest typeName = "invalid_reference" message = "Referenced resource does not exist" } } apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) } func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusInternalServerError typeName := "billing_error" message := "Billing operation failed" switch { case errors.Is(err, billing.ErrInvalidAmount): status = http.StatusBadRequest typeName = "invalid_amount" message = "Amount is outside the configured bounds" case errors.Is(err, billing.ErrInsufficientBalance): status = http.StatusPaymentRequired typeName = "insufficient_balance" message = "Available balance is insufficient" case errors.Is(err, billing.ErrStripeDisabled): status = http.StatusServiceUnavailable typeName = "stripe_disabled" message = "Stripe top-ups are disabled" default: a.logger.Error("admin_billing_error", "error", err) } apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) } func decodeBody(w http.ResponseWriter, r *http.Request, destination any) bool { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) defer r.Body.Close() decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_params", Message: "Invalid JSON request body"}, requestID(r)) return false } return true } func writeJSON(w http.ResponseWriter, value any) { writeStatusJSON(w, http.StatusOK, value) } func writeStatusJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func bearerToken(header string) string { parts := strings.Fields(header) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { return parts[1] } return "" } func requestID(r *http.Request) string { if value := r.Header.Get("X-AIGW-Request-ID"); value != "" { return value } return adminRequestID(r) }