package adminapi import ( "context" "crypto/rand" "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 } 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 } func New(options Options) *API { prefix := strings.TrimRight(options.Prefix, "/") if prefix == "" { prefix = "/admin" } return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), logger: options.Logger, prefix: prefix} } 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+"/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 mux } func (a *API) withAuth(permission string, 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)) } w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID")) provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) if provided == "" { provided = bearerToken(r.Header.Get("Authorization")) } actor := controlplane.ConsoleActor{} 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{} } } 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) 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) }