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:
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": {},
|
||||
}
|
||||
Reference in New Issue
Block a user