feat: sdk migration in progress

This commit is contained in:
allanice001
2025-11-02 13:19:30 +00:00
commit 0d10d42442
492 changed files with 71067 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
package httpmiddleware
import (
"net/http"
"strings"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthMiddleware authenticates either a user principal (JWT, user API key, app key/secret)
// or an org principal (org key/secret). If requireOrg is true, the request must have
// an organization resolved; otherwise org is optional.
//
// Org resolution order for user principals (when requireOrg == true):
// 1. X-Org-ID header (UUID)
// 2. chi URL param {id} (useful under /orgs/{id}/... routers)
// 3. single-membership fallback (exactly one membership)
//
// If none resolves, respond with org_required.
func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var user *models.User
var org *models.Organization
var roles []string
// --- 1) Authenticate principal ---
// Prefer org principal if explicit machine access is provided.
if orgKey := r.Header.Get("X-ORG-KEY"); orgKey != "" {
secret := r.Header.Get("X-ORG-SECRET")
org = auth.ValidateOrgKeyPair(orgKey, secret, db)
if org == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid org credentials")
return
}
// org principal implies machine role
roles = []string{"org:machine"}
} else {
// User principals
if ah := r.Header.Get("Authorization"); strings.HasPrefix(ah, "Bearer ") {
user = auth.ValidateJWT(ah[7:], db)
} else if apiKey := r.Header.Get("X-API-KEY"); apiKey != "" {
user = auth.ValidateAPIKey(apiKey, db)
} else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
secret := r.Header.Get("X-APP-SECRET")
user = auth.ValidateAppKeyPair(appKey, secret, db)
}
if user == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
return
}
// --- 2) Resolve organization (user principal) ---
// A) Try X-Org-ID if present
if s := r.Header.Get("X-Org-ID"); s != "" {
oid, err := uuid.Parse(s)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_org_id", "X-Org-ID must be a UUID")
return
}
var o models.Organization
if err := db.First(&o, "id = ?", oid).Error; err != nil {
// Header provided but org not found
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "organization not found")
return
}
// Verify membership
if !userIsMember(db, user.ID, o.ID) {
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
return
}
org = &o
}
// B) If still no org and requireOrg==true, try chi URL param {id}
if org == nil && requireOrg {
if sid := chi.URLParam(r, "id"); sid != "" {
if oid, err := uuid.Parse(sid); err == nil {
var o models.Organization
if err := db.First(&o, "id = ?", oid).Error; err == nil && userIsMember(db, user.ID, o.ID) {
org = &o
} else {
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
return
}
}
}
}
// C) Single-membership fallback (only if requireOrg==true and still nil)
if org == nil && requireOrg {
var ms []models.Membership
if err := db.Where("user_id = ?", user.ID).Find(&ms).Error; err == nil && len(ms) == 1 {
var o models.Organization
if err := db.First(&o, "id = ?", ms[0].OrganizationID).Error; err == nil {
org = &o
}
}
}
// D) Final check
if requireOrg && org == nil {
utils.WriteError(w, http.StatusUnauthorized, "org_required", "specify X-Org-ID or use an endpoint that does not require org")
return
}
// Populate roles if an org was resolved (optional for org-optional endpoints)
if org != nil {
roles = userRolesInOrg(db, user.ID, org.ID)
if len(roles) == 0 {
utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in organization")
return
}
}
}
// --- 3) Attach to context and proceed ---
ctx := r.Context()
if user != nil {
ctx = WithUser(ctx, user)
}
if org != nil {
ctx = WithOrg(ctx, org)
}
if roles != nil {
ctx = WithRoles(ctx, roles)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func userIsMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
var count int64
db.Model(&models.Membership{}).
Where("user_id = ? AND organization_id = ?", userID, orgID).
Count(&count)
return count > 0
}
func userRolesInOrg(db *gorm.DB, userID, orgID uuid.UUID) []string {
var m models.Membership
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err == nil {
switch m.Role {
case "owner":
return []string{"role:owner", "role:admin", "role:member"}
case "admin":
return []string{"role:admin", "role:member"}
default:
return []string{"role:member"}
}
}
return nil
}

View File

