feat: sdk migration in progress

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

477
internal/handlers/auth.go Normal file
View 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>`))
}

View 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..."`
}

View 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:"-"`
}

View 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"`
}

View 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"`
}

View 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
View 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
View 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)
}
}

View 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
View 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)
}
}

View 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
View 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
View 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": {},
}