mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
The relay gate and the money bits asked authz's PlatformSudo, which granted platform authority to an admin membership held at any position, so a brand org's member added to admin got X-User-IsAdmin and the Admin bit here while cloud and IAM said no. authz v1.10.47 has no PlatformSudo; both transports now ask Claims.Sudo. cmd/admin-api asked the owner CLAIM, the org of the application that minted the token, behind an IAM_ADMIN_ORG knob. A bearer is now asked Sudo, and a session the server's isSuperAdmin verdict. The knob and the row-level is_global_admin column, a copy of the predicate nothing read, are gone. authz v1.10.47 brings zip v1.37.3, whose untyped routes are Raw, and brand orgs that open only to the people who run them, so the fixtures' plain member now lives in a customer org.
215 lines
6.5 KiB
Go
215 lines
6.5 KiB
Go
// admin-api is the god-mode backend for the Hanzo Operator console
|
|
// (admin.hanzo.ai). It is the ONE analytics layer: an aggregator periodically
|
|
// pulls every product's identity, usage and health into a dedicated `admin`
|
|
// database inside the ZAP-native Hanzo Datastore, and a small read API serves
|
|
// that aggregate to the operator SPA under /v1/admin/*.
|
|
//
|
|
// Two orthogonal concerns, one binary:
|
|
//
|
|
// 1. aggregate — sources (IAM, platform, commerce) → datastore. A background
|
|
// ticker plus an on-demand POST /v1/admin/sync.
|
|
// 2. serve — /v1/admin/* reads over the `admin` db. Every request is gated to
|
|
// a Hanzo SuperAdmin, enforced here: no request reads god-mode data without
|
|
// a SuperAdmin session or JWT. Fail-closed.
|
|
//
|
|
// SuperAdmin is one fact, asked two ways because there are two credentials: a
|
|
// Bearer/Basic JWT validated through the edge is asked authz.Claims.Sudo (a
|
|
// person whose home org, orgs[0], is the reserved admin org), and a first-party
|
|
// session cookie is forwarded to cloud's /v1/ai/account, whose isSuperAdmin is
|
|
// the boundary's verdict on that same rule. The `owner` claim — the org of the
|
|
// application that minted the token — is never asked.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/authz/edge"
|
|
"github.com/hanzoai/gateway/v2/token"
|
|
)
|
|
|
|
type config struct {
|
|
addr string
|
|
|
|
// Datastore (the analytics layer).
|
|
dsAddr string
|
|
dsUser string
|
|
dsPassword string
|
|
dsDatabase string
|
|
|
|
// Source backends for the aggregator.
|
|
iamInternal string
|
|
iamClientID string
|
|
iamSecret string
|
|
cloudURL string // cloud: session check (/v1/ai/account)
|
|
commerceURL string
|
|
commerceTok string
|
|
platformURL string
|
|
platformTok string
|
|
|
|
syncInterval time.Duration
|
|
|
|
validator *edge.Verifier
|
|
}
|
|
|
|
func loadConfig() *config {
|
|
return &config{
|
|
addr: envOr("ADMIN_API_ADDR", ":8080"),
|
|
|
|
dsAddr: envOr("DATASTORE_ADDR", "datastore.hanzo.svc.cluster.local:9000"),
|
|
dsUser: envOr("DATASTORE_USER", "hanzo"),
|
|
dsPassword: os.Getenv("DATASTORE_PASSWORD"),
|
|
dsDatabase: envOr("DATASTORE_DB", "admin"),
|
|
|
|
iamInternal: strings.TrimRight(envOr("IAM_INTERNAL_URL", "http://iam.hanzo.svc.cluster.local"), "/"),
|
|
iamClientID: os.Getenv("IAM_CLIENT_ID"),
|
|
iamSecret: os.Getenv("IAM_CLIENT_SECRET"),
|
|
cloudURL: strings.TrimRight(envOr("CLOUD_API_URL", "http://cloud.hanzo.svc.cluster.local:8000"), "/"),
|
|
commerceURL: strings.TrimRight(envOr("COMMERCE_URL", "http://commerce.hanzo.svc.cluster.local:8001"), "/"),
|
|
commerceTok: os.Getenv("COMMERCE_SERVICE_TOKEN"),
|
|
platformURL: strings.TrimRight(envOr("PLATFORM_URL", "http://platform.hanzo.svc.cluster.local:4000"), "/"),
|
|
platformTok: firstEnv("PLATFORM_SERVICE_TOKEN", "PAAS_SERVICE_TOKEN"),
|
|
|
|
syncInterval: envDuration("ADMIN_SYNC_INTERVAL", 5*time.Minute),
|
|
|
|
validator: token.NewValidator(token.ConfigFromEnv()),
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
cfg := loadConfig()
|
|
|
|
store, err := openStore(cfg)
|
|
if err != nil {
|
|
log.Fatalf("admin-api: datastore connect: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.bootstrap(context.Background()); err != nil {
|
|
log.Fatalf("admin-api: schema bootstrap: %v", err)
|
|
}
|
|
|
|
agg := &aggregator{cfg: cfg, store: store}
|
|
|
|
// Aggregate on boot (best-effort) then on a ticker, so the cockpit is never
|
|
// empty for long and never blocks serving on a slow source.
|
|
go agg.loop(context.Background())
|
|
|
|
srv := &server{cfg: cfg, store: store, agg: agg}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /v1/admin/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
mux.HandleFunc("GET /v1/admin/me", srv.gate(srv.handleMe))
|
|
mux.HandleFunc("GET /v1/admin/overview", srv.gate(srv.handleOverview))
|
|
mux.HandleFunc("GET /v1/admin/orgs", srv.gate(srv.handleOrgs))
|
|
mux.HandleFunc("GET /v1/admin/users", srv.gate(srv.handleUsers))
|
|
mux.HandleFunc("GET /v1/admin/usage", srv.gate(srv.handleUsage))
|
|
mux.HandleFunc("GET /v1/admin/products", srv.gate(srv.handleProducts))
|
|
mux.HandleFunc("GET /v1/admin/audit", srv.gate(srv.handleAudit))
|
|
mux.HandleFunc("GET /v1/admin/applications", srv.gate(srv.handleApplications))
|
|
mux.HandleFunc("GET /v1/admin/roles", srv.gate(srv.handleRoles))
|
|
mux.HandleFunc("POST /v1/admin/sync", srv.gate(srv.handleSync))
|
|
|
|
h := &http.Server{
|
|
Addr: cfg.addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 15 * time.Second,
|
|
}
|
|
log.Printf("admin-api listening on %s (datastore=%s/%s sync=%s)",
|
|
cfg.addr, cfg.dsAddr, cfg.dsDatabase, cfg.syncInterval)
|
|
log.Fatal(h.ListenAndServe())
|
|
}
|
|
|
|
// ---- envelope + identity --------------------------------------------------
|
|
|
|
// identity is the resolved caller, attached to the request context by gate().
|
|
type identity struct {
|
|
Owner string `json:"owner"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
IsSuperAdmin bool `json:"isSuperAdmin"`
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
const identityKey ctxKey = 0
|
|
|
|
func identityOf(r *http.Request) identity {
|
|
if v, ok := r.Context().Value(identityKey).(identity); ok {
|
|
return v
|
|
}
|
|
return identity{}
|
|
}
|
|
|
|
// envelope is the admin-api wire shape the operator SPA speaks:
|
|
// { status, msg, data, total }. total carries the list total for paged
|
|
// reads; absent means absent. (total was casibase's untyped data2 slot;
|
|
// the transports read total first, so this emits only the named field.)
|
|
type envelope struct {
|
|
Status string `json:"status"`
|
|
Msg string `json:"msg"`
|
|
Data any `json:"data"`
|
|
Total any `json:"total,omitempty"`
|
|
}
|
|
|
|
func writeOK(w http.ResponseWriter, data any, total ...any) {
|
|
e := envelope{Status: "ok", Data: data}
|
|
if len(total) > 0 {
|
|
e.Total = total[0]
|
|
}
|
|
writeJSON(w, http.StatusOK, e)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, envelope{Status: "error", Msg: msg})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// ---- small env helpers ----------------------------------------------------
|
|
|
|
func envOr(k, d string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
|
|
func firstEnv(keys ...string) string {
|
|
for _, k := range keys {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func envDuration(k string, d time.Duration) time.Duration {
|
|
if v := os.Getenv(k); v != "" {
|
|
if p, err := time.ParseDuration(v); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
func atoiOr(s string, d int) int {
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
return n
|
|
}
|
|
return d
|
|
}
|