From 1a3d7f9a8a181df48f0e911cbe17a3fad3ab9ac9 Mon Sep 17 00:00:00 2001 From: Chia Date: Wed, 5 Aug 2026 00:26:25 +1200 Subject: add some scripts --- internal/adminapi/api.go | 362 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 338 insertions(+), 24 deletions(-) (limited to 'internal/adminapi/api.go') diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go index 1eb2a5d..7f5f8bd 100644 --- a/internal/adminapi/api.go +++ b/internal/adminapi/api.go @@ -1,15 +1,21 @@ 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" @@ -18,14 +24,33 @@ import ( 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 @@ -36,7 +61,7 @@ func New(options Options) *API { if prefix == "" { prefix = "/admin" } - return &API{store: options.Store, manager: options.Manager, token: []byte(options.Token), logger: options.Logger, prefix: prefix} + 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 { @@ -47,40 +72,119 @@ func (a *API) Handler() http.Handler { }) mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler())) - mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth(a.overview)) - mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth(a.listTenants)) - mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth(a.createTenant)) - mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth(a.listProjects)) - mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth(a.createProject)) - mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth(a.listKeys)) - mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth(a.createKey)) - mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth(a.revokeKey)) - mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth(a.listProviders)) - mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth(a.createProvider)) - mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth(a.toggleProvider)) - mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth(a.listModels)) - mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth(a.createModel)) - mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth(a.toggleModel)) - mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth(a.reload)) + 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(next http.HandlerFunc) http.HandlerFunc { +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")) } - if len(provided) == 0 || subtle.ConstantTimeCompare([]byte(provided), a.token) != 1 { + 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 } - next(w, r) + 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) { - result, err := a.store.Overview(r.Context()) + actor := a.actor(r) + tenantID := actor.TenantID + result, err := a.store.OverviewFor(r.Context(), tenantID) if err != nil { a.databaseError(w, r, err) return @@ -88,11 +192,67 @@ func (a *API) overview(w http.ResponseWriter, r *http.Request) { 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.ListTenants(r.Context()) + result, err := a.store.ListTenantsFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return @@ -117,7 +277,7 @@ func (a *API) createTenant(w http.ResponseWriter, r *http.Request) { } func (a *API) listProjects(w http.ResponseWriter, r *http.Request) { - result, err := a.store.ListProjects(r.Context()) + result, err := a.store.ListProjectsFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return @@ -130,6 +290,9 @@ func (a *API) createProject(w http.ResponseWriter, r *http.Request) { 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) @@ -142,7 +305,7 @@ func (a *API) createProject(w http.ResponseWriter, r *http.Request) { } func (a *API) listKeys(w http.ResponseWriter, r *http.Request) { - result, err := a.store.ListAPIKeys(r.Context()) + result, err := a.store.ListAPIKeysFor(r.Context(), a.actor(r).TenantID) if err != nil { a.databaseError(w, r, err) return @@ -155,6 +318,9 @@ func (a *API) createKey(w http.ResponseWriter, r *http.Request) { 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) @@ -167,6 +333,10 @@ func (a *API) createKey(w http.ResponseWriter, r *http.Request) { } 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) @@ -275,6 +445,127 @@ func (a *API) reload(w http.ResponseWriter, r *http.Request) { 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) @@ -314,6 +605,29 @@ func (a *API) mutationError(w http.ResponseWriter, r *http.Request, err error) { 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() @@ -348,5 +662,5 @@ func requestID(r *http.Request) string { if value := r.Header.Get("X-AIGW-Request-ID"); value != "" { return value } - return "admin" + return adminRequestID(r) } -- cgit v1.2.3