mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
feat: sdk migration in progress
This commit is contained in:
161
internal/api/httpmiddleware/auth.go
Normal file
161
internal/api/httpmiddleware/auth.go
Normal 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
|
||||
}
|
||||
45
internal/api/httpmiddleware/context.go
Normal file
45
internal/api/httpmiddleware/context.go
Normal 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
|
||||
}
|
||||
45
internal/api/httpmiddleware/rbac.go
Normal file
45
internal/api/httpmiddleware/rbac.go
Normal 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
35
internal/api/mw_logger.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
63
internal/api/mw_security.go
Normal file
63
internal/api/mw_security.go
Normal 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
195
internal/api/routes.go
Normal 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
45
internal/api/utils.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
internal/app/runtime.go
Normal file
46
internal/app/runtime.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Runtime struct {
|
||||
Cfg config.Config
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewRuntime() *Runtime {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
d := db.Open(cfg.DbURL)
|
||||
|
||||
err = db.Run(d,
|
||||
&models.MasterKey{},
|
||||
&models.SigningKey{},
|
||||
&models.User{},
|
||||
&models.Organization{},
|
||||
&models.Account{},
|
||||
&models.Membership{},
|
||||
&models.APIKey{},
|
||||
&models.UserEmail{},
|
||||
&models.RefreshToken{},
|
||||
&models.OrganizationKey{},
|
||||
&models.SshKey{},
|
||||
&models.Server{},
|
||||
&models.Taint{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
return &Runtime{
|
||||
Cfg: cfg,
|
||||
DB: d,
|
||||
}
|
||||
}
|
||||
38
internal/auth/hash.go
Normal file
38
internal/auth/hash.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
)
|
||||
|
||||
func SHA256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var argonParams = &argon2id.Params{
|
||||
Memory: 64 * 1024, // 64MB
|
||||
Iterations: 3,
|
||||
Parallelism: 2,
|
||||
SaltLength: 16,
|
||||
KeyLength: 32,
|
||||
}
|
||||
|
||||
func HashSecretArgon2id(plain string) (string, error) {
|
||||
return argon2id.CreateHash(plain, argonParams)
|
||||
}
|
||||
|
||||
func VerifySecretArgon2id(encodedHash, plain string) (bool, error) {
|
||||
if encodedHash == "" {
|
||||
return false, errors.New("empty hash")
|
||||
}
|
||||
return argon2id.ComparePasswordAndHash(plain, encodedHash)
|
||||
}
|
||||
|
||||
func NotExpired(expiresAt *time.Time) bool {
|
||||
return expiresAt == nil || time.Now().Before(*expiresAt)
|
||||
}
|
||||
42
internal/auth/issue.go
Normal file
42
internal/auth/issue.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// URL-safe, no padding
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// IssueUserAPIKey creates a single-token user API key (X-API-KEY)
|
||||
func IssueUserAPIKey(db *gorm.DB, userID uuid.UUID, name string, ttl *time.Duration) (plaintext string, rec models.APIKey, err error) {
|
||||
plaintext, err = randomToken(32)
|
||||
if err != nil {
|
||||
return "", models.APIKey{}, err
|
||||
}
|
||||
rec = models.APIKey{
|
||||
Name: name,
|
||||
Scope: "user",
|
||||
UserID: &userID,
|
||||
KeyHash: SHA256Hex(plaintext), // deterministic lookup
|
||||
}
|
||||
if ttl != nil {
|
||||
ex := time.Now().Add(*ttl)
|
||||
rec.ExpiresAt = &ex
|
||||
}
|
||||
if err = db.Create(&rec).Error; err != nil {
|
||||
return "", models.APIKey{}, err
|
||||
}
|
||||
return plaintext, rec, nil
|
||||
}
|
||||
71
internal/auth/jwks_export.go
Normal file
71
internal/auth/jwks_export.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// base64url (no padding)
|
||||
func b64url(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// convert small int (RSA exponent) to big-endian bytes
|
||||
func fromInt(i int) []byte {
|
||||
var x big.Int
|
||||
x.SetInt64(int64(i))
|
||||
return x.Bytes()
|
||||
}
|
||||
|
||||
// --- public accessors for JWKS ---
|
||||
|
||||
// KeyMeta is a minimal metadata view exposed for JWKS rendering.
|
||||
type KeyMeta struct {
|
||||
Alg string
|
||||
}
|
||||
|
||||
// MetaFor returns minimal metadata (currently the alg) for a given kid.
|
||||
// If not found, returns zero value (Alg == "").
|
||||
func MetaFor(kid string) KeyMeta {
|
||||
kc.mu.RLock()
|
||||
defer kc.mu.RUnlock()
|
||||
if m, ok := kc.meta[kid]; ok {
|
||||
return KeyMeta{Alg: m.Alg}
|
||||
}
|
||||
return KeyMeta{}
|
||||
}
|
||||
|
||||
// KcCopy invokes fn with a shallow copy of the public key map (kid -> public key instance).
|
||||
// Useful to iterate without holding the lock during JSON building.
|
||||
func KcCopy(fn func(map[string]interface{})) {
|
||||
kc.mu.RLock()
|
||||
defer kc.mu.RUnlock()
|
||||
out := make(map[string]interface{}, len(kc.pub))
|
||||
for kid, pk := range kc.pub {
|
||||
out[kid] = pk
|
||||
}
|
||||
fmt.Println(out)
|
||||
fn(out)
|
||||
}
|
||||
|
||||
// PubToJWK converts a parsed public key into bare JWK parameters + kty.
|
||||
// - RSA: returns n/e (base64url) and kty="RSA"
|
||||
// - Ed25519: returns x (base64url) and kty="OKP"
|
||||
func PubToJWK(_kid, _alg string, pub any) (map[string]string, string) {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
return map[string]string{
|
||||
"n": b64url(k.N.Bytes()),
|
||||
"e": b64url(fromInt(k.E)),
|
||||
}, "RSA"
|
||||
case ed25519.PublicKey:
|
||||
return map[string]string{
|
||||
"x": b64url([]byte(k)),
|
||||
}, "OKP"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
55
internal/auth/jwt_issue.go
Normal file
55
internal/auth/jwt_issue.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type IssueOpts struct {
|
||||
Subject string
|
||||
Issuer string
|
||||
Audience string
|
||||
TTL time.Duration
|
||||
Claims map[string]any // extra app claims
|
||||
}
|
||||
|
||||
func IssueAccessToken(opts IssueOpts) (string, error) {
|
||||
kc.mu.RLock()
|
||||
defer kc.mu.RUnlock()
|
||||
|
||||
if kc.selPriv == nil || kc.selKid == "" || kc.selAlg == "" {
|
||||
return "", errors.New("no active signing key")
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"iss": opts.Issuer,
|
||||
"aud": opts.Audience,
|
||||
"sub": opts.Subject,
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(opts.TTL).Unix(),
|
||||
}
|
||||
for k, v := range opts.Claims {
|
||||
claims[k] = v
|
||||
}
|
||||
|
||||
var method jwt.SigningMethod
|
||||
switch kc.selAlg {
|
||||
case "RS256":
|
||||
method = jwt.SigningMethodRS256
|
||||
case "RS384":
|
||||
method = jwt.SigningMethodRS384
|
||||
case "RS512":
|
||||
method = jwt.SigningMethodRS512
|
||||
case "EdDSA":
|
||||
method = jwt.SigningMethodEdDSA
|
||||
default:
|
||||
return "", errors.New("unsupported alg")
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(method, claims)
|
||||
token.Header["kid"] = kc.selKid
|
||||
|
||||
return token.SignedString(kc.selPriv)
|
||||
}
|
||||
138
internal/auth/jwt_signer.go
Normal file
138
internal/auth/jwt_signer.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/keys"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type keyCache struct {
|
||||
mu sync.RWMutex
|
||||
pub map[string]interface{} // kid -> public key object
|
||||
meta map[string]models.SigningKey
|
||||
selKid string
|
||||
selAlg string
|
||||
selPriv any
|
||||
}
|
||||
|
||||
var kc keyCache
|
||||
|
||||
// Refresh loads active keys into memory. Call on startup and periodically (ticker/cron).
|
||||
func Refresh(db *gorm.DB, encKeyB64 string) error {
|
||||
var rows []models.SigningKey
|
||||
if err := db.Where("is_active = true AND (expires_at IS NULL OR expires_at > ?)", time.Now()).
|
||||
Order("created_at desc").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pub := make(map[string]interface{}, len(rows))
|
||||
meta := make(map[string]models.SigningKey, len(rows))
|
||||
var selKid string
|
||||
var selAlg string
|
||||
var selPriv any
|
||||
|
||||
for i, r := range rows {
|
||||
// parse public
|
||||
block, _ := pem.Decode([]byte(r.PublicPEM))
|
||||
if block == nil {
|
||||
continue
|
||||
}
|
||||
var pubKey any
|
||||
switch r.Alg {
|
||||
case "RS256", "RS384", "RS512":
|
||||
pubKey, _ = x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
if pubKey == nil {
|
||||
// also allow PKIX format
|
||||
if k, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
|
||||
pubKey = k
|
||||
}
|
||||
}
|
||||
case "EdDSA":
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err == nil {
|
||||
if edk, ok := k.(ed25519.PublicKey); ok {
|
||||
pubKey = edk
|
||||
}
|
||||
}
|
||||
}
|
||||
if pubKey == nil {
|
||||
continue
|
||||
}
|
||||
pub[r.Kid] = pubKey
|
||||
meta[r.Kid] = r
|
||||
|
||||
// pick first row as current signer (most recent because of order desc)
|
||||
if i == 0 {
|
||||
privPEM := r.PrivatePEM
|
||||
// decrypt if necessary
|
||||
if len(privPEM) > 10 && privPEM[:10] == "enc:aesgcm" {
|
||||
pt, err := keysDecrypt(encKeyB64, privPEM)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
privPEM = string(pt)
|
||||
}
|
||||
blockPriv, _ := pem.Decode([]byte(privPEM))
|
||||
if blockPriv == nil {
|
||||
continue
|
||||
}
|
||||
switch r.Alg {
|
||||
case "RS256", "RS384", "RS512":
|
||||
if k, err := x509.ParsePKCS1PrivateKey(blockPriv.Bytes); err == nil {
|
||||
selPriv = k
|
||||
selAlg = r.Alg
|
||||
selKid = r.Kid
|
||||
} else if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
|
||||
if k, ok := kAny.(*rsa.PrivateKey); ok {
|
||||
selPriv = k
|
||||
selAlg = r.Alg
|
||||
selKid = r.Kid
|
||||
}
|
||||
}
|
||||
case "EdDSA":
|
||||
if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
|
||||
if k, ok := kAny.(ed25519.PrivateKey); ok {
|
||||
selPriv = k
|
||||
selAlg = r.Alg
|
||||
selKid = r.Kid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kc.mu.Lock()
|
||||
defer kc.mu.Unlock()
|
||||
kc.pub = pub
|
||||
kc.meta = meta
|
||||
kc.selKid = selKid
|
||||
kc.selAlg = selAlg
|
||||
kc.selPriv = selPriv
|
||||
return nil
|
||||
}
|
||||
|
||||
func keysDecrypt(encKey, enc string) ([]byte, error) {
|
||||
return keysDecryptImpl(encKey, enc)
|
||||
}
|
||||
|
||||
// indirection for same package
|
||||
var keysDecryptImpl = func(encKey, enc string) ([]byte, error) {
|
||||
return nil, errors.New("not wired")
|
||||
}
|
||||
|
||||
// Wire up from keys package
|
||||
func init() {
|
||||
keysDecryptImpl = keysDecryptShim
|
||||
}
|
||||
|
||||
func keysDecryptShim(encKey, enc string) ([]byte, error) {
|
||||
return keys.Decrypt(encKey, enc)
|
||||
}
|
||||
56
internal/auth/jwt_validate.go
Normal file
56
internal/auth/jwt_validate.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ValidateJWT verifies RS256/RS384/RS512/EdDSA tokens using the in-memory key cache.
|
||||
// It honors kid when present, and falls back to any active key.
|
||||
func ValidateJWT(tokenStr string, db *gorm.DB) *models.User {
|
||||
cfg, _ := config.Load()
|
||||
|
||||
parser := jwt.NewParser(
|
||||
jwt.WithIssuer(cfg.JWTIssuer),
|
||||
jwt.WithAudience(cfg.JWTAudience),
|
||||
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "EdDSA"}),
|
||||
)
|
||||
|
||||
token, err := parser.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
// Resolve by kid first
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
|
||||
kc.mu.RLock()
|
||||
defer kc.mu.RUnlock()
|
||||
|
||||
if kid != "" {
|
||||
if k, ok := kc.pub[kid]; ok {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
// Fallback: try first active key
|
||||
for _, k := range kc.pub {
|
||||
return k, nil
|
||||
}
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims, _ := token.Claims.(jwt.MapClaims)
|
||||
sub, _ := claims["sub"].(string)
|
||||
uid, err := uuid.Parse(sub)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var u models.User
|
||||
if err := db.First(&u, "id = ? AND is_disabled = false", uid).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
105
internal/auth/refresh.go
Normal file
105
internal/auth/refresh.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// random opaque token (returned to client once)
|
||||
func generateOpaqueToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
type RefreshPair struct {
|
||||
Plain string
|
||||
Record models.RefreshToken
|
||||
}
|
||||
|
||||
// Issue a new refresh token (new family if familyID == nil)
|
||||
func IssueRefreshToken(db *gorm.DB, userID uuid.UUID, ttl time.Duration, familyID *uuid.UUID) (RefreshPair, error) {
|
||||
plain, err := generateOpaqueToken(32)
|
||||
if err != nil {
|
||||
return RefreshPair{}, err
|
||||
}
|
||||
hash, err := HashSecretArgon2id(plain)
|
||||
if err != nil {
|
||||
return RefreshPair{}, err
|
||||
}
|
||||
|
||||
fid := uuid.New()
|
||||
if familyID != nil {
|
||||
fid = *familyID
|
||||
}
|
||||
|
||||
rec := models.RefreshToken{
|
||||
UserID: userID,
|
||||
FamilyID: fid,
|
||||
TokenHash: hash,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
return RefreshPair{}, err
|
||||
}
|
||||
return RefreshPair{Plain: plain, Record: rec}, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken refresh token; returns record if valid & not revoked/expired
|
||||
func ValidateRefreshToken(db *gorm.DB, plain string) (*models.RefreshToken, error) {
|
||||
if plain == "" {
|
||||
return nil, errors.New("empty")
|
||||
}
|
||||
// var rec models.RefreshToken
|
||||
// We can't query by hash w/ Argon; scan candidates by expiry window. Keep small TTL (e.g. 30d).
|
||||
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
|
||||
Find(&[]models.RefreshToken{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Better: add a prefix column to narrow scan; omitted for brevity.
|
||||
|
||||
// Pragmatic approach: single SELECT per token:
|
||||
// Add a TokenHashSHA256 column for deterministic lookup if you want O(1). (Optional)
|
||||
|
||||
// Minimal: iterate limited set; for simplicity we fetch by created window:
|
||||
var recs []models.RefreshToken
|
||||
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
|
||||
Order("created_at desc").Limit(500).Find(&recs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range recs {
|
||||
ok, _ := VerifySecretArgon2id(r.TokenHash, plain)
|
||||
if ok {
|
||||
return &r, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid")
|
||||
}
|
||||
|
||||
// RevokeFamily revokes all tokens in a family (logout everywhere)
|
||||
func RevokeFamily(db *gorm.DB, familyID uuid.UUID) error {
|
||||
now := time.Now()
|
||||
return db.Model(&models.RefreshToken{}).
|
||||
Where("family_id = ? AND revoked_at IS NULL", familyID).
|
||||
Update("revoked_at", &now).Error
|
||||
}
|
||||
|
||||
// RotateRefreshToken replaces one token with a fresh one within the same family
|
||||
func RotateRefreshToken(db *gorm.DB, used *models.RefreshToken, ttl time.Duration) (RefreshPair, error) {
|
||||
// revoke the used token (one-time use)
|
||||
now := time.Now()
|
||||
if err := db.Model(&models.RefreshToken{}).
|
||||
Where("id = ? AND revoked_at IS NULL", used.ID).
|
||||
Update("revoked_at", &now).Error; err != nil {
|
||||
return RefreshPair{}, err
|
||||
}
|
||||
return IssueRefreshToken(db, used.UserID, ttl, &used.FamilyID)
|
||||
}
|
||||
88
internal/auth/validate_keys.go
Normal file
88
internal/auth/validate_keys.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ValidateAPIKey validates a single-token user API key sent via X-API-KEY.
|
||||
func ValidateAPIKey(rawKey string, db *gorm.DB) *models.User {
|
||||
if rawKey == "" {
|
||||
return nil
|
||||
}
|
||||
digest := SHA256Hex(rawKey)
|
||||
|
||||
var k models.APIKey
|
||||
if err := db.
|
||||
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
|
||||
First(&k).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
if k.UserID == nil {
|
||||
return nil
|
||||
}
|
||||
var u models.User
|
||||
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
// Optional: touch last_used_at here if you've added it on the model.
|
||||
return &u
|
||||
}
|
||||
|
||||
// ValidateAppKeyPair validates a user key/secret pair via X-APP-KEY / X-APP-SECRET.
|
||||
func ValidateAppKeyPair(appKey, secret string, db *gorm.DB) *models.User {
|
||||
if appKey == "" || secret == "" {
|
||||
return nil
|
||||
}
|
||||
digest := SHA256Hex(appKey)
|
||||
|
||||
var k models.APIKey
|
||||
if err := db.
|
||||
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
|
||||
First(&k).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
|
||||
if !ok || k.UserID == nil {
|
||||
return nil
|
||||
}
|
||||
var u models.User
|
||||
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
|
||||
// ValidateOrgKeyPair validates an org key/secret via X-ORG-KEY / X-ORG-SECRET.
|
||||
func ValidateOrgKeyPair(orgKey, secret string, db *gorm.DB) *models.Organization {
|
||||
if orgKey == "" || secret == "" {
|
||||
return nil
|
||||
}
|
||||
digest := SHA256Hex(orgKey)
|
||||
|
||||
var k models.APIKey
|
||||
if err := db.
|
||||
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "org", time.Now()).
|
||||
First(&k).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
|
||||
if !ok || k.OrgID == nil {
|
||||
return nil
|
||||
}
|
||||
var o models.Organization
|
||||
if err := db.First(&o, "id = ?", *k.OrgID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &o
|
||||
}
|
||||
|
||||
// local helper; avoids nil-deref when comparing secrets
|
||||
func zeroIfNil(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
194
internal/config/config.go
Normal file
194
internal/config/config.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DbURL string
|
||||
Port string
|
||||
Host string
|
||||
JWTIssuer string
|
||||
JWTAudience string
|
||||
JWTPrivateEncKey string
|
||||
OAuthRedirectBase string
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GithubClientID string
|
||||
GithubClientSecret string
|
||||
|
||||
UIDev bool
|
||||
Env string
|
||||
Debug bool
|
||||
Swagger bool
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
cached Config
|
||||
loadErr error
|
||||
)
|
||||
|
||||
func Load() (Config, error) {
|
||||
once.Do(func() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
// Use a private viper to avoid global mutation/races
|
||||
v := viper.New()
|
||||
|
||||
// Defaults
|
||||
v.SetDefault("bind.address", "127.0.0.1")
|
||||
v.SetDefault("bind.port", "8080")
|
||||
v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
|
||||
|
||||
v.SetDefault("ui.dev", false)
|
||||
v.SetDefault("env", "development")
|
||||
v.SetDefault("debug", false)
|
||||
v.SetDefault("swagger", false)
|
||||
|
||||
// Env setup and binding
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
keys := []string{
|
||||
"bind.address",
|
||||
"bind.port",
|
||||
"database.url",
|
||||
"jwt.issuer",
|
||||
"jwt.audience",
|
||||
"jwt.private.enc.key",
|
||||
"oauth.redirect.base",
|
||||
"google.client.id",
|
||||
"google.client.secret",
|
||||
"github.client.id",
|
||||
"github.client.secret",
|
||||
"ui.dev",
|
||||
"env",
|
||||
"debug",
|
||||
"swagger",
|
||||
}
|
||||
for _, k := range keys {
|
||||
_ = v.BindEnv(k)
|
||||
}
|
||||
|
||||
// Build config
|
||||
cfg := Config{
|
||||
DbURL: v.GetString("database.url"),
|
||||
Port: v.GetString("bind.port"),
|
||||
Host: v.GetString("bind.address"),
|
||||
JWTIssuer: v.GetString("jwt.issuer"),
|
||||
JWTAudience: v.GetString("jwt.audience"),
|
||||
JWTPrivateEncKey: v.GetString("jwt.private.enc.key"),
|
||||
OAuthRedirectBase: v.GetString("oauth.redirect.base"),
|
||||
GoogleClientID: v.GetString("google.client.id"),
|
||||
GoogleClientSecret: v.GetString("google.client.secret"),
|
||||
GithubClientID: v.GetString("github.client.id"),
|
||||
GithubClientSecret: v.GetString("github.client.secret"),
|
||||
|
||||
UIDev: v.GetBool("ui.dev"),
|
||||
Env: v.GetString("env"),
|
||||
Debug: v.GetBool("debug"),
|
||||
Swagger: v.GetBool("swagger"),
|
||||
}
|
||||
|
||||
// Validate
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
loadErr = err
|
||||
return
|
||||
}
|
||||
|
||||
cached = cfg
|
||||
})
|
||||
return cached, loadErr
|
||||
}
|
||||
|
||||
func validateConfig(cfg Config) error {
|
||||
var errs []string
|
||||
|
||||
// Required general settings
|
||||
req := map[string]string{
|
||||
"jwt.issuer": cfg.JWTIssuer,
|
||||
"jwt.audience": cfg.JWTAudience,
|
||||
"jwt.private.enc.key": cfg.JWTPrivateEncKey,
|
||||
"oauth.redirect.base": cfg.OAuthRedirectBase,
|
||||
}
|
||||
for k, v := range req {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
errs = append(errs, fmt.Sprintf("missing required config key %q (env %s)", k, envNameFromKey(k)))
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth provider requirements:
|
||||
googleOK := strings.TrimSpace(cfg.GoogleClientID) != "" && strings.TrimSpace(cfg.GoogleClientSecret) != ""
|
||||
githubOK := strings.TrimSpace(cfg.GithubClientID) != "" && strings.TrimSpace(cfg.GithubClientSecret) != ""
|
||||
|
||||
// If partially configured, report what's missing for each
|
||||
if !googleOK && (cfg.GoogleClientID != "" || cfg.GoogleClientSecret != "") {
|
||||
if cfg.GoogleClientID == "" {
|
||||
errs = append(errs, fmt.Sprintf("google.client.id is missing (env %s) while google.client.secret is set", envNameFromKey("google.client.id")))
|
||||
}
|
||||
if cfg.GoogleClientSecret == "" {
|
||||
errs = append(errs, fmt.Sprintf("google.client.secret is missing (env %s) while google.client.id is set", envNameFromKey("google.client.secret")))
|
||||
}
|
||||
}
|
||||
if !githubOK && (cfg.GithubClientID != "" || cfg.GithubClientSecret != "") {
|
||||
if cfg.GithubClientID == "" {
|
||||
errs = append(errs, fmt.Sprintf("github.client.id is missing (env %s) while github.client.secret is set", envNameFromKey("github.client.id")))
|
||||
}
|
||||
if cfg.GithubClientSecret == "" {
|
||||
errs = append(errs, fmt.Sprintf("github.client.secret is missing (env %s) while github.client.id is set", envNameFromKey("github.client.secret")))
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce minimum: at least one full provider
|
||||
if !googleOK && !githubOK {
|
||||
errs = append(errs, "at least one OAuth provider must be fully configured: either Google (google.client.id + google.client.secret) or GitHub (github.client.id + github.client.secret)")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envNameFromKey(key string) string {
|
||||
return strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
|
||||
}
|
||||
|
||||
func DebugPrintConfig() {
|
||||
cfg, _ := Load()
|
||||
b, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
fmt.Println("error marshalling config:", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Loaded configuration:")
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
|
||||
func IsUIDev() bool {
|
||||
cfg, _ := Load()
|
||||
return cfg.UIDev
|
||||
}
|
||||
|
||||
func IsDev() bool {
|
||||
cfg, _ := Load()
|
||||
return strings.EqualFold(cfg.Env, "development")
|
||||
}
|
||||
|
||||
func IsDebug() bool {
|
||||
cfg, _ := Load()
|
||||
return cfg.Debug
|
||||
}
|
||||
|
||||
func IsSwaggerEnabled() bool {
|
||||
cfg, _ := Load()
|
||||
return cfg.Swagger
|
||||
}
|
||||
17
internal/db/db.go
Normal file
17
internal/db/db.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Open(dsn string) *gorm.DB {
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to db: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
25
internal/db/migrate.go
Normal file
25
internal/db/migrate.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Run(db *gorm.DB, models ...any) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 0) Extensions
|
||||
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS pgcrypto`).Error; err != nil {
|
||||
return fmt.Errorf("enable pgcrypto: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS citext`).Error; err != nil {
|
||||
return fmt.Errorf("enable citext: %w", err)
|
||||
}
|
||||
|
||||
// 1) AutoMigrate (pass parents before children in caller)
|
||||
if err := tx.AutoMigrate(models...); err != nil {
|
||||
return fmt.Errorf("automigrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
477
internal/handlers/auth.go
Normal file
477
internal/handlers/auth.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/glueops/autoglue/internal/auth"
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type oauthProvider struct {
|
||||
Name string
|
||||
Issuer string
|
||||
Scopes []string
|
||||
ClientID string
|
||||
Secret string
|
||||
}
|
||||
|
||||
func providerConfig(cfg config.Config, name string) (oauthProvider, bool) {
|
||||
switch strings.ToLower(name) {
|
||||
case "google":
|
||||
return oauthProvider{
|
||||
Name: "google",
|
||||
Issuer: "https://accounts.google.com",
|
||||
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
|
||||
ClientID: cfg.GoogleClientID,
|
||||
Secret: cfg.GoogleClientSecret,
|
||||
}, true
|
||||
case "github":
|
||||
// GitHub is not a pure OIDC provider; we use OAuth2 + user email API
|
||||
return oauthProvider{
|
||||
Name: "github",
|
||||
Issuer: "github",
|
||||
Scopes: []string{"read:user", "user:email"},
|
||||
ClientID: cfg.GithubClientID, Secret: cfg.GithubClientSecret,
|
||||
}, true
|
||||
}
|
||||
return oauthProvider{}, false
|
||||
}
|
||||
|
||||
// AuthStart godoc
|
||||
// @ID AuthStart
|
||||
// @Summary Begin social login
|
||||
// @Description Returns provider authorization URL for the frontend to redirect
|
||||
// @Tags Auth
|
||||
// @Param provider path string true "google|github"
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.AuthStartResponse
|
||||
// @Router /auth/{provider}/start [post]
|
||||
func AuthStart(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, _ := config.Load()
|
||||
provider := strings.ToLower(chi.URLParam(r, "provider"))
|
||||
|
||||
p, ok := providerConfig(cfg, provider)
|
||||
if !ok || p.ClientID == "" || p.Secret == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
|
||||
|
||||
// Optional SPA hints to be embedded into state
|
||||
mode := r.URL.Query().Get("mode") // "spa" enables postMessage callback page
|
||||
origin := r.URL.Query().Get("origin") // e.g. http://localhost:5173
|
||||
|
||||
state := uuid.NewString()
|
||||
if mode == "spa" && origin != "" {
|
||||
state = state + "|mode=spa|origin=" + url.QueryEscape(origin)
|
||||
}
|
||||
|
||||
var authURL string
|
||||
|
||||
if p.Issuer == "github" {
|
||||
o := &oauth2.Config{
|
||||
ClientID: p.ClientID,
|
||||
ClientSecret: p.Secret,
|
||||
RedirectURL: redirect,
|
||||
Scopes: p.Scopes,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
},
|
||||
}
|
||||
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
} else {
|
||||
// Google OIDC
|
||||
ctx := context.Background()
|
||||
prov, err := oidc.NewProvider(ctx, p.Issuer)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "oidc_discovery_failed", err.Error())
|
||||
return
|
||||
}
|
||||
o := &oauth2.Config{
|
||||
ClientID: p.ClientID,
|
||||
ClientSecret: p.Secret,
|
||||
RedirectURL: redirect,
|
||||
Endpoint: prov.Endpoint(),
|
||||
Scopes: p.Scopes,
|
||||
}
|
||||
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, dto.AuthStartResponse{AuthURL: authURL})
|
||||
}
|
||||
}
|
||||
|
||||
// AuthCallback godoc
|
||||
// @ID AuthCallback
|
||||
// @Summary Handle social login callback
|
||||
// @Tags Auth
|
||||
// @Param provider path string true "google|github"
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.TokenPair
|
||||
// @Router /auth/{provider}/callback [get]
|
||||
func AuthCallback(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, _ := config.Load()
|
||||
provider := strings.ToLower(chi.URLParam(r, "provider"))
|
||||
|
||||
p, ok := providerConfig(cfg, provider)
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_request", "missing code")
|
||||
return
|
||||
}
|
||||
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
|
||||
|
||||
var email, display, subject string
|
||||
|
||||
if p.Issuer == "github" {
|
||||
// OAuth2 code exchange
|
||||
o := &oauth2.Config{
|
||||
ClientID: p.ClientID,
|
||||
ClientSecret: p.Secret,
|
||||
RedirectURL: redirect,
|
||||
Scopes: p.Scopes,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
},
|
||||
}
|
||||
tok, err := o.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "exchange_failed", err.Error())
|
||||
return
|
||||
}
|
||||
// Fetch user primary email
|
||||
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
||||
req.Header.Set("Authorization", "token "+tok.AccessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "email_fetch_failed", "github user/emails")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil || len(emails) == 0 {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "email_parse_failed", err.Error())
|
||||
return
|
||||
}
|
||||
email = emails[0].Email
|
||||
for _, e := range emails {
|
||||
if e.Primary {
|
||||
email = e.Email
|
||||
break
|
||||
}
|
||||
}
|
||||
subject = "github:" + email
|
||||
display = strings.Split(email, "@")[0]
|
||||
} else {
|
||||
// Google OIDC
|
||||
oidcProv, err := oidc.NewProvider(r.Context(), p.Issuer)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "oidc_discovery_failed", err.Error())
|
||||
return
|
||||
}
|
||||
o := &oauth2.Config{
|
||||
ClientID: p.ClientID,
|
||||
ClientSecret: p.Secret,
|
||||
RedirectURL: redirect,
|
||||
Endpoint: oidcProv.Endpoint(),
|
||||
Scopes: p.Scopes,
|
||||
}
|
||||
tok, err := o.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 401, "exchange_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
verifier := oidcProv.Verifier(&oidc.Config{ClientID: p.ClientID})
|
||||
rawIDToken, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "no_id_token", "")
|
||||
return
|
||||
}
|
||||
idt, err := verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 401, "id_token_invalid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
if err := idt.Claims(&claims); err != nil {
|
||||
utils.WriteError(w, 401, "claims_parse_error", err.Error())
|
||||
return
|
||||
}
|
||||
email = strings.ToLower(claims.Email)
|
||||
display = claims.Name
|
||||
subject = "google:" + claims.Sub
|
||||
}
|
||||
|
||||
// Upsert Account + User; domain auto-join (member)
|
||||
user, err := upsertAccountAndUser(db, p.Name, subject, email, display)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "account_upsert_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Org auto-join: Organization.Domain == email domain
|
||||
_ = ensureAutoMembership(db, user.ID, email)
|
||||
|
||||
// Issue tokens
|
||||
accessTTL := 1 * time.Hour
|
||||
refreshTTL := 30 * 24 * time.Hour
|
||||
|
||||
access, err := auth.IssueAccessToken(auth.IssueOpts{
|
||||
Subject: user.ID.String(),
|
||||
Issuer: cfg.JWTIssuer,
|
||||
Audience: cfg.JWTAudience,
|
||||
TTL: accessTTL,
|
||||
Claims: map[string]any{
|
||||
"email": email,
|
||||
"name": display,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "issue_access_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rp, err := auth.IssueRefreshToken(db, user.ID, refreshTTL, nil)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "issue_refresh_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If the state indicates SPA popup mode, postMessage tokens to the opener and close
|
||||
state := r.URL.Query().Get("state")
|
||||
if strings.Contains(state, "mode=spa") {
|
||||
origin := ""
|
||||
for _, part := range strings.Split(state, "|") {
|
||||
if strings.HasPrefix(part, "origin=") {
|
||||
origin, _ = url.QueryUnescape(strings.TrimPrefix(part, "origin="))
|
||||
break
|
||||
}
|
||||
}
|
||||
// fallback: restrict to backend origin if none supplied
|
||||
if origin == "" {
|
||||
origin = cfg.OAuthRedirectBase
|
||||
}
|
||||
payload := dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: rp.Plain,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(accessTTL.Seconds()),
|
||||
}
|
||||
writePostMessageHTML(w, origin, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Default JSON response
|
||||
utils.WriteJSON(w, http.StatusOK, dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: rp.Plain,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(accessTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @ID Refresh
|
||||
// @Summary Rotate refresh token
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.RefreshRequest true "Refresh token"
|
||||
// @Success 200 {object} dto.TokenPair
|
||||
// @Router /auth/refresh [post]
|
||||
func Refresh(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, _ := config.Load()
|
||||
var req dto.RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 401, "invalid_refresh", "")
|
||||
return
|
||||
}
|
||||
|
||||
var u models.User
|
||||
if err := db.First(&u, "id = ? AND is_disabled = false", rec.UserID).Error; err != nil {
|
||||
utils.WriteError(w, 401, "user_disabled", "")
|
||||
return
|
||||
}
|
||||
|
||||
// rotate
|
||||
newPair, err := auth.RotateRefreshToken(db, rec, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "rotate_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// new access
|
||||
access, err := auth.IssueAccessToken(auth.IssueOpts{
|
||||
Subject: u.ID.String(),
|
||||
Issuer: cfg.JWTIssuer,
|
||||
Audience: cfg.JWTAudience,
|
||||
TTL: 1 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "issue_access_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, 200, dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: newPair.Plain,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Logout godoc
|
||||
// @ID Logout
|
||||
// @Summary Revoke refresh token family (logout everywhere)
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.LogoutRequest true "Refresh token"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /auth/logout [post]
|
||||
func Logout(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.LogoutRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
|
||||
if err != nil {
|
||||
w.WriteHeader(204) // already invalid/revoked
|
||||
return
|
||||
}
|
||||
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
|
||||
utils.WriteError(w, 500, "revoke_failed", err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
func upsertAccountAndUser(db *gorm.DB, provider, subject, email, display string) (*models.User, error) {
|
||||
email = strings.ToLower(email)
|
||||
var acc models.Account
|
||||
if err := db.Where("provider = ? AND subject = ?", provider, subject).First(&acc).Error; err == nil {
|
||||
var u models.User
|
||||
if err := db.First(&u, "id = ?", acc.UserID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
// Link by email if exists
|
||||
var ue models.UserEmail
|
||||
if err := db.Where("LOWER(email) = ?", email).First(&ue).Error; err == nil {
|
||||
acc = models.Account{
|
||||
UserID: ue.UserID,
|
||||
Provider: provider,
|
||||
Subject: subject,
|
||||
Email: &email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := db.Create(&acc).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var u models.User
|
||||
if err := db.First(&u, "id = ?", ue.UserID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
// Create user
|
||||
u := models.User{DisplayName: &display, PrimaryEmail: &email}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ue = models.UserEmail{UserID: u.ID, Email: email, IsVerified: true, IsPrimary: true}
|
||||
_ = db.Create(&ue).Error
|
||||
acc = models.Account{UserID: u.ID, Provider: provider, Subject: subject, Email: &email, EmailVerified: true}
|
||||
_ = db.Create(&acc).Error
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
|
||||
parts := strings.SplitN(strings.ToLower(email), "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
domain := parts[1]
|
||||
var org models.Organization
|
||||
if err := db.Where("LOWER(domain) = ?", domain).First(&org).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
// if already member, done
|
||||
var c int64
|
||||
db.Model(&models.Membership{}).
|
||||
Where("user_id = ? AND organization_id = ?", userID, org.ID).
|
||||
Count(&c)
|
||||
if c > 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&models.Membership{
|
||||
UserID: userID, OrganizationID: org.ID, Role: "member",
|
||||
}).Error
|
||||
}
|
||||
|
||||
// writePostMessageHTML sends a tiny HTML page that posts tokens to the SPA and closes the window.
|
||||
func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
|
||||
b, _ := json.Marshal(payload)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><body><script>
|
||||
(function(){
|
||||
try {
|
||||
var data = ` + string(b) + `;
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'autoglue:auth', payload: data }, '` + origin + `');
|
||||
}
|
||||
} catch (e) {}
|
||||
window.close();
|
||||
})();
|
||||
</script></body></html>`))
|
||||
}
|
||||
24
internal/handlers/dto/auth.go
Normal file
24
internal/handlers/dto/auth.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package dto
|
||||
|
||||
// swagger:model AuthStartResponse
|
||||
type AuthStartResponse struct {
|
||||
AuthURL string `json:"auth_url" example:"https://accounts.google.com/o/oauth2/v2/auth?client_id=..."`
|
||||
}
|
||||
|
||||
// swagger:model TokenPair
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token" example:"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ij..."`
|
||||
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf...."`
|
||||
TokenType string `json:"token_type" example:"Bearer"`
|
||||
ExpiresIn int64 `json:"expires_in" example:"3600"`
|
||||
}
|
||||
|
||||
// swagger:model RefreshRequest
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
|
||||
}
|
||||
|
||||
// swagger:model LogoutRequest
|
||||
type LogoutRequest struct {
|
||||
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
|
||||
}
|
||||
19
internal/handlers/dto/jwks.go
Normal file
19
internal/handlers/dto/jwks.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package dto
|
||||
|
||||
// JWK represents a single JSON Web Key (public only).
|
||||
// swagger:model JWK
|
||||
type JWK struct {
|
||||
Kty string `json:"kty" example:"RSA" gorm:"-"`
|
||||
Use string `json:"use,omitempty" example:"sig" gorm:"-"`
|
||||
Kid string `json:"kid,omitempty" example:"7c6f1d0a-7a98-4e6a-9dbf-6b1af4b9f345" gorm:"-"`
|
||||
Alg string `json:"alg,omitempty" example:"RS256" gorm:"-"`
|
||||
N string `json:"n,omitempty" gorm:"-"`
|
||||
E string `json:"e,omitempty" example:"AQAB" gorm:"-"`
|
||||
X string `json:"x,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
// JWKS is a JSON Web Key Set container.
|
||||
// swagger:model JWKS
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys" gorm:"-"`
|
||||
}
|
||||
37
internal/handlers/dto/servers.go
Normal file
37
internal/handlers/dto/servers.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package dto
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type CreateServerRequest struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
PublicIPAddress string `json:"public_ip_address,omitempty"`
|
||||
PrivateIPAddress string `json:"private_ip_address"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SshKeyID string `json:"ssh_key_id"`
|
||||
Role string `json:"role" example:"master|worker|bastion"`
|
||||
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
|
||||
}
|
||||
|
||||
type UpdateServerRequest struct {
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
PublicIPAddress *string `json:"public_ip_address,omitempty"`
|
||||
PrivateIPAddress *string `json:"private_ip_address,omitempty"`
|
||||
SSHUser *string `json:"ssh_user,omitempty"`
|
||||
SshKeyID *string `json:"ssh_key_id,omitempty"`
|
||||
Role *string `json:"role,omitempty" example:"master|worker|bastion"`
|
||||
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
|
||||
}
|
||||
|
||||
type ServerResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
PublicIPAddress *string `json:"public_ip_address,omitempty"`
|
||||
PrivateIPAddress string `json:"private_ip_address"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SshKeyID uuid.UUID `json:"ssh_key_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
38
internal/handlers/dto/ssh.go
Normal file
38
internal/handlers/dto/ssh.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package dto
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type CreateSSHRequest struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
|
||||
Bits *int `json:"bits,omitempty"` // Only for RSA
|
||||
Type *string `json:"type,omitempty"` // "rsa" (default) or "ed25519"
|
||||
}
|
||||
|
||||
type SshResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type SshRevealResponse struct {
|
||||
SshResponse
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
type SshMaterialJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
// Exactly one of the following will be populated for part=public/private.
|
||||
PublicKey *string `json:"public_key,omitempty"` // OpenSSH authorized_key (string)
|
||||
PrivatePEM *string `json:"private_pem,omitempty"` // PKCS#1/PEM (string)
|
||||
// For part=both with mode=json we'll return a base64 zip
|
||||
ZipBase64 *string `json:"zip_base64,omitempty"` // base64-encoded zip
|
||||
// Suggested filenames (SDKs can save to disk without inferring names)
|
||||
Filenames []string `json:"filenames"`
|
||||
}
|
||||
22
internal/handlers/dto/taints.go
Normal file
22
internal/handlers/dto/taints.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package dto
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type TaintResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Effect string `json:"effect"`
|
||||
}
|
||||
|
||||
type CreateTaintRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Effect string `json:"effect"`
|
||||
}
|
||||
|
||||
type UpdateTaintRequest struct {
|
||||
Key *string `json:"key,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
Effect *string `json:"effect,omitempty"`
|
||||
}
|
||||
56
internal/handlers/jwks.go
Normal file
56
internal/handlers/jwks.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/glueops/autoglue/internal/auth"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
)
|
||||
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
N string `json:"n,omitempty"` // RSA modulus (base64url)
|
||||
E string `json:"e,omitempty"` // RSA exponent (base64url)
|
||||
X string `json:"x,omitempty"` // Ed25519 public key (base64url)
|
||||
}
|
||||
|
||||
type jwks struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
|
||||
// JWKSHandler godoc
|
||||
// @ID getJWKS
|
||||
// @Summary Get JWKS
|
||||
// @Description Returns the JSON Web Key Set for token verification
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.JWKS
|
||||
// @Router /.well-known/jwks.json [get]
|
||||
func JWKSHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
out := dto.JWKS{Keys: make([]dto.JWK, 0)}
|
||||
|
||||
auth.KcCopy(func(pub map[string]interface{}) {
|
||||
for kid, pk := range pub {
|
||||
meta := auth.MetaFor(kid)
|
||||
params, kty := auth.PubToJWK(kid, meta.Alg, pk)
|
||||
if kty == "" {
|
||||
continue
|
||||
}
|
||||
j := dto.JWK{
|
||||
Kty: kty,
|
||||
Use: "sig",
|
||||
Kid: kid,
|
||||
Alg: meta.Alg,
|
||||
N: params["n"],
|
||||
E: params["e"],
|
||||
X: params["x"],
|
||||
}
|
||||
out.Keys = append(out.Keys, j)
|
||||
}
|
||||
})
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
120
internal/handlers/me.go
Normal file
120
internal/handlers/me.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type meResponse struct {
|
||||
models.User `json:",inline"`
|
||||
Emails []models.UserEmail `json:"emails"`
|
||||
Organizations []models.Organization `json:"organizations"`
|
||||
}
|
||||
|
||||
// GetMe godoc
|
||||
// @ID GetMe
|
||||
// @Summary Get current user profile
|
||||
// @Tags Me
|
||||
// @Produce json
|
||||
// @Success 200 {object} meResponse
|
||||
// @Router /me [get]
|
||||
// @Security BearerAuth
|
||||
// @Security ApiKeyAuth
|
||||
func GetMe(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := httpmiddleware.UserFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, "id = ? AND is_disabled = false", u.ID).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user not found/disabled")
|
||||
return
|
||||
}
|
||||
|
||||
var emails []models.UserEmail
|
||||
_ = db.Where("user_id = ?", user.ID).Order("is_primary desc, created_at asc").Find(&emails).Error
|
||||
|
||||
var orgs []models.Organization
|
||||
{
|
||||
var rows []models.Membership
|
||||
_ = db.Where("user_id = ?", user.ID).Find(&rows).Error
|
||||
if len(rows) > 0 {
|
||||
var ids []interface{}
|
||||
for _, m := range rows {
|
||||
ids = append(ids, m.OrganizationID)
|
||||
}
|
||||
_ = db.Find(&orgs, "id IN ?", ids).Error
|
||||
}
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, meResponse{
|
||||
User: user,
|
||||
Emails: emails,
|
||||
Organizations: orgs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type updateMeRequest struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
// You can add more editable fields here (timezone, avatar, etc)
|
||||
}
|
||||
|
||||
// UpdateMe godoc
|
||||
// @ID UpdateMe
|
||||
// @Summary Update current user profile
|
||||
// @Tags Me
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body updateMeRequest true "Patch profile"
|
||||
// @Success 200 {object} models.User
|
||||
// @Router /me [patch]
|
||||
// @Security BearerAuth
|
||||
// @Security ApiKeyAuth
|
||||
func UpdateMe(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := httpmiddleware.UserFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateMeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if req.DisplayName != nil {
|
||||
updates["display_name"] = req.DisplayName
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
var user models.User
|
||||
if err := db.First(&user, "id = ?", u.ID).Error; err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "user")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, 200, user)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&models.User{}).Where("id = ?", u.ID).Updates(updates).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var out models.User
|
||||
_ = db.First(&out, "id = ?", u.ID).Error
|
||||
utils.WriteJSON(w, 200, out)
|
||||
}
|
||||
}
|
||||
175
internal/handlers/me_keys.go
Normal file
175
internal/handlers/me_keys.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"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"
|
||||
)
|
||||
|
||||
type userAPIKeyOut struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Scope string `json:"scope"` // "user"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Plain *string `json:"plain,omitempty"` // Shown only on create:
|
||||
}
|
||||
|
||||
// ListUserAPIKeys godoc
|
||||
// @ID ListUserAPIKeys
|
||||
// @Summary List my API keys
|
||||
// @Tags Me / API Keys
|
||||
// @Produce json
|
||||
// @Success 200 {array} userAPIKeyOut
|
||||
// @Router /me/api-keys [get]
|
||||
// @Security BearerAuth
|
||||
// @Security ApiKeyAuth
|
||||
func ListUserAPIKeys(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := httpmiddleware.UserFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
|
||||
return
|
||||
}
|
||||
var rows []models.APIKey
|
||||
if err := db.
|
||||
Where("scope = ? AND user_id = ?", "user", u.ID).
|
||||
Order("created_at desc").
|
||||
Find(&rows).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]userAPIKeyOut, 0, len(rows))
|
||||
for _, k := range rows {
|
||||
out = append(out, toUserKeyOut(k, nil))
|
||||
}
|
||||
utils.WriteJSON(w, 200, out)
|
||||
}
|
||||
}
|
||||
|
||||
type createUserKeyRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
ExpiresInHours *int `json:"expires_in_hours,omitempty"` // optional TTL
|
||||
}
|
||||
|
||||
// CreateUserAPIKey godoc
|
||||
// @ID CreateUserAPIKey
|
||||
// @Summary Create a new user API key
|
||||
// @Description Returns the plaintext key once. Store it securely on the client side.
|
||||
// @Tags Me / API Keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body createUserKeyRequest true "Key options"
|
||||
// @Success 201 {object} userAPIKeyOut
|
||||
// @Router /me/api-keys [post]
|
||||
// @Security BearerAuth
|
||||
// @Security ApiKeyAuth
|
||||
func CreateUserAPIKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := httpmiddleware.UserFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
|
||||
return
|
||||
}
|
||||
var req createUserKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plain, err := generateUserAPIKey()
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "gen_failed", err.Error())
|
||||
return
|
||||
}
|
||||
hash := auth.SHA256Hex(plain)
|
||||
|
||||
var exp *time.Time
|
||||
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
|
||||
t := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
|
||||
exp = &t
|
||||
}
|
||||
|
||||
rec := models.APIKey{
|
||||
Scope: "user",
|
||||
UserID: &u.ID,
|
||||
KeyHash: hash,
|
||||
Name: req.Name, // if field exists
|
||||
ExpiresAt: exp,
|
||||
// SecretHash: nil (not used for user keys)
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusCreated, toUserKeyOut(rec, &plain))
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteUserAPIKey godoc
|
||||
// @ID DeleteUserAPIKey
|
||||
// @Summary Delete a user API key
|
||||
// @Tags Me / API Keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID (UUID)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /me/api-keys/{id} [delete]
|
||||
// @Security BearerAuth
|
||||
func DeleteUserAPIKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := httpmiddleware.UserFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 400, "invalid_id", "must be uuid")
|
||||
return
|
||||
}
|
||||
tx := db.Where("id = ? AND scope = ? AND user_id = ?", id, "user", u.ID).
|
||||
Delete(&models.APIKey{})
|
||||
if tx.Error != nil {
|
||||
utils.WriteError(w, 500, "db_error", tx.Error.Error())
|
||||
return
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
utils.WriteError(w, 404, "not_found", "key not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func toUserKeyOut(k models.APIKey, plain *string) userAPIKeyOut {
|
||||
return userAPIKeyOut{
|
||||
ID: k.ID,
|
||||
Name: &k.Name, // if your model has it; else remove
|
||||
Scope: k.Scope,
|
||||
CreatedAt: k.CreatedAt,
|
||||
ExpiresAt: k.ExpiresAt,
|
||||
LastUsedAt: k.LastUsedAt, // if present; else remove
|
||||
Plain: plain,
|
||||
}
|
||||
}
|
||||
|
||||
func generateUserAPIKey() (string, error) {
|
||||
// 24 random bytes → base64url (no padding), with "u_" prefix
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base64.RawURLEncoding.EncodeToString(b)
|
||||
return "u_" + s, nil
|
||||
}
|
||||
647
internal/handlers/orgs.go
Normal file
647
internal/handlers/orgs.go
Normal file
@@ -0,0 +1,647 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
func mustUser(r *http.Request) (*models.User, bool) {
|
||||
return httpmiddleware.UserFrom(r.Context())
|
||||
}
|
||||
|
||||
func isOrgRole(db *gorm.DB, userID, orgID uuid.UUID, want ...string) (bool, string) {
|
||||
var m models.Membership
|
||||
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err != nil {
|
||||
return false, ""
|
||||
}
|
||||
got := strings.ToLower(m.Role)
|
||||
for _, w := range want {
|
||||
if got == strings.ToLower(w) {
|
||||
return true, got
|
||||
}
|
||||
}
|
||||
return false, got
|
||||
}
|
||||
|
||||
func mustMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
|
||||
ok, _ := isOrgRole(db, userID, orgID, "owner", "admin", "member")
|
||||
return ok
|
||||
}
|
||||
|
||||
func randomB64URL(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// ---------- Orgs: list/create/get/update/delete ----------
|
||||
|
||||
type orgCreateReq struct {
|
||||
Name string `json:"name" example:"Acme Corp"`
|
||||
Domain *string `json:"domain,omitempty" example:"acme.com"`
|
||||
}
|
||||
|
||||
// CreateOrg godoc
|
||||
// @ID CreateOrg
|
||||
// @Summary Create organization
|
||||
// @Tags Orgs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body orgCreateReq true "Org payload"
|
||||
// @Success 201 {object} models.Organization
|
||||
// @Failure 400 {object} utils.ErrorResponse
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Failure 409 {object} utils.ErrorResponse
|
||||
// @Router /orgs [post]
|
||||
// @ID createOrg
|
||||
// @Security BearerAuth
|
||||
func CreateOrg(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req orgCreateReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
utils.WriteError(w, 400, "validation_error", "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
org := models.Organization{Name: req.Name}
|
||||
if req.Domain != nil && strings.TrimSpace(*req.Domain) != "" {
|
||||
org.Domain = req.Domain
|
||||
}
|
||||
|
||||
if err := db.Create(&org).Error; err != nil {
|
||||
utils.WriteError(w, 409, "conflict", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// creator is owner
|
||||
_ = db.Create(&models.Membership{
|
||||
UserID: u.ID, OrganizationID: org.ID, Role: "owner",
|
||||
}).Error
|
||||
|
||||
utils.WriteJSON(w, 201, org)
|
||||
}
|
||||
}
|
||||
|
||||
// ListMyOrgs godoc
|
||||
// @ID ListMyOrgs
|
||||
// @Summary List organizations I belong to
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Organization
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs [get]
|
||||
// @ID listMyOrgs
|
||||
// @Security BearerAuth
|
||||
func ListMyOrgs(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
|
||||
var orgs []models.Organization
|
||||
if err := db.
|
||||
Joins("join memberships m on m.organization_id = organizations.id").
|
||||
Where("m.user_id = ?", u.ID).
|
||||
Order("organizations.created_at desc").
|
||||
Find(&orgs).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, 200, orgs)
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrg godoc
|
||||
// @ID GetOrg
|
||||
// @Summary Get organization
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Success 200 {object} models.Organization
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Failure 404 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id} [get]
|
||||
// @ID getOrg
|
||||
// @Security BearerAuth
|
||||
func GetOrg(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if !mustMember(db, u.ID, oid) {
|
||||
utils.WriteError(w, 401, "forbidden", "not a member")
|
||||
return
|
||||
}
|
||||
var org models.Organization
|
||||
if err := db.First(&org, "id = ?", oid).Error; err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, 200, org)
|
||||
}
|
||||
}
|
||||
|
||||
type orgUpdateReq struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Domain *string `json:"domain,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateOrg godoc
|
||||
// @ID UpdateOrg
|
||||
// @Summary Update organization (owner/admin)
|
||||
// @Tags Orgs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Param body body orgUpdateReq true "Update payload"
|
||||
// @Success 200 {object} models.Organization
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Failure 404 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id} [patch]
|
||||
// @ID updateOrg
|
||||
// @Security BearerAuth
|
||||
func UpdateOrg(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
|
||||
utils.WriteError(w, 401, "forbidden", "admin or owner required")
|
||||
return
|
||||
}
|
||||
var req orgUpdateReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
changes := map[string]any{}
|
||||
if req.Name != nil {
|
||||
changes["name"] = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
if d := strings.TrimSpace(*req.Domain); d == "" {
|
||||
changes["domain"] = nil
|
||||
} else {
|
||||
changes["domain"] = d
|
||||
}
|
||||
}
|
||||
if len(changes) > 0 {
|
||||
if err := db.Model(&models.Organization{}).Where("id = ?", oid).Updates(changes).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
var out models.Organization
|
||||
_ = db.First(&out, "id = ?", oid).Error
|
||||
utils.WriteJSON(w, 200, out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteOrg godoc
|
||||
// @ID DeleteOrg
|
||||
// @Summary Delete organization (owner)
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Success 204 "Deleted"
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Failure 404 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id} [delete]
|
||||
// @ID deleteOrg
|
||||
// @Security BearerAuth
|
||||
func DeleteOrg(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if ok, _ := isOrgRole(db, u.ID, oid, "owner"); !ok {
|
||||
utils.WriteError(w, 401, "forbidden", "owner required")
|
||||
return
|
||||
}
|
||||
// Optional safety: deny if members >1 or resources exist; here we just delete.
|
||||
res := db.Delete(&models.Organization{}, "id = ?", oid)
|
||||
if res.Error != nil {
|
||||
utils.WriteError(w, 500, "db_error", res.Error.Error())
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Members: list/add/update/delete ----------
|
||||
|
||||
type memberOut struct {
|
||||
UserID uuid.UUID `json:"user_id" format:"uuid"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner/admin/member
|
||||
}
|
||||
|
||||
type memberUpsertReq struct {
|
||||
UserID uuid.UUID `json:"user_id" format:"uuid"`
|
||||
Role string `json:"role" example:"member"`
|
||||
}
|
||||
|
||||
// ListMembers godoc
|
||||
// @ID ListMembers
|
||||
// @Summary List members in org
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Success 200 {array} memberOut
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id}/members [get]
|
||||
// @ID listMembers
|
||||
// @Security BearerAuth
|
||||
func ListMembers(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil || !mustMember(db, u.ID, oid) {
|
||||
utils.WriteError(w, 401, "forbidden", "")
|
||||
return
|
||||
}
|
||||
var ms []models.Membership
|
||||
if err := db.Where("organization_id = ?", oid).Find(&ms).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// load emails
|
||||
userIDs := make([]uuid.UUID, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
userIDs = append(userIDs, m.UserID)
|
||||
}
|
||||
var emails []models.UserEmail
|
||||
if len(userIDs) > 0 {
|
||||
_ = db.Where("user_id in ?", userIDs).Where("is_primary = true").Find(&emails).Error
|
||||
}
|
||||
emailByUser := map[uuid.UUID]string{}
|
||||
for _, e := range emails {
|
||||
emailByUser[e.UserID] = e.Email
|
||||
}
|
||||
|
||||
out := make([]memberOut, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
out = append(out, memberOut{
|
||||
UserID: m.UserID,
|
||||
Email: emailByUser[m.UserID],
|
||||
Role: m.Role,
|
||||
})
|
||||
}
|
||||
utils.WriteJSON(w, 200, out)
|
||||
}
|
||||
}
|
||||
|
||||
// AddOrUpdateMember godoc
|
||||
// @ID AddOrUpdateMember
|
||||
// @Summary Add or update a member (owner/admin)
|
||||
// @Tags Orgs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Param body body memberUpsertReq true "User & role"
|
||||
// @Success 200 {object} memberOut
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id}/members [post]
|
||||
// @ID addOrUpdateMember
|
||||
// @Security BearerAuth
|
||||
func AddOrUpdateMember(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
|
||||
utils.WriteError(w, 401, "forbidden", "admin or owner required")
|
||||
return
|
||||
}
|
||||
var req memberUpsertReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "owner" && role != "admin" && role != "member" {
|
||||
utils.WriteError(w, 400, "validation_error", "role must be owner|admin|member")
|
||||
return
|
||||
}
|
||||
var m models.Membership
|
||||
tx := db.Where("user_id = ? AND organization_id = ?", req.UserID, oid).First(&m)
|
||||
if tx.Error == nil {
|
||||
// update
|
||||
if err := db.Model(&m).Update("role", role).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
} else if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
m = models.Membership{UserID: req.UserID, OrganizationID: oid, Role: role}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
utils.WriteError(w, 500, "db_error", tx.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// make response
|
||||
var ue models.UserEmail
|
||||
_ = db.Where("user_id = ? AND is_primary = true", req.UserID).First(&ue).Error
|
||||
utils.WriteJSON(w, 200, memberOut{
|
||||
UserID: req.UserID, Email: ue.Email, Role: m.Role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveMember godoc
|
||||
// @ID RemoveMember
|
||||
// @Summary Remove a member (owner/admin)
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Param user_id path string true "User ID (UUID)"
|
||||
// @Success 204 "Removed"
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id}/members/{user_id} [delete]
|
||||
// @ID removeMember
|
||||
// @Security BearerAuth
|
||||
func RemoveMember(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
|
||||
utils.WriteError(w, 401, "forbidden", "admin or owner required")
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(chi.URLParam(r, "user_id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 400, "invalid_user_id", "")
|
||||
return
|
||||
}
|
||||
res := db.Where("user_id = ? AND organization_id = ?", uid, oid).Delete(&models.Membership{})
|
||||
if res.Error != nil {
|
||||
utils.WriteError(w, 500, "db_error", res.Error.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Org API Keys (key/secret pair) ----------
|
||||
|
||||
type orgKeyCreateReq struct {
|
||||
Name string `json:"name,omitempty" example:"automation-bot"`
|
||||
ExpiresInHours *int `json:"expires_in_hours,omitempty" example:"720"`
|
||||
}
|
||||
|
||||
type orgKeyCreateResp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Scope string `json:"scope"` // "org"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
OrgKey string `json:"org_key"` // shown once:
|
||||
OrgSecret string `json:"org_secret"` // shown once:
|
||||
}
|
||||
|
||||
// ListOrgKeys godoc
|
||||
// @ID ListOrgKeys
|
||||
// @Summary List org-scoped API keys (no secrets)
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Success 200 {array} models.APIKey
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id}/api-keys [get]
|
||||
// @ID listOrgKeys
|
||||
// @Security BearerAuth
|
||||
func ListOrgKeys(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil || !mustMember(db, u.ID, oid) {
|
||||
utils.WriteError(w, 401, "forbidden", "")
|
||||
return
|
||||
}
|
||||
var keys []models.APIKey
|
||||
if err := db.Where("org_id = ? AND scope = ?", oid, "org").
|
||||
Order("created_at desc").
|
||||
Find(&keys).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
// SecretHash must not be exposed; your json tags likely hide it already.
|
||||
utils.WriteJSON(w, 200, keys)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOrgKey godoc
|
||||
// @ID CreateOrgKey
|
||||
// @Summary Create org key/secret pair (owner/admin)
|
||||
// @Tags Orgs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Param body body orgKeyCreateReq true "Key name + optional expiry"
|
||||
// @Success 201 {object} orgKeyCreateResp
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id}/api-keys [post]
|
||||
// @ID createOrgKey
|
||||
// @Security BearerAuth
|
||||
func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
|
||||
utils.WriteError(w, 401, "forbidden", "admin or owner required")
|
||||
return
|
||||
}
|
||||
|
||||
var req orgKeyCreateReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// generate
|
||||
keySuffix, err := randomB64URL(16)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "entropy_error", err.Error())
|
||||
return
|
||||
}
|
||||
sec, err := randomB64URL(32)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "entropy_error", err.Error())
|
||||
return
|
||||
}
|
||||
orgKey := "org_" + keySuffix
|
||||
secretPlain := sec
|
||||
|
||||
keyHash := auth.SHA256Hex(orgKey)
|
||||
secretHash, err := auth.HashSecretArgon2id(secretPlain)
|
||||
if err != nil {
|
||||
utils.WriteError(w, 500, "hash_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var exp *time.Time
|
||||
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
|
||||
e := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
|
||||
exp = &e
|
||||
}
|
||||
|
||||
rec := models.APIKey{
|
||||
OrgID: &oid,
|
||||
Scope: "org",
|
||||
Name: req.Name,
|
||||
KeyHash: keyHash,
|
||||
SecretHash: &secretHash,
|
||||
ExpiresAt: exp,
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
utils.WriteError(w, 500, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, 201, orgKeyCreateResp{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Scope: rec.Scope,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
ExpiresAt: rec.ExpiresAt,
|
||||
OrgKey: orgKey,
|
||||
OrgSecret: secretPlain,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteOrgKey godoc
|
||||
// @ID DeleteOrgKey
|
||||
// @Summary Delete org key (owner/admin)
|
||||
// @Tags Orgs
|
||||
// @Produce json
|
||||
// @Param id path string true "Org ID (UUID)"
|
||||
// @Param key_id path string true "Key ID (UUID)"
|
||||
// @Success 204 "Deleted"
|
||||
// @Failure 401 {object} utils.ErrorResponse
|
||||
// @Router /orgs/{id}/api-keys/{key_id} [delete]
|
||||
// @ID deleteOrgKey
|
||||
// @Security BearerAuth
|
||||
func DeleteOrgKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := mustUser(r)
|
||||
if !ok {
|
||||
utils.WriteError(w, 401, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 404, "not_found", "org not found")
|
||||
return
|
||||
}
|
||||
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
|
||||
utils.WriteError(w, 401, "forbidden", "admin or owner required")
|
||||
return
|
||||
}
|
||||
kid, err := uuid.Parse(chi.URLParam(r, "key_id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, 400, "invalid_key_id", "")
|
||||
return
|
||||
}
|
||||
res := db.Where("id = ? AND org_id = ? AND scope = ?", kid, oid, "org").Delete(&models.APIKey{})
|
||||
if res.Error != nil {
|
||||
utils.WriteError(w, 500, "db_error", res.Error.Error())
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
utils.WriteError(w, 404, "not_found", "key not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}
|
||||
388
internal/handlers/servers.go
Normal file
388
internal/handlers/servers.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ListServers godoc
|
||||
// @ID ListServers
|
||||
// @Summary List servers (org scoped)
|
||||
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
|
||||
// @Tags Servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
|
||||
// @Param role query string false "Filter by role"
|
||||
// @Success 200 {array} dto.ServerResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list servers"
|
||||
// @Router /servers [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func ListServers(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
q := db.Where("organization_id = ?", orgID)
|
||||
|
||||
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
|
||||
if !validStatus(s) {
|
||||
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
|
||||
return
|
||||
}
|
||||
q = q.Where("status = ?", strings.ToLower(s))
|
||||
}
|
||||
|
||||
if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
|
||||
q = q.Where("role = ?", role)
|
||||
}
|
||||
|
||||
var rows []models.Server
|
||||
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list servers")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.ServerResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, dto.ServerResponse{
|
||||
ID: row.ID,
|
||||
OrganizationID: row.OrganizationID,
|
||||
Hostname: row.Hostname,
|
||||
PublicIPAddress: row.PublicIPAddress,
|
||||
PrivateIPAddress: row.PrivateIPAddress,
|
||||
SSHUser: row.SSHUser,
|
||||
SshKeyID: row.SshKeyID,
|
||||
Role: row.Role,
|
||||
Status: row.Status,
|
||||
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// GetServer godoc
|
||||
// @ID GetServer
|
||||
// @Summary Get server by ID (org scoped)
|
||||
// @Description Returns one server in the given organization.
|
||||
// @Tags Servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Server ID (UUID)"
|
||||
// @Success 200 {object} dto.ServerResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /servers/{id} [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func GetServer(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var row models.Server
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateServer godoc
|
||||
// @ID CreateServer
|
||||
// @Summary Create server (org scoped)
|
||||
// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
|
||||
// @Tags Servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param body body dto.CreateServerRequest true "Server payload"
|
||||
// @Success 201 {object} dto.ServerResponse
|
||||
// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "create failed"
|
||||
// @Router /servers [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func CreateServer(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateServerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
||||
req.Status = strings.ToLower(strings.TrimSpace(req.Status))
|
||||
pub := strings.TrimSpace(req.PublicIPAddress)
|
||||
|
||||
if req.PrivateIPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "private_ip_address, ssh_user, ssh_key_id and role are required")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "" && !validStatus(req.Status) {
|
||||
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role == "bastion" && pub == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
|
||||
return
|
||||
}
|
||||
|
||||
keyID, err := uuid.Parse(req.SshKeyID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
|
||||
return
|
||||
}
|
||||
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
|
||||
return
|
||||
}
|
||||
|
||||
var publicPtr *string
|
||||
if pub != "" {
|
||||
publicPtr = &pub
|
||||
}
|
||||
|
||||
s := models.Server{
|
||||
OrganizationID: orgID,
|
||||
Hostname: req.Hostname,
|
||||
PublicIPAddress: publicPtr,
|
||||
PrivateIPAddress: req.PrivateIPAddress,
|
||||
SSHUser: req.SSHUser,
|
||||
SshKeyID: keyID,
|
||||
Role: req.Role,
|
||||
Status: "pending",
|
||||
}
|
||||
if req.Status != "" {
|
||||
s.Status = strings.ToLower(req.Status)
|
||||
}
|
||||
|
||||
if err := db.Create(&s).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create server")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusCreated, s)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateServer godoc
|
||||
// @ID UpdateServer
|
||||
// @Summary Update server (org scoped)
|
||||
// @Description Partially update fields; changing ssh_key_id validates ownership.
|
||||
// @Tags Servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Server ID (UUID)"
|
||||
// @Param body body dto.UpdateServerRequest true "Fields to update"
|
||||
// @Success 200 {object} dto.ServerResponse
|
||||
// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "update failed"
|
||||
// @Router /servers/{id} [patch]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func UpdateServer(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var server models.Server
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateServerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
next := server
|
||||
|
||||
if req.Hostname != nil {
|
||||
next.Hostname = *req.Hostname
|
||||
}
|
||||
if req.PrivateIPAddress != nil {
|
||||
next.PrivateIPAddress = *req.PrivateIPAddress
|
||||
}
|
||||
if req.PublicIPAddress != nil {
|
||||
next.PublicIPAddress = req.PublicIPAddress
|
||||
}
|
||||
if req.SSHUser != nil {
|
||||
next.SSHUser = *req.SSHUser
|
||||
}
|
||||
if req.Role != nil {
|
||||
next.Role = *req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
st := strings.ToLower(strings.TrimSpace(*req.Status))
|
||||
if !validStatus(st) {
|
||||
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
|
||||
return
|
||||
}
|
||||
next.Status = st
|
||||
}
|
||||
if req.SshKeyID != nil {
|
||||
keyID, err := uuid.Parse(*req.SshKeyID)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
|
||||
return
|
||||
}
|
||||
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
|
||||
return
|
||||
}
|
||||
next.SshKeyID = keyID
|
||||
}
|
||||
|
||||
if strings.EqualFold(next.Role, "bastion") &&
|
||||
(next.PublicIPAddress == nil || strings.TrimSpace(*next.PublicIPAddress) == "") {
|
||||
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Save(&next).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to update server")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, server)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteServer godoc
|
||||
// @ID DeleteServer
|
||||
// @Summary Delete server (org scoped)
|
||||
// @Description Permanently deletes the server.
|
||||
// @Tags Servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Server ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "delete failed"
|
||||
// @Router /servers/{id} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func DeleteServer(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&models.Server{}).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Server{}).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete server")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func validStatus(status string) bool {
|
||||
switch strings.ToLower(status) {
|
||||
case "pending", "provisioning", "ready", "failed", "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ensureKeyBelongsToOrg(orgID, keyID uuid.UUID, db *gorm.DB) error {
|
||||
var k models.SshKey
|
||||
if err := db.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("ssh key not found for this organization")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
553
internal/handlers/ssh.go
Normal file
553
internal/handlers/ssh.go
Normal file
@@ -0,0 +1,553 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListPublicSshKeys godoc
|
||||
// @ID ListPublicSshKeys
|
||||
// @Summary List ssh keys (org scoped)
|
||||
// @Description Returns ssh keys for the organization in X-Org-ID.
|
||||
// @Tags Ssh
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Success 200 {array} dto.SshResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list keys"
|
||||
// @Router /ssh [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func ListPublicSshKeys(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var rows []models.SshKey
|
||||
if err := db.Where("organization_id = ?", orgID).Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list ssh keys")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.SshResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, dto.SshResponse{
|
||||
ID: row.ID,
|
||||
OrganizationID: row.OrganizationID,
|
||||
Name: row.Name,
|
||||
PublicKey: row.PublicKey,
|
||||
Fingerprint: row.Fingerprint,
|
||||
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSSHKey
|
||||
// @ID CreateSSHKey
|
||||
// @Summary Create ssh keypair (org scoped)
|
||||
// @Description Generates an RSA or ED25519 keypair, saves it, and returns metadata. For RSA you may set bits (2048/3072/4096). Default is 4096. ED25519 ignores bits.
|
||||
// @Tags Ssh
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param body body dto.CreateSSHRequest true "Key generation options"
|
||||
// @Success 201 {object} dto.SshResponse
|
||||
// @Failure 400 {string} string "invalid json / invalid bits"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "generation/create failed"
|
||||
// @Router /ssh [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateSSHRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||
return
|
||||
}
|
||||
|
||||
keyType := "rsa"
|
||||
if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
|
||||
keyType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||
}
|
||||
|
||||
if keyType != "rsa" && keyType != "ed25519" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_type", "invalid type (rsa|ed25519)")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
privPEM string
|
||||
pubAuth string
|
||||
err error
|
||||
)
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
bits := 4096
|
||||
if req.Bits != nil {
|
||||
if !allowedBits(*req.Bits) {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_bits", "invalid bits (allowed: 2048, 3072, 4096)")
|
||||
return
|
||||
}
|
||||
bits = *req.Bits
|
||||
}
|
||||
privPEM, pubAuth, err = GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
|
||||
|
||||
case "ed25519":
|
||||
if req.Bits != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_bits_for_type", "bits is only valid for RSA")
|
||||
return
|
||||
}
|
||||
privPEM, pubAuth, err = GenerateEd25519PEMAndAuthorized(strings.TrimSpace(req.Comment))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "keygen_failure", "key generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
cipher, iv, tag, err := utils.EncryptForOrg(orgID, []byte(privPEM), db)
|
||||
if err != nil {
|
||||
http.Error(w, "encryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "ssh_failure", "ssh public key parsing failed")
|
||||
return
|
||||
}
|
||||
|
||||
fp := ssh.FingerprintSHA256(parsed)
|
||||
|
||||
key := models.SshKey{
|
||||
OrganizationID: orgID,
|
||||
Name: req.Name,
|
||||
PublicKey: pubAuth,
|
||||
EncryptedPrivateKey: cipher,
|
||||
PrivateIV: iv,
|
||||
PrivateTag: tag,
|
||||
Fingerprint: fp,
|
||||
}
|
||||
|
||||
if err := db.Create(&key).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusCreated, dto.SshResponse{
|
||||
ID: key.ID,
|
||||
OrganizationID: key.OrganizationID,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetSSHKey godoc
|
||||
// @ID GetSSHKey
|
||||
// @Summary Get ssh key by ID (org scoped)
|
||||
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
|
||||
// @Tags Ssh
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "SSH Key ID (UUID)"
|
||||
// @Param reveal query bool false "Reveal private key PEM"
|
||||
// @Success 200 {object} dto.SshResponse
|
||||
// @Success 200 {object} dto.SshRevealResponse "When reveal=true"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /ssh/{id} [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func GetSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
|
||||
return
|
||||
}
|
||||
|
||||
var key models.SshKey
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&key).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("reveal") != "true" {
|
||||
utils.WriteJSON(w, http.StatusOK, dto.SshResponse{
|
||||
ID: key.ID,
|
||||
OrganizationID: key.OrganizationID,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, dto.SshRevealResponse{
|
||||
SshResponse: dto.SshResponse{
|
||||
ID: key.ID,
|
||||
OrganizationID: key.OrganizationID,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
},
|
||||
PrivateKey: plain,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSSHKey godoc
|
||||
// @ID DeleteSSHKey
|
||||
// @Summary Delete ssh keypair (org scoped)
|
||||
// @Description Permanently deletes a keypair.
|
||||
// @Tags Ssh
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "SSH Key ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "delete failed"
|
||||
// @Router /ssh/{id} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func DeleteSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
|
||||
Delete(&models.SshKey{}).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete ssh key")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadSSHKey godoc
|
||||
// @ID DownloadSSHKey
|
||||
// @Summary Download ssh key files by ID (org scoped)
|
||||
// @Description Download `part=public|private|both` of the keypair. `both` returns a zip file.
|
||||
// @Tags Ssh
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "SSH Key ID (UUID)"
|
||||
// @Param part query string true "Which part to download" Enums(public,private,both)
|
||||
// @Success 200 {string} string "file content"
|
||||
// @Failure 400 {string} string "invalid id / invalid part"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "download failed"
|
||||
// @Router /ssh/{id}/download [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
|
||||
return
|
||||
}
|
||||
|
||||
var key models.SshKey
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
|
||||
First(&key).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
part := strings.ToLower(r.URL.Query().Get("part"))
|
||||
if part == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
|
||||
return
|
||||
}
|
||||
|
||||
mode := strings.ToLower(r.URL.Query().Get("mode"))
|
||||
if mode != "" && mode != "json" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (json|attachment[default])")
|
||||
return
|
||||
}
|
||||
|
||||
if mode == "json" {
|
||||
resp := dto.SshMaterialJSON{
|
||||
ID: key.ID.String(),
|
||||
Name: key.Name,
|
||||
Fingerprint: key.Fingerprint,
|
||||
}
|
||||
switch part {
|
||||
case "public":
|
||||
pub := key.PublicKey
|
||||
resp.PublicKey = &pub
|
||||
resp.Filenames = []string{fmt.Sprintf("id_rsa_%s.pub", key.ID.String())}
|
||||
utils.WriteJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
|
||||
case "private":
|
||||
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
|
||||
return
|
||||
}
|
||||
resp.PrivatePEM = &plain
|
||||
resp.Filenames = []string{fmt.Sprintf("id_rsa_%s.pem", key.ID.String())}
|
||||
utils.WriteJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
|
||||
case "both":
|
||||
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pem", key.ID.String()), []byte(plain), zw)
|
||||
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
|
||||
_ = zw.Close()
|
||||
|
||||
b64 := utils.EncodeB64(buf.Bytes())
|
||||
resp.ZipBase64 = &b64
|
||||
resp.Filenames = []string{
|
||||
fmt.Sprintf("id_rsa_%s.zip", key.ID.String()),
|
||||
fmt.Sprintf("id_rsa_%s.pem", key.ID.String()),
|
||||
fmt.Sprintf("id_rsa_%s.pub", key.ID.String()),
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
|
||||
default:
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
prefix := keyFilenamePrefix(key.PublicKey)
|
||||
|
||||
switch part {
|
||||
case "public":
|
||||
filename := fmt.Sprintf("%s_%s.pub", prefix, key.ID.String())
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
_, _ = w.Write([]byte(key.PublicKey))
|
||||
return
|
||||
|
||||
case "private":
|
||||
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
|
||||
return
|
||||
}
|
||||
filename := fmt.Sprintf("%s_%s.pem", prefix, key.ID.String())
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
_, _ = w.Write([]byte(plain))
|
||||
return
|
||||
|
||||
case "both":
|
||||
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
_ = toZipFile(fmt.Sprintf("%s_%s.pem", prefix, key.ID.String()), []byte(plain), zw)
|
||||
_ = toZipFile(fmt.Sprintf("%s_%s.pub", prefix, key.ID.String()), []byte(key.PublicKey), zw)
|
||||
_ = zw.Close()
|
||||
|
||||
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
return
|
||||
|
||||
default:
|
||||
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func allowedBits(b int) bool {
|
||||
return b == 2048 || b == 3072 || b == 4096
|
||||
}
|
||||
|
||||
func GenerateRSA(bits int) (*rsa.PrivateKey, error) {
|
||||
return rsa.GenerateKey(rand.Reader, bits)
|
||||
}
|
||||
|
||||
func RSAPrivateToPEMAndAuthorized(priv *rsa.PrivateKey, comment string) (privPEM string, authorized string, err error) {
|
||||
der := x509.MarshalPKCS1PrivateKey(priv)
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
|
||||
var buf bytes.Buffer
|
||||
if err = pem.Encode(&buf, block); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pub, err := ssh.NewPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
|
||||
comment = strings.TrimSpace(comment)
|
||||
if comment != "" {
|
||||
auth += " " + comment
|
||||
}
|
||||
return buf.String(), auth, nil
|
||||
}
|
||||
|
||||
func GenerateRSAPEMAndAuthorized(bits int, comment string) (string, string, error) {
|
||||
priv, err := GenerateRSA(bits)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return RSAPrivateToPEMAndAuthorized(priv, comment)
|
||||
}
|
||||
|
||||
func toZipFile(filename string, content []byte, zw *zip.Writer) error {
|
||||
f, err := zw.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func keyFilenamePrefix(pubAuth string) string {
|
||||
// OpenSSH authorized keys start with the algorithm name
|
||||
if strings.HasPrefix(pubAuth, "ssh-ed25519 ") {
|
||||
return "id_ed25519"
|
||||
}
|
||||
// default to RSA
|
||||
return "id_rsa"
|
||||
}
|
||||
|
||||
func GenerateEd25519PEMAndAuthorized(comment string) (privPEM string, authorized string, err error) {
|
||||
// Generate ed25519 keypair
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Private: PKCS#8 PEM
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
|
||||
var buf bytes.Buffer
|
||||
if err := pem.Encode(&buf, block); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Public: OpenSSH authorized_key
|
||||
sshPub, err := ssh.NewPublicKey(ed25519.PublicKey(pub))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
|
||||
comment = strings.TrimSpace(comment)
|
||||
if comment != "" {
|
||||
auth += " " + comment
|
||||
}
|
||||
|
||||
return buf.String(), auth, nil
|
||||
}
|
||||
335
internal/handlers/taints.go
Normal file
335
internal/handlers/taints.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ListTaints godoc
|
||||
// @ID ListTaints
|
||||
// @Summary List node pool taints (org scoped)
|
||||
// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
|
||||
// @Tags Taints
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param key query string false "Exact key"
|
||||
// @Param value query string false "Exact value"
|
||||
// @Param q query string false "key contains (case-insensitive)"
|
||||
// @Success 200 {array} dto.TaintResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list node taints"
|
||||
// @Router /taints [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func ListTaints(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
q := db.Where("organization_id = ?", orgID)
|
||||
|
||||
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
|
||||
q = q.Where(`key = ?`, key)
|
||||
}
|
||||
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
|
||||
q = q.Where(`value = ?`, val)
|
||||
}
|
||||
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
|
||||
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
|
||||
}
|
||||
|
||||
var rows []models.Taint
|
||||
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.TaintResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, dto.TaintResponse{
|
||||
ID: row.ID,
|
||||
Key: row.Key,
|
||||
Value: row.Value,
|
||||
Effect: row.Effect,
|
||||
})
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaint godoc
|
||||
// @ID GetTaint
|
||||
// @Summary Get node taint by ID (org scoped)
|
||||
// @Tags Taints
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Taint ID (UUID)"
|
||||
// @Success 200 {object} dto.TaintResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /taints/{id} [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func GetTaint(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
var row models.Taint
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
out := dto.TaintResponse{
|
||||
ID: row.ID,
|
||||
Key: row.Key,
|
||||
Value: row.Value,
|
||||
Effect: row.Effect,
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTaint godoc
|
||||
// @ID CreateTaint
|
||||
// @Summary Create node taint (org scoped)
|
||||
// @Description Creates a taint.
|
||||
// @Tags Taints
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param body body dto.CreateTaintRequest true "Taint payload"
|
||||
// @Success 201 {object} dto.TaintResponse
|
||||
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "create failed"
|
||||
// @Router /taints [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func CreateTaint(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTaintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Value = strings.TrimSpace(req.Value)
|
||||
req.Effect = strings.TrimSpace(req.Effect)
|
||||
|
||||
if req.Key == "" || req.Value == "" || req.Effect == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value/effect")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := allowedEffects[req.Effect]; !ok {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
|
||||
return
|
||||
}
|
||||
|
||||
t := models.Taint{
|
||||
OrganizationID: orgID,
|
||||
Key: req.Key,
|
||||
Value: req.Value,
|
||||
Effect: req.Effect,
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
out := dto.TaintResponse{
|
||||
ID: t.ID,
|
||||
Key: t.Key,
|
||||
Value: t.Value,
|
||||
Effect: t.Effect,
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTaint godoc
|
||||
// @ID UpdateTaint
|
||||
// @Summary Update node taint (org scoped)
|
||||
// @Description Partially update taint fields.
|
||||
// @Tags Taints
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Taint ID (UUID)"
|
||||
// @Param body body dto.UpdateTaintRequest true "Fields to update"
|
||||
// @Success 200 {object} dto.TaintResponse
|
||||
// @Failure 400 {string} string "invalid id / invalid json"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "update failed"
|
||||
// @Router /taints/{id} [patch]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func UpdateTaint(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
var t models.Taint
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&t).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateTaintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
next := t
|
||||
|
||||
if req.Key != nil {
|
||||
next.Key = strings.TrimSpace(*req.Key)
|
||||
}
|
||||
if req.Value != nil {
|
||||
next.Value = strings.TrimSpace(*req.Value)
|
||||
}
|
||||
if req.Effect != nil {
|
||||
e := strings.TrimSpace(*req.Effect)
|
||||
if e == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing effect")
|
||||
return
|
||||
}
|
||||
if _, ok := allowedEffects[e]; !ok {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
|
||||
return
|
||||
}
|
||||
next.Effect = e
|
||||
}
|
||||
|
||||
if err := db.Save(&next).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
out := dto.TaintResponse{
|
||||
ID: next.ID,
|
||||
Key: next.Key,
|
||||
Value: next.Value,
|
||||
Effect: next.Effect,
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteTaint godoc
|
||||
// @ID DeleteTaint
|
||||
// @Summary Delete taint (org scoped)
|
||||
// @Description Permanently deletes the taint.
|
||||
// @Tags Taints
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Taint ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "delete failed"
|
||||
// @Router /taints/{id} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func DeleteTaint(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
var row models.Taint
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Delete(&row).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
var allowedEffects = map[string]struct{}{
|
||||
"NoSchedule": {},
|
||||
"PreferNoSchedule": {},
|
||||
"NoExecute": {},
|
||||
}
|
||||
35
internal/keys/base64util.go
Normal file
35
internal/keys/base64util.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package keys
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func decode32ByteKey(s string) ([]byte, error) {
|
||||
try := func(enc *base64.Encoding, v string) ([]byte, bool) {
|
||||
if b, err := enc.DecodeString(v); err == nil && len(b) == 32 {
|
||||
return b, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Try raw (no padding) variants first
|
||||
if b, ok := try(base64.RawURLEncoding, s); ok {
|
||||
return b, nil
|
||||
}
|
||||
if b, ok := try(base64.RawStdEncoding, s); ok {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Try padded variants (add padding if missing)
|
||||
pad := func(v string) string { return v + strings.Repeat("=", (4-len(v)%4)%4) }
|
||||
if b, ok := try(base64.URLEncoding, pad(s)); ok {
|
||||
return b, nil
|
||||
}
|
||||
if b, ok := try(base64.StdEncoding, pad(s)); ok {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("key must be 32 bytes in base64/base64url")
|
||||
}
|
||||
5
internal/keys/export.go
Normal file
5
internal/keys/export.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package keys
|
||||
|
||||
func Decrypt(encKeyB64, enc string) ([]byte, error) {
|
||||
return decryptAESGCM(encKeyB64, enc)
|
||||
}
|
||||
149
internal/keys/keys.go
Normal file
149
internal/keys/keys.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package keys
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GenOpts struct {
|
||||
Alg string // "RS256"|"RS384"|"RS512"|"EdDSA"
|
||||
Bits int // RSA bits (2048/3072/4096). ignored for EdDSA
|
||||
KID string // optional; if empty we generate one
|
||||
NBF *time.Time
|
||||
EXP *time.Time
|
||||
}
|
||||
|
||||
func GenerateAndStore(db *gorm.DB, encKeyB64 string, opts GenOpts) (*models.SigningKey, error) {
|
||||
if opts.KID == "" {
|
||||
opts.KID = uuid.NewString()
|
||||
}
|
||||
|
||||
var pubPEM, privPEM []byte
|
||||
var alg = opts.Alg
|
||||
|
||||
switch alg {
|
||||
case "RS256", "RS384", "RS512":
|
||||
if opts.Bits == 0 {
|
||||
opts.Bits = 3072
|
||||
}
|
||||
priv, err := rsa.GenerateKey(rand.Reader, opts.Bits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privDER := x509.MarshalPKCS1PrivateKey(priv)
|
||||
privPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
|
||||
|
||||
pubDER := x509.MarshalPKCS1PublicKey(&priv.PublicKey)
|
||||
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubDER})
|
||||
|
||||
case "EdDSA":
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
|
||||
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported alg: %s", alg)
|
||||
}
|
||||
|
||||
privateOut := string(privPEM)
|
||||
if encKeyB64 != "" {
|
||||
enc, err := encryptAESGCM(encKeyB64, privPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privateOut = enc
|
||||
}
|
||||
|
||||
rec := models.SigningKey{
|
||||
Kid: opts.KID,
|
||||
Alg: alg,
|
||||
Use: "sig",
|
||||
IsActive: true,
|
||||
PublicPEM: string(pubPEM),
|
||||
PrivatePEM: privateOut,
|
||||
NotBefore: opts.NBF,
|
||||
ExpiresAt: opts.EXP,
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func encryptAESGCM(b64 string, plaintext []byte) (string, error) {
|
||||
key, err := decode32ByteKey(b64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("JWT_PRIVATE_ENC_KEY must be 32 bytes (base64url)")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := aead.Seal(nonce, nonce, plaintext, nil)
|
||||
return "enc:aesgcm:" + base64.RawStdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func decryptAESGCM(b64 string, enc string) ([]byte, error) {
|
||||
if !bytes.HasPrefix([]byte(enc), []byte("enc:aesgcm:")) {
|
||||
return nil, errors.New("not encrypted")
|
||||
}
|
||||
key, err := decode32ByteKey(b64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob, err := base64.RawStdEncoding.DecodeString(enc[len("enc:aesgcm:"):])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonceSize := aead.NonceSize()
|
||||
if len(blob) < nonceSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ct := blob[:nonceSize], blob[nonceSize:]
|
||||
return aead.Open(nil, nonce, ct, nil)
|
||||
}
|
||||
23
internal/models/account.go
Normal file
23
internal/models/account.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Provider string `gorm:"not null" json:"provider"`
|
||||
Subject string `gorm:"not null" json:"subject"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
|
||||
Profile datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"profile"`
|
||||
SecretHash *string `json:"-"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
23
internal/models/api_key.go
Normal file
23
internal/models/api_key.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
Name string `gorm:"not null;default:''" json:"name"`
|
||||
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
Scope string `gorm:"not null;default:''" json:"scope"`
|
||||
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
|
||||
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
|
||||
SecretHash *string `json:"-"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
||||
Prefix *string `json:"prefix,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
15
internal/models/master_key.go
Normal file
15
internal/models/master_key.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type MasterKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||
Key string `gorm:"not null"`
|
||||
IsActive bool `gorm:"default:true"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"`
|
||||
}
|
||||
18
internal/models/membership.go
Normal file
18
internal/models/membership.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Membership struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
OrganizationID uuid.UUID `gorm:"index;not null" json:"org_id" format:"uuid"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"-"`
|
||||
Role string `gorm:"not null;default:'member'" json:"role"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
21
internal/models/node_pool.go
Normal file
21
internal/models/node_pool.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type NodePool struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
|
||||
//Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
|
||||
//Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
|
||||
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
|
||||
//Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
20
internal/models/organization-key.go
Normal file
20
internal/models/organization-key.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type OrganizationKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
MasterKeyID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
MasterKey MasterKey `gorm:"foreignKey:MasterKeyID;constraint:OnDelete:CASCADE" json:"master_key"`
|
||||
EncryptedKey string `gorm:"not null"`
|
||||
IV string `gorm:"not null"`
|
||||
Tag string `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
16
internal/models/organization.go
Normal file
16
internal/models/organization.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Domain *string `gorm:"index" json:"domain"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
17
internal/models/refresh_token.go
Normal file
17
internal/models/refresh_token.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type RefreshToken struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"index;not null" json:"user_id"`
|
||||
FamilyID uuid.UUID `gorm:"type:uuid;index;not null" json:"family_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
|
||||
}
|
||||
36
internal/models/server.go
Normal file
36
internal/models/server.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Hostname string `json:"hostname"`
|
||||
PublicIPAddress *string `json:"public_ip_address,omitempty"`
|
||||
PrivateIPAddress string `gorm:"not null" json:"private_ip_address"`
|
||||
SSHUser string `gorm:"not null" json:"ssh_user"`
|
||||
SshKeyID uuid.UUID `gorm:"type:uuid;not null" json:"ssh_key_id"`
|
||||
SshKey SshKey `gorm:"foreignKey:SshKeyID" json:"ssh_key"`
|
||||
Role string `gorm:"not null" json:"role"` // e.g., "master", "worker", "bastion"
|
||||
Status string `gorm:"default:'pending'" json:"status"` // pending, provisioning, ready, failed
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
func (s *Server) BeforeSave(tx *gorm.DB) error {
|
||||
role := strings.ToLower(strings.TrimSpace(s.Role))
|
||||
if role == "bastion" {
|
||||
if s.PublicIPAddress == nil || strings.TrimSpace(*s.PublicIPAddress) == "" {
|
||||
return errors.New("public_ip_address is required for role=bastion")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
22
internal/models/signing_key.go
Normal file
22
internal/models/signing_key.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SigningKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Kid string `gorm:"uniqueIndex;not null" json:"kid"` // key id (header 'kid')
|
||||
Alg string `gorm:"not null" json:"alg"` // RS256|RS384|RS512|EdDSA
|
||||
Use string `gorm:"not null;default:'sig'" json:"use"` // "sig"
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
PublicPEM string `gorm:"type:text;not null" json:"-"`
|
||||
PrivatePEM string `gorm:"type:text;not null" json:"-"`
|
||||
NotBefore *time.Time `json:"-"`
|
||||
ExpiresAt *time.Time `json:"-"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
|
||||
RotatedFrom *uuid.UUID `json:"-"` // previous key id, if any
|
||||
}
|
||||
21
internal/models/ssh-key.go
Normal file
21
internal/models/ssh-key.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SshKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
PublicKey string `gorm:"not null"`
|
||||
EncryptedPrivateKey string `gorm:"not null"`
|
||||
PrivateIV string `gorm:"not null"`
|
||||
PrivateTag string `gorm:"not null"`
|
||||
Fingerprint string `gorm:"not null;index" json:"fingerprint"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
18
internal/models/taint.go
Normal file
18
internal/models/taint.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Taint struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Key string `gorm:"not null" json:"key"`
|
||||
Value string `gorm:"not null" json:"value"`
|
||||
Effect string `gorm:"not null" json:"effect"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
18
internal/models/user.go
Normal file
18
internal/models/user.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
PrimaryEmail *string `json:"primary_email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
IsDisabled bool `json:"is_disabled"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
19
internal/models/user_email.go
Normal file
19
internal/models/user_email.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserEmail struct {
|
||||
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
Email string `gorm:"not null" json:"email"`
|
||||
IsVerified bool `gorm:"not null;default:false" json:"is_verified"`
|
||||
IsPrimary bool `gorm:"not null;default:false" json:"is_primary"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
85
internal/utils/crypto.go
Normal file
85
internal/utils/crypto.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoActiveMasterKey = errors.New("no active master key found")
|
||||
ErrInvalidOrgID = errors.New("invalid organization ID")
|
||||
ErrCredentialNotFound = errors.New("credential not found")
|
||||
ErrInvalidMasterKeyLen = errors.New("invalid master key length")
|
||||
)
|
||||
|
||||
func randomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return nil, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func encryptAESGCM(plaintext, key []byte) (cipherNoTag, iv, tag []byte, _ error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
if gcm.NonceSize() != 12 {
|
||||
return nil, nil, nil, fmt.Errorf("unexpected nonce size: %d", gcm.NonceSize())
|
||||
}
|
||||
|
||||
iv, err = randomBytes(gcm.NonceSize())
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Go’s GCM returns ciphertext||tag, with 16-byte tag.
|
||||
cipherWithTag := gcm.Seal(nil, iv, plaintext, nil)
|
||||
if len(cipherWithTag) < 16 {
|
||||
return nil, nil, nil, errors.New("ciphertext too short")
|
||||
}
|
||||
tagLen := 16
|
||||
cipherNoTag = cipherWithTag[:len(cipherWithTag)-tagLen]
|
||||
tag = cipherWithTag[len(cipherWithTag)-tagLen:]
|
||||
return cipherNoTag, iv, tag, nil
|
||||
}
|
||||
|
||||
func decryptAESGCM(cipherNoTag, key, iv, tag []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
if gcm.NonceSize() != len(iv) {
|
||||
return nil, fmt.Errorf("bad nonce size: %d", len(iv))
|
||||
}
|
||||
// Reattach tag
|
||||
cipherWithTag := append(append([]byte{}, cipherNoTag...), tag...)
|
||||
plain, err := gcm.Open(nil, iv, cipherWithTag, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm open: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func EncodeB64(b []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func DecodeB64(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
27
internal/utils/helpers.go
Normal file
27
internal/utils/helpers.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorResponse is a simple, reusable error payload.
|
||||
// swagger:model ErrorResponse
|
||||
type ErrorResponse struct {
|
||||
// A machine-readable error code, e.g. "validation_error"
|
||||
// example: validation_error
|
||||
Code string `json:"code"`
|
||||
// Human-readable message
|
||||
// example: slug is required
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, status int, code, msg string) {
|
||||
WriteJSON(w, status, ErrorResponse{Code: code, Message: msg})
|
||||
}
|
||||
107
internal/utils/keys.go
Normal file
107
internal/utils/keys.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func getMasterKey(db *gorm.DB) ([]byte, error) {
|
||||
var mk models.MasterKey
|
||||
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNoActiveMasterKey
|
||||
}
|
||||
return nil, fmt.Errorf("querying master key: %w", err)
|
||||
}
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(mk.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding master key: %w", err)
|
||||
}
|
||||
if len(keyBytes) != 32 {
|
||||
return nil, fmt.Errorf("%w: got %d, want 32", ErrInvalidMasterKeyLen, len(keyBytes))
|
||||
}
|
||||
return keyBytes, nil
|
||||
}
|
||||
|
||||
func getOrCreateTenantKey(orgID string, db *gorm.DB) ([]byte, error) {
|
||||
var orgKey models.OrganizationKey
|
||||
err := db.Where("organization_id = ?", orgID).First(&orgKey).Error
|
||||
if err == nil {
|
||||
encKeyB64 := orgKey.EncryptedKey
|
||||
ivB64 := orgKey.IV
|
||||
tagB64 := orgKey.Tag
|
||||
|
||||
encryptedKey, err := DecodeB64(encKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode enc key: %w", err)
|
||||
}
|
||||
|
||||
iv, err := DecodeB64(ivB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode iv: %w", err)
|
||||
}
|
||||
|
||||
tag, err := DecodeB64(tagB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode tag: %w", err)
|
||||
}
|
||||
|
||||
masterKey, err := getMasterKey(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decryptAESGCM(encryptedKey, masterKey, iv, tag)
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new tenant key and wrap with the current master key
|
||||
orgUUID, err := uuid.Parse(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidOrgID, err)
|
||||
}
|
||||
|
||||
tenantKey, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant key gen: %w", err)
|
||||
}
|
||||
|
||||
masterKey, err := getMasterKey(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encrypted, iv, tag, err := encryptAESGCM(tenantKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wrap tenant key: %w", err)
|
||||
}
|
||||
|
||||
var mk models.MasterKey
|
||||
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNoActiveMasterKey
|
||||
}
|
||||
return nil, fmt.Errorf("querying master key: %w", err)
|
||||
}
|
||||
|
||||
orgKey = models.OrganizationKey{
|
||||
OrganizationID: orgUUID,
|
||||
MasterKeyID: mk.ID,
|
||||
EncryptedKey: EncodeB64(encrypted),
|
||||
IV: EncodeB64(iv),
|
||||
Tag: EncodeB64(tag),
|
||||
}
|
||||
if err := db.Create(&orgKey).Error; err != nil {
|
||||
return nil, fmt.Errorf("persist org key: %w", err)
|
||||
}
|
||||
return tenantKey, nil
|
||||
}
|
||||
44
internal/utils/org-crypto.go
Normal file
44
internal/utils/org-crypto.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func EncryptForOrg(orgID uuid.UUID, plaintext []byte, db *gorm.DB) (cipherB64, ivB64, tagB64 string, err error) {
|
||||
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
ct, iv, tag, err := encryptAESGCM(plaintext, tenantKey)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return EncodeB64(ct), EncodeB64(iv), EncodeB64(tag), nil
|
||||
}
|
||||
|
||||
func DecryptForOrg(orgID uuid.UUID, cipherB64, ivB64, tagB64 string, db *gorm.DB) (string, error) {
|
||||
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct, err := DecodeB64(cipherB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode cipher: %w", err)
|
||||
}
|
||||
iv, err := DecodeB64(ivB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode iv: %w", err)
|
||||
}
|
||||
tag, err := DecodeB64(tagB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode tag: %w", err)
|
||||
}
|
||||
plain, err := decryptAESGCM(ct, tenantKey, iv, tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
36
internal/version/version.go
Normal file
36
internal/version/version.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
Date = "unknown"
|
||||
BuiltBy = "local"
|
||||
)
|
||||
|
||||
func Info() string {
|
||||
v := fmt.Sprintf("Version: %s\nCommit: %s\nBuilt: %s\nBuiltBy: %s\nGo: %s %s/%s",
|
||||
Version, Commit, Date, BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
// Include VCS info from embedded build metadata (if available)
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs":
|
||||
v += fmt.Sprintf("\nVCS: %s", s.Value)
|
||||
case "vcs.revision":
|
||||
v += fmt.Sprintf("\nRevision: %s", s.Value)
|
||||
case "vcs.time":
|
||||
v += fmt.Sprintf("\nCommitTime: %s", s.Value)
|
||||
case "vcs.modified":
|
||||
v += fmt.Sprintf("\nModified: %s", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
16
internal/web/devproxy.go
Normal file
16
internal/web/devproxy.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func DevProxy(target string) (http.Handler, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := httputil.NewSingleHostReverseProxy(u)
|
||||
return p, nil
|
||||
}
|
||||
2
internal/web/dist/assets/index-BvUUUOIq.css
vendored
Normal file
2
internal/web/dist/assets/index-BvUUUOIq.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-BvUUUOIq.css.br
vendored
Normal file
BIN
internal/web/dist/assets/index-BvUUUOIq.css.br
vendored
Normal file
Binary file not shown.
BIN
internal/web/dist/assets/index-BvUUUOIq.css.gz
vendored
Normal file
BIN
internal/web/dist/assets/index-BvUUUOIq.css.gz
vendored
Normal file
Binary file not shown.
10
internal/web/dist/assets/index-CFwByDWI.js
vendored
Normal file
10
internal/web/dist/assets/index-CFwByDWI.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-CFwByDWI.js.br
vendored
Normal file
BIN
internal/web/dist/assets/index-CFwByDWI.js.br
vendored
Normal file
Binary file not shown.
BIN
internal/web/dist/assets/index-CFwByDWI.js.gz
vendored
Normal file
BIN
internal/web/dist/assets/index-CFwByDWI.js.gz
vendored
Normal file
Binary file not shown.
1
internal/web/dist/assets/index-CFwByDWI.js.map
vendored
Normal file
1
internal/web/dist/assets/index-CFwByDWI.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4
internal/web/dist/assets/react-BZmgNp9X.js
vendored
Normal file
4
internal/web/dist/assets/react-BZmgNp9X.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/react-BZmgNp9X.js.br
vendored
Normal file
BIN
internal/web/dist/assets/react-BZmgNp9X.js.br
vendored
Normal file
Binary file not shown.
BIN
internal/web/dist/assets/react-BZmgNp9X.js.gz
vendored
Normal file
BIN
internal/web/dist/assets/react-BZmgNp9X.js.gz
vendored
Normal file
Binary file not shown.
1
internal/web/dist/assets/react-BZmgNp9X.js.map
vendored
Normal file
1
internal/web/dist/assets/react-BZmgNp9X.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
15
internal/web/dist/index.html
vendored
Normal file
15
internal/web/dist/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ui</title>
|
||||
<script type="module" crossorigin src="/assets/index-CFwByDWI.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/react-BZmgNp9X.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BvUUUOIq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
internal/web/dist/index.html.br
vendored
Normal file
BIN
internal/web/dist/index.html.br
vendored
Normal file
Binary file not shown.
BIN
internal/web/dist/index.html.gz
vendored
Normal file
BIN
internal/web/dist/index.html.gz
vendored
Normal file
Binary file not shown.
1
internal/web/dist/vite.svg
vendored
Normal file
1
internal/web/dist/vite.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
239
internal/web/static.go
Normal file
239
internal/web/static.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NOTE: Vite outputs to web/dist with assets in dist/assets.
|
||||
// If you add more nested folders in the future, include them here too.
|
||||
|
||||
//go:embed dist
|
||||
var distFS embed.FS
|
||||
|
||||
// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
|
||||
type spaFileSystem struct {
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
func (s spaFileSystem) Open(name string) (fs.File, error) {
|
||||
// Normalize, strip leading slash
|
||||
if strings.HasPrefix(name, "/") {
|
||||
name = name[1:]
|
||||
}
|
||||
// Try exact file
|
||||
f, err := s.fs.Open(name)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// If the requested file doesn't exist, fall back to index.html for SPA routes
|
||||
// BUT only if it's not obviously a static asset extension
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg",
|
||||
".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz":
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
|
||||
return s.fs.Open("index.html")
|
||||
}
|
||||
|
||||
func newDistFS() (fs.FS, error) {
|
||||
return fs.Sub(distFS, "dist")
|
||||
}
|
||||
|
||||
// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
|
||||
func SPAHandler() (http.Handler, error) {
|
||||
sub, err := newDistFS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spa := spaFileSystem{fs: sub}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
r.URL.Path == "/api" ||
|
||||
strings.HasPrefix(r.URL.Path, "/swagger") ||
|
||||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if filePath == "" {
|
||||
filePath = "index.html"
|
||||
}
|
||||
|
||||
// Try compressed variants for assets and HTML
|
||||
// NOTE: we only change *Content-Encoding*; Content-Type derives from original ext
|
||||
// Always vary on Accept-Encoding
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
|
||||
enc := r.Header.Get("Accept-Encoding")
|
||||
if tryServeCompressed(w, r, spa, filePath, enc) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: normal open (or SPA fallback)
|
||||
f, err := spa.Open(filePath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if strings.HasSuffix(filePath, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
|
||||
info, _ := f.Stat()
|
||||
modTime := time.Now()
|
||||
if info != nil {
|
||||
modTime = info.ModTime()
|
||||
}
|
||||
http.ServeContent(w, r, filePath, modTime, file{f})
|
||||
}), nil
|
||||
}
|
||||
|
||||
func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool {
|
||||
wantsBR := strings.Contains(enc, "br")
|
||||
wantsGZ := strings.Contains(enc, "gzip")
|
||||
|
||||
type cand struct {
|
||||
logical string // MIME/type decision uses this (uncompressed name)
|
||||
physical string // actual file we open (with .br/.gz)
|
||||
enc string
|
||||
}
|
||||
|
||||
var cands []cand
|
||||
|
||||
// 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try)
|
||||
if wantsBR {
|
||||
cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"})
|
||||
}
|
||||
if wantsGZ {
|
||||
cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"})
|
||||
}
|
||||
|
||||
// 2) SPA route: fall back to compressed index.html
|
||||
if filepath.Ext(filePath) == "" {
|
||||
if wantsBR {
|
||||
cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"})
|
||||
}
|
||||
if wantsGZ {
|
||||
cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"})
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range cands {
|
||||
f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Cache headers
|
||||
if strings.HasSuffix(c.logical, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
|
||||
if ct := mimeByExt(path.Ext(c.logical)); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
w.Header().Set("Content-Encoding", c.enc)
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
|
||||
info, _ := f.Stat()
|
||||
modTime := time.Now()
|
||||
if info != nil {
|
||||
modTime = info.ModTime()
|
||||
}
|
||||
|
||||
// Serve the precompressed bytes
|
||||
http.ServeContent(w, r, c.physical, modTime, file{f})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool {
|
||||
cf := filePath + ext
|
||||
f, err := spa.Open(cf)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Set caching headers
|
||||
if strings.HasSuffix(filePath, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
// Preserve original content type by extension of *uncompressed* file
|
||||
if ct := mimeByExt(path.Ext(filePath)); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
w.Header().Set("Content-Encoding", encoding)
|
||||
|
||||
info, _ := f.Stat()
|
||||
modTime := time.Now()
|
||||
if info != nil {
|
||||
modTime = info.ModTime()
|
||||
}
|
||||
|
||||
// Serve the compressed bytes as an io.ReadSeeker if possible
|
||||
http.ServeContent(w, r, cf, modTime, file{f})
|
||||
return true
|
||||
}
|
||||
|
||||
func mimeByExt(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8"
|
||||
case ".js":
|
||||
return "application/javascript"
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
case ".woff2":
|
||||
return "font/woff2"
|
||||
case ".woff":
|
||||
return "font/woff"
|
||||
default:
|
||||
return "" // let Go sniff if empty
|
||||
}
|
||||
}
|
||||
|
||||
// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
|
||||
type file struct{ fs.File }
|
||||
|
||||
func (f file) Seek(offset int64, whence int) (int64, error) {
|
||||
if s, ok := f.File.(io.Seeker); ok {
|
||||
return s.Seek(offset, whence)
|
||||
}
|
||||
// Fallback: not seekable
|
||||
return 0, fs.ErrInvalid
|
||||
}
|
||||
Reference in New Issue
Block a user