@@ -0,0 +1,45 @@
package httpmiddleware
import (
"context"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
)
type ctxKey string
const (
ctxUserKey ctxKey = "ctx_user"
ctxOrgKey ctxKey = "ctx_org"
ctxRolesKey ctxKey = "ctx_roles" // []string, user roles in current org
)
func WithUser(ctx context.Context, u *models.User) context.Context {
return context.WithValue(ctx, ctxUserKey, u)
}
func WithOrg(ctx context.Context, o *models.Organization) context.Context {
return context.WithValue(ctx, ctxOrgKey, o)
}
func WithRoles(ctx context.Context, roles []string) context.Context {
return context.WithValue(ctx, ctxRolesKey, roles)
}
func UserFrom(ctx context.Context) (*models.User, bool) {
u, ok := ctx.Value(ctxUserKey).(*models.User)
return u, ok && u != nil
}
func OrgFrom(ctx context.Context) (*models.Organization, bool) {
o, ok := ctx.Value(ctxOrgKey).(*models.Organization)
return o, ok && o != nil
}
func OrgIDFrom(ctx context.Context) (uuid.UUID, bool) {
if o, ok := OrgFrom(ctx); ok {
return o.ID, true
}
return uuid.Nil, false
}
func RolesFrom(ctx context.Context) ([]string, bool) {
r, ok := ctx.Value(ctxRolesKey).([]string)
return r, ok && r != nil
}

View File

@@ -0,0 +1,45 @@
package httpmiddleware
import (
"net/http"
"github.com/glueops/autoglue/internal/utils"
)
func RequireRole(minRole string) func(http.Handler) http.Handler {
// order: owner > admin > member
rank := map[string]int{
"role:member": 1,
"role:admin": 2,
"role:owner": 3,
"org:machine": 2,
"org:machine:ro": 1,
}
need := map[string]bool{
"member": true, "admin": true, "owner": true,
}
if !need[minRole] {
minRole = "member"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roles, ok := RolesFrom(r.Context())
if !ok || len(roles) == 0 {
utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in context")
return
}
max := 0
for _, ro := range roles {
if rank[ro] > max {
max = rank[ro]
}
}
if max < rank["role:"+minRole] {
utils.WriteError(w, http.StatusForbidden, "forbidden", "insufficient role")
return
}
next.ServeHTTP(w, r)
})
}
}

35
internal/api/mw_logger.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
)
func zeroLogMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
dur := time.Since(start)
ev := log.Info()
if ww.Status() >= 500 {
ev = log.Error()
}
ev.
Str("remote_ip", r.RemoteAddr).
Str("request_id", middleware.GetReqID(r.Context())).
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", ww.Status()).
Int("bytes", ww.BytesWritten()).
Dur("duration", dur).
Msg("http_request")
})
}
}

View File

@@ -0,0 +1,63 @@
package api
import (
"net/http"
"strings"
"github.com/glueops/autoglue/internal/config"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HSTS (enable only over TLS/behind HTTPS)
// HSTS only when not in dev and over TLS/behind a proxy that terminates TLS
if !config.IsDev() {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
}
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
if config.IsDev() {
// --- Relaxed CSP for Vite dev server & Google Fonts ---
// Allows inline/eval for React Refresh preamble, HMR websocket, and fonts.
// Tighten these as you move to prod or self-host fonts.
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
// Vite dev & inline preamble/eval:
"script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173",
// allow dev style + Google Fonts
"style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com",
"img-src 'self' data: blob:",
// Google font files
"font-src 'self' data: https://fonts.gstatic.com",
// HMR connections
"connect-src 'self' http://localhost:5173 ws://localhost:5173 ws://localhost:8080",
"frame-ancestors 'none'",
}, "; "))
} else {
// --- Strict CSP for production ---
// If you keep using Google Fonts in prod, add:
// style-src ... https://fonts.googleapis.com
// font-src ... https://fonts.gstatic.com
// Recommended: self-host fonts in prod and keep these tight.
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self'",
"frame-ancestors 'none'",
}, "; "))
}
next.ServeHTTP(w, r)
})
}

195
internal/api/routes.go Normal file
View File

