summaryrefslogtreecommitdiff
path: root/internal/adminapi/api.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/adminapi/api.go')
-rw-r--r--internal/adminapi/api.go362
1 files changed, 338 insertions, 24 deletions
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)
}