@@ -0,0 +1,195 @@
package api
import (
"fmt"
"net/http"
httpPprof "net/http/pprof"
"os"
"time"
"github.com/glueops/autoglue/docs"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers"
"github.com/glueops/autoglue/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"gorm.io/gorm"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger/v2"
)
func NewRouter(db *gorm.DB) http.Handler {
zerolog.TimeFieldFormat = time.RFC3339
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
log.Logger = l
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(zeroLogMiddleware())
r.Use(middleware.Recoverer)
r.Use(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(100, 1*time.Minute))
allowed := getAllowedOrigins()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowed,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"Authorization",
"X-Org-ID",
"X-API-KEY",
"X-ORG-KEY",
"X-ORG-SECRET",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 600,
}))
r.Use(middleware.AllowContentType("application/json"))
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
r.Route("/api", func(api chi.Router) {
api.Route("/v1", func(v1 chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
authOrg := httpmiddleware.AuthMiddleware(db, true)
// Also serving a versioned JWKS for swagger, which uses BasePath
v1.Get("/.well-known/jwks.json", handlers.JWKSHandler)
v1.Route("/auth", func(a chi.Router) {
a.Post("/{provider}/start", handlers.AuthStart(db))
a.Get("/{provider}/callback", handlers.AuthCallback(db))
a.Post("/refresh", handlers.Refresh(db))
a.Post("/logout", handlers.Logout(db))
})
v1.Route("/me", func(me chi.Router) {
me.Use(authUser)
me.Get("/", handlers.GetMe(db))
me.Patch("/", handlers.UpdateMe(db))
me.Get("/api-keys", handlers.ListUserAPIKeys(db))
me.Post("/api-keys", handlers.CreateUserAPIKey(db))
me.Delete("/api-keys/{id}", handlers.DeleteUserAPIKey(db))
})
v1.Route("/orgs", func(o chi.Router) {
o.Use(authUser)
o.Get("/", handlers.ListMyOrgs(db))
o.Post("/", handlers.CreateOrg(db))
o.Group(func(og chi.Router) {
og.Use(authOrg)
og.Get("/{id}", handlers.GetOrg(db))
og.Patch("/{id}", handlers.UpdateOrg(db))
og.Delete("/{id}", handlers.DeleteOrg(db))
// members
og.Get("/{id}/members", handlers.ListMembers(db))
og.Post("/{id}/members", handlers.AddOrUpdateMember(db))
og.Delete("/{id}/members/{user_id}", handlers.RemoveMember(db))
// org-scoped key/secret pair
og.Get("/{id}/api-keys", handlers.ListOrgKeys(db))
og.Post("/{id}/api-keys", handlers.CreateOrgKey(db))
og.Delete("/{id}/api-keys/{key_id}", handlers.DeleteOrgKey(db))
})
})
v1.Route("/ssh", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListPublicSshKeys(db))
s.Post("/", handlers.CreateSSHKey(db))
s.Get("/{id}", handlers.GetSSHKey(db))
s.Delete("/{id}", handlers.DeleteSSHKey(db))
s.Get("/{id}/download", handlers.DownloadSSHKey(db))
})
v1.Route("/servers", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListServers(db))
s.Post("/", handlers.CreateServer(db))
s.Get("/{id}", handlers.GetServer(db))
s.Patch("/{id}", handlers.UpdateServer(db))
s.Delete("/{id}", handlers.DeleteServer(db))
})
v1.Route("/taints", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListTaints(db))
s.Post("/", handlers.CreateTaint(db))
s.Get("/{id}", handlers.GetTaint(db))
s.Patch("/{id}", handlers.UpdateTaint(db))
s.Delete("/{id}", handlers.DeleteTaint(db))
})
})
})
if config.IsDebug() {
r.Route("/debug/pprof", func(pr chi.Router) {
pr.Get("/", httpPprof.Index)
pr.Get("/cmdline", httpPprof.Cmdline)
pr.Get("/profile", httpPprof.Profile)
pr.Get("/symbol", httpPprof.Symbol)
pr.Get("/trace", httpPprof.Trace)
pr.Handle("/allocs", httpPprof.Handler("allocs"))
pr.Handle("/block", httpPprof.Handler("block"))
pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
pr.Handle("/heap", httpPprof.Handler("heap"))
pr.Handle("/mutex", httpPprof.Handler("mutex"))
pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
})
}
if config.IsSwaggerEnabled() {
r.Get("/swagger/*", httpSwagger.Handler(
httpSwagger.URL("swagger.json"),
))
r.Get("/swagger/swagger.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
r.Get("/swagger/swagger.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
}
if config.IsUIDev() {
fmt.Println("Running in development mode")
// Dev: isolate proxy from chi middlewares so WS upgrade can hijack.
proxy, err := web.DevProxy("http://localhost:5173")
if err != nil {
log.Error().Err(err).Msg("dev proxy init failed")
return r // fallback
}
mux := http.NewServeMux()
// Send API/Swagger/pprof to chi
mux.Handle("/api/", r)
mux.Handle("/api", r)
mux.Handle("/swagger/", r)
mux.Handle("/debug/pprof/", r)
// Everything else (/, /brand-preview, assets) → proxy (no middlewares)
mux.Handle("/", proxy)
return mux
} else {
fmt.Println("Running in production mode")
if h, err := web.SPAHandler(); err == nil {
r.NotFound(h.ServeHTTP)
} else {
log.Error().Err(err).Msg("spa handler init failed")
}
}
return r
}

45
internal/api/utils.go Normal file
View File

@@ -0,0 +1,45 @@
package api
import (
"net/http"
"os"
"strings"
)
func requestBodyLimit(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
func getAllowedOrigins() []string {
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimSpace(p)
if s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
// Defaults (dev)
return []string{
"http://localhost:5173",
"http://localhost:8080",
}
}
func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
}