feat: sdk migration in progress

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

136
.gitignore vendored Normal file
View File

@@ -0,0 +1,136 @@
# Created by https://www.toptal.com/developers/gitignore/api/go,react,intellij+all
# Edit at https://www.toptal.com/developers/gitignore?templates=go,react,intellij+all
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### react ###
.DS_*
*.log
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
psd
thumb
sketch
# End of https://www.toptal.com/developers/gitignore/api/go,react,intellij+all
.env
config.yaml
go_*_WhiteSpaceConflict/
notes.txt

277
Makefile Normal file
View File

@@ -0,0 +1,277 @@
# --- variables ---
GOCMD ?= go
GOINSTALL := $(GOCMD) install
BIN ?= autoglue
MAIN ?= main.go
UI_DIR ?= ui
UI_DEST_DIR ?= internal/web
# Module path (used for ldflags to internal/version)
GIT_HOST ?= github.com
GIT_USER ?= glueops
MODULE_PATH ?= $(GIT_HOST)/$(GIT_USER)/$(BIN)
# SDK / module settings (Go)
SDK_REPO ?= $(BIN)-sdk # repo name used for module path
SDK_OUTDIR ?= sdk/go # output directory (inside repo)
SDK_PKG ?= ${BIN} # package name inside the SDK
UI_SSG_ROUTES ?= /,/login,/docs,/pricing
# Go versioning (go.mod uses major.minor; youre on 1.25.3)
GO_VERSION ?= 1.25.3
# SDK / package settings (TypeScript)
SDK_TS_OUTDIR ?= sdk/ts
SDK_TS_GEN ?= typescript-fetch
SDK_TS_NPM_NAME ?= @glueops/$(SDK_REPO)
SDK_TS_NPM_VER ?= 0.1.0
SDK_TS_DIR := $(abspath $(SDK_TS_OUTDIR))
SDK_TS_PROPS ?= supportsES6=true,typescriptThreePlus=true,useSingleRequestParameter=true,withSeparateModelsAndApi=true,modelPropertyNaming=original,enumPropertyNaming=original,useUnionTypes=true
SDK_TS_PROPS_FLAGS := $(foreach p,$(subst , ,$(SDK_TS_PROPS)),-p $(p))
# Path for vendored UI SDK (absolute, path-safe)
SDK_TS_UI_OUTDIR ?= ui/src/sdk
SDK_TS_UI_DIR := $(abspath $(SDK_TS_UI_OUTDIR))
SWAG := $(shell command -v swag 2>/dev/null)
GMU := $(shell command -v go-mod-upgrade 2>/dev/null)
YARN := $(shell command -v yarn 2>/dev/null)
NPM := $(shell command -v npm 2>/dev/null)
OGC := $(shell command -v openapi-generator-cli 2>/dev/null || command -v openapi-generator 2>/dev/null)
BROTLI := $(shell command -v brotli 2>/dev/null)
GZIP := $(shell command -v gzip 2>/dev/null)
.DEFAULT_GOAL := build
# --- version metadata (ldflags) ---
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "none")
DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
BUILT_BY := $(shell whoami)
LDFLAGS := -X '$(MODULE_PATH)/internal/version.Version=$(VERSION)' \
-X '$(MODULE_PATH)/internal/version.Commit=$(COMMIT)' \
-X '$(MODULE_PATH)/internal/version.Date=$(DATE)' \
-X '$(MODULE_PATH)/internal/version.BuiltBy=$(BUILT_BY)'
# --- phony targets ---
.PHONY: all prepare ui-install ui-build ui swagger build clean fmt vet tidy upgrade \
sdk sdk-go sdk-ts sdk-ts-ui sdk-all worksync wire-sdk-replace help dev ui-compress \
print-version
# --- meta targets ---
all: build
prepare: fmt vet tidy upgrade
# --- go hygiene ---
fmt:
@$(GOCMD) fmt ./...
vet:
@$(GOCMD) vet ./...
tidy:
@$(GOCMD) mod tidy
upgrade:
@echo ">> Checking go-mod-upgrade..."
@if [ -z "$(GMU)" ]; then \
echo "Installing go-mod-upgrade..."; \
$(GOINSTALL) github.com/oligot/go-mod-upgrade@latest; \
fi
@go-mod-upgrade -f || true
# --- ui ---
ui-install:
@echo ">> Installing UI deps in $(UI_DIR)..."
@if [ -n "$(YARN)" ]; then \
cd $(UI_DIR) && yarn install --frozen-lockfile; \
elif [ -n "$(NPM)" ]; then \
cd $(UI_DIR) && npm ci; \
else \
echo "Error: neither yarn nor npm is installed." >&2; exit 1; \
fi
ui-build: ui-install
@echo ">> Building UI in $(UI_DIR)..."
@rm -rf $(UI_DEST_DIR)/dist
@if [ -n "$(YARN)" ]; then \
cd $(UI_DIR) && yarn build; \
else \
cd $(UI_DIR) && npm run build; \
fi
ui-compress: ui-build
@echo ">> Precompressing assets (brotli + gzip) in $(UI_DEST_DIR)/dist"
@if [ -n "$(BROTLI)" ]; then \
find "$(UI_DEST_DIR)/dist" -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) -print0 | \
xargs -0 -I{} brotli -f {}; \
else echo "brotli not found; skipping .br"; fi
@if [ -n "$(GZIP)" ]; then \
find "$(UI_DEST_DIR)/dist" -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) -print0 | \
xargs -0 -I{} gzip -kf {}; \
else echo "gzip not found; skipping .gz"; fi
ui: ui-compress
# --- swagger ---
swagger:
@echo ">> Generating Swagger docs..."
@if [ -z "$(SWAG)" ]; then \
echo "Installing swag..."; \
$(GOINSTALL) github.com/swaggo/swag/cmd/swag@latest; \
fi
@rm -rf docs/swagger.* docs/docs.go
@swag init -g $(MAIN) -o docs
# --- build ---
build: prepare ui swagger sdk-all
@echo ">> Building Go binary: $(BIN)"
@$(GOCMD) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) $(MAIN)
# Handy: print resolved version metadata
print-version:
@echo "VERSION = $(VERSION)"
@echo "COMMIT = $(COMMIT)"
@echo "DATE = $(DATE)"
@echo "BUILT_BY = $(BUILT_BY)"
@echo "LDFLAGS = $(LDFLAGS)"
# --- development ---
dev: ui-install swagger
@echo ">> Starting Vite (frontend) and Go API (backend) with dev env..."
@cd $(UI_DIR) && \
( \
if command -v yarn >/dev/null 2>&1; then \
yarn dev & \
elif command -v npm >/dev/null 2>&1; then \
npm run dev & \
else \
echo "Error: neither yarn nor npm is installed." >&2; exit 1; \
fi; \
cd .. && \
$(GOCMD) run . serve & \
wait \
)
# --- sdk generation (Go) ---
sdk-go: swagger
@echo ">> Generating Go SDK (module $(GIT_HOST)/$(GIT_USER)/$(SDK_REPO), Go $(GO_VERSION))..."
@set -e; \
export GO_POST_PROCESS_FILE="gofmt -w"; \
if [ -z "$(OGC)" ]; then \
if [ -z "$(NPM)" ]; then \
echo "Error: npm is required to install openapi-generator-cli." >&2; exit 1; \
fi; \
echo "Installing openapi-generator-cli..."; \
$(NPM) i -g @openapitools/openapi-generator-cli; \
OGC_BIN=openapi-generator-cli; \
else \
OGC_BIN="$(OGC)"; \
fi; \
rm -rf "$(SDK_OUTDIR)"; \
mkdir -p "$(SDK_OUTDIR)"; \
"$$OGC_BIN" generate \
--enable-post-process-file \
--generate-alias-as-model \
-i docs/swagger.json \
-g go \
-o "$(SDK_OUTDIR)" \
--additional-properties=packageName=$(SDK_PKG) \
--git-host "$(GIT_HOST)" \
--git-user-id "$(GIT_USER)" \
--git-repo-id "$(SDK_REPO)"; \
cd "$(SDK_OUTDIR)"; \
$(GOCMD) mod edit -go=$(GO_VERSION); \
$(GOCMD) mod tidy
# --- sdk generation (TypeScript) ---
sdk-ts: swagger
@set -e; \
if [ -z "$(OGC)" ]; then \
if [ -z "$(NPM)" ]; then echo "Error: npm is required to install openapi-generator-cli." >&2; exit 1; fi; \
echo "Installing openapi-generator-cli..."; \
$(NPM) i -g @openapitools/openapi-generator-cli; \
OGC_BIN=openapi-generator-cli; \
else \
OGC_BIN="$(OGC)"; \
fi; \
rm -rf "$(SDK_TS_DIR)"; \
mkdir -p "$(SDK_TS_DIR)"; \
"$$OGC_BIN" generate \
-i docs/swagger.json \
-g "$(SDK_TS_GEN)" \
-o "$(SDK_TS_DIR)" \
-p npmName=$(SDK_TS_NPM_NAME) \
-p npmVersion=$(SDK_TS_NPM_VER) \
$(SDK_TS_PROPS_FLAGS); \
if [ ! -d "$(SDK_TS_DIR)" ]; then \
echo "Generation failed: $(SDK_TS_DIR) not found." >&2; exit 1; \
fi; \
if command -v npx >/dev/null 2>&1; then \
echo ">> Prettier: formatting generated TS SDK"; \
cd "$(SDK_TS_DIR)" && npx --yes prettier -w . || true; \
fi; \
echo ">> Installing & building TS SDK in $(SDK_TS_DIR)"; \
if command -v yarn >/dev/null 2>&1; then \
cd "$(SDK_TS_DIR)" && yarn install --frozen-lockfile || true; \
cd "$(SDK_TS_DIR)" && yarn build || true; \
elif command -v npm >/dev/null 2>&1; then \
cd "$(SDK_TS_DIR)" && npm ci || npm install || true; \
cd "$(SDK_TS_DIR)" && npm run build || true; \
else \
echo "Warning: neither yarn nor npm is installed; skipping install/build for TS SDK."; \
fi
# --- sdk generation (TypeScript into UI/src) ---
sdk-ts-ui: swagger
@echo ">> Generating TypeScript SDK directly into UI source: $(SDK_TS_UI_DIR)"
@set -e; \
if [ -z "$(OGC)" ]; then \
if [ -z "$(NPM)" ]; then \
echo "Error: npm is required to install openapi-generator-cli." >&2; exit 1; \
fi; \
echo "Installing openapi-generator-cli..."; \
$(NPM) i -g @openapitools/openapi-generator-cli; \
OGC_BIN=openapi-generator-cli; \
else \
OGC_BIN="$(OGC)"; \
fi; \
rm -rf "$(SDK_TS_UI_DIR)"; \
mkdir -p "$(SDK_TS_UI_DIR)"; \
"$$OGC_BIN" generate \
-i docs/swagger.json \
-g typescript-fetch \
-o "$(SDK_TS_UI_DIR)" \
-p npmName=$(SDK_TS_NPM_NAME) \
-p npmVersion=$(SDK_TS_NPM_VER) \
$(SDK_TS_PROPS_FLAGS); \
# --- move src/* up one level ---
@if [ -d "$(SDK_TS_UI_DIR)/src" ]; then \
mv "$(SDK_TS_UI_DIR)/src/"* "$(SDK_TS_UI_DIR)/"; \
rm -rf "$(SDK_TS_UI_DIR)/src"; \
fi; \
rm -f "$(SDK_TS_UI_DIR)/package.json" "$(SDK_TS_UI_DIR)/tsconfig.json" "$(SDK_TS_UI_DIR)/README.md"
# convenience
sdk-all: sdk-go sdk-ts sdk-ts-ui
sdk: sdk-go
# --- clean/help ---
clean:
@echo ">> Cleaning artifacts..."
@rm -rf "$(BIN)" docs/swagger.* docs/docs.go $(UI_DEST_DIR)/dist $(UI_DIR)/dist $(UI_DIR)/node_modules "$(SDK_OUTDIR)" "$(SDK_TS_OUTDIR)"
help:
@echo "Targets:"
@echo " build - fmt, vet, tidy, upgrade, build UI, generate Swagger, build Go binary (with ldflags)"
@echo " ui - build the Vite UI and copy to $(UI_DEST_DIR)/dist (with compression)"
@echo " swagger - (re)generate Swagger docs using swag"
@echo " sdk-go (sdk) - generate Go SDK with correct module path and Go version"
@echo " sdk-ts - generate TypeScript SDK (typescript-fetch) with package.json"
@echo " sdk-ts-ui - generate TypeScript SDK directly into ui/src for inline consumption"
@echo " sdk-all - generate both Go and TypeScript SDKs"
@echo " dev - run Vite UI dev server + Go API"
@echo " clean - remove binary, Swagger outputs, UI dist, and SDKs"
@echo " prepare - fmt, vet, tidy, upgrade deps"
@echo " print-version - show computed ldflags values"

83
cmd/encryption.go Normal file
View File

@@ -0,0 +1,83 @@
package cmd
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"github.com/glueops/autoglue/internal/app"
"github.com/glueops/autoglue/internal/models"
"github.com/spf13/cobra"
)
var rotateMasterCmd = &cobra.Command{
Use: "rotate-master",
Short: "Generate and activate a new master encryption key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
rt := app.NewRuntime()
db := rt.DB
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("generating random key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := db.Model(&models.MasterKey{}).
Where("is_active = ?", true).
Update("is_active", false).Error; err != nil {
return fmt.Errorf("deactivating previous key: %w", err)
}
if err := db.Create(&models.MasterKey{
Key: encoded,
IsActive: true,
}).Error; err != nil {
return fmt.Errorf("creating new master key: %w", err)
}
fmt.Println("Master key rotated successfully")
return nil
},
}
var createMasterCmd = &cobra.Command{
Use: "create-master",
Short: "Generate and activate a new master encryption key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
rt := app.NewRuntime()
db := rt.DB
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("generating random key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := db.Create(&models.MasterKey{
Key: encoded,
IsActive: true,
}).Error; err != nil {
return fmt.Errorf("creating master key: %w", err)
}
fmt.Println("Master key created successfully")
return nil
},
}
var encryptCmd = &cobra.Command{
Use: "encrypt",
Short: "Manage autoglue encryption keys",
Long: "Manage autoglue master encryption keys used for securing data.",
}
func init() {
encryptCmd.AddCommand(rotateMasterCmd)
encryptCmd.AddCommand(createMasterCmd)
rootCmd.AddCommand(encryptCmd)
}

81
cmd/keys_generate.go Normal file
View File

@@ -0,0 +1,81 @@
package cmd
import (
"fmt"
"time"
"github.com/glueops/autoglue/internal/app"
"github.com/glueops/autoglue/internal/keys"
"github.com/spf13/cobra"
)
var (
alg string
rsaBits int
kidFlag string
nbfStr string
expStr string
)
var keysCmd = &cobra.Command{
Use: "keys",
Short: "Manage JWT signing keys",
}
var keysGenCmd = &cobra.Command{
Use: "generate",
Short: "Generate and store a new signing key",
RunE: func(_ *cobra.Command, _ []string) error {
rt := app.NewRuntime()
var nbfPtr, expPtr *time.Time
if nbfStr != "" {
t, err := time.Parse(time.RFC3339, nbfStr)
if err != nil {
return err
}
nbfPtr = &t
}
if expStr != "" {
t, err := time.Parse(time.RFC3339, expStr)
if err != nil {
return err
}
expPtr = &t
}
rec, err := keys.GenerateAndStore(rt.DB, rt.Cfg.JWTPrivateEncKey, keys.GenOpts{
Alg: alg,
Bits: rsaBits,
KID: kidFlag,
NBF: nbfPtr,
EXP: expPtr,
})
if err != nil {
return err
}
fmt.Printf("created signing key\n")
fmt.Printf(" kid: %s\n", rec.Kid)
fmt.Printf(" alg: %s\n", rec.Alg)
fmt.Printf(" active: %v\n", rec.IsActive)
if rec.NotBefore != nil {
fmt.Printf(" nbf: %s\n", rec.NotBefore.Format(time.RFC3339))
}
if rec.ExpiresAt != nil {
fmt.Printf(" exp: %s\n", rec.ExpiresAt.Format(time.RFC3339))
}
return nil
},
}
func init() {
rootCmd.AddCommand(keysCmd)
keysCmd.AddCommand(keysGenCmd)
keysGenCmd.Flags().StringVarP(&alg, "alg", "a", "EdDSA", "Signing alg: EdDSA|RS256|RS384|RS512")
keysGenCmd.Flags().IntVarP(&rsaBits, "bits", "b", 3072, "RSA key size (when alg is RS*)")
keysGenCmd.Flags().StringVarP(&kidFlag, "kid", "k", "", "Key ID (optional; auto if empty)")
keysGenCmd.Flags().StringVarP(&nbfStr, "nbf", "n", "", "Not Before (RFC3339)")
keysGenCmd.Flags().StringVarP(&expStr, "exp", "e", "", "Expires At (RFC3339)")
}

33
cmd/root.go Normal file
View File

@@ -0,0 +1,33 @@
package cmd
import (
"log"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "autoglue",
Short: "Autoglue Kubernetes Cluster Management",
Long: "autoglue is used to manage the lifecycle of kubernetes clusters on GlueOps supported cloud providers",
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
err := serveCmd.RunE(cmd, args)
if err != nil {
log.Fatal(err)
}
} else {
_ = cmd.Help()
}
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
func init() {
cobra.OnInitialize()
}

94
cmd/serve.go Normal file
View File

@@ -0,0 +1,94 @@
package cmd
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/glueops/autoglue/internal/api"
"github.com/glueops/autoglue/internal/app"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/config"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start API server",
RunE: func(_ *cobra.Command, _ []string) error {
rt := app.NewRuntime()
cfg, err := config.Load()
if err != nil {
return err
}
_ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
go func() {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for range t.C {
_ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
}
}()
r := api.NewRouter(rt.DB)
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: TimeoutExceptUpgrades(r, 60*time.Second, "request timed out"), // global safety
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
fmt.Printf("🚀 API running on http://%s (ui.dev=%v)\n", addr, cfg.UIDev)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
fmt.Println("\n⏳ Shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
},
}
func init() {
rootCmd.AddCommand(serveCmd)
}
func TimeoutExceptUpgrades(next http.Handler, d time.Duration, msg string) http.Handler {
timeout := http.TimeoutHandler(next, d, msg)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If this is an upgrade (e.g., websocket), don't wrap.
if isUpgrade(r) {
next.ServeHTTP(w, r)
return
}
timeout.ServeHTTP(w, r)
})
}
func isUpgrade(r *http.Request) bool {
// Connection: Upgrade, Upgrade: websocket
if strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
return true
}
return false
}

20
cmd/version.go Normal file
View File

@@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"github.com/glueops/autoglue/internal/version"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version.Info())
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}

52
docker-compose.yml Normal file
View File

@@ -0,0 +1,52 @@
services:
autoglue:
# image: ghcr.io/glueops/autoglue:latest
build: .
ports:
- 8080:8080
expose:
- 8080
env_file: .env
environment:
AUTOGLUE_DATABASE_DSN: postgres://$DB_USER:$DB_PASSWORD@postgres:5432/$DB_NAME
AUTOGLUE_BIND_ADDRESS: 0.0.0.0
depends_on:
- postgres
postgres:
build:
context: postgres
env_file: .env
environment:
POSTGRES_USER: $DB_USER
POSTGRES_PASSWORD: $DB_PASSWORD
POSTGRES_DB: $DB_NAME
expose:
- 5432
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
pgweb:
image: sosedoff/pgweb@sha256:8f1ed22e10c9da0912169b98b62ddc54930dc39a5ae07b0f1354d2a93d44c6ed
restart: always
ports:
- "8081:8081"
links:
- postgres:postgres
env_file: .env
environment:
PGWEB_DATABASE_URL: postgres://$DB_USER:$DB_PASSWORD@postgres:5432/$DB_NAME
depends_on:
- postgres
mailpit:
image: axllent/mailpit@sha256:6abc8e633df15eaf785cfcf38bae48e66f64beecdc03121e249d0f9ec15f0707
restart: always
ports:
- "1025:1025"
- "8025:8025"
volumes:
postgres_data:

26
docs/docs.go Normal file

File diff suppressed because one or more lines are too long

9
docs/efs.go Normal file
View File

@@ -0,0 +1,9 @@
package docs
import _ "embed"
//go:embed swagger.json
var SwaggerJSON []byte
//go:embed swagger.yaml
var SwaggerYAML []byte

1
docs/swagger.json Normal file

File diff suppressed because one or more lines are too long

1731
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

70
go.mod Normal file
View File

@@ -0,0 +1,70 @@
module github.com/glueops/autoglue
go 1.25.3
require (
github.com/alexedwards/argon2id v1.0.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag/v2 v2.0.0-rc4
golang.org/x/crypto v0.35.0
golang.org/x/oauth2 v0.32.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/sv-tools/openapi v0.2.1 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/swaggo/swag v1.8.1 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)

236
go.sum Normal file
View File

@@ -0,0 +1,236 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/sv-tools/openapi v0.2.1 h1:ES1tMQMJFGibWndMagvdoo34T1Vllxr1Nlm5wz6b1aA=
github.com/sv-tools/openapi v0.2.1/go.mod h1:k5VuZamTw1HuiS9p2Wl5YIDWzYnHG6/FgPOSFXLAhGg=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag/v2 v2.0.0-rc4 h1:SZ8cK68gcV6cslwrJMIOqPkJELRwq4gmjvk77MrvHvY=
github.com/swaggo/swag/v2 v2.0.0-rc4/go.mod h1:Ow7Y8gF16BTCDn8YxZbyKn8FkMLRUHekv1kROJZpbvE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

46
internal/app/runtime.go Normal file
View File

@@ -0,0 +1,46 @@
package app
import (
"log"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type Runtime struct {
Cfg config.Config
DB *gorm.DB
}
func NewRuntime() *Runtime {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
d := db.Open(cfg.DbURL)
err = db.Run(d,
&models.MasterKey{},
&models.SigningKey{},
&models.User{},
&models.Organization{},
&models.Account{},
&models.Membership{},
&models.APIKey{},
&models.UserEmail{},
&models.RefreshToken{},
&models.OrganizationKey{},
&models.SshKey{},
&models.Server{},
&models.Taint{},
)
if err != nil {
log.Fatalf("Error initializing database: %v", err)
}
return &Runtime{
Cfg: cfg,
DB: d,
}
}

38
internal/auth/hash.go Normal file
View File

@@ -0,0 +1,38 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/alexedwards/argon2id"
)
func SHA256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
var argonParams = &argon2id.Params{
Memory: 64 * 1024, // 64MB
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
func HashSecretArgon2id(plain string) (string, error) {
return argon2id.CreateHash(plain, argonParams)
}
func VerifySecretArgon2id(encodedHash, plain string) (bool, error) {
if encodedHash == "" {
return false, errors.New("empty hash")
}
return argon2id.ComparePasswordAndHash(plain, encodedHash)
}
func NotExpired(expiresAt *time.Time) bool {
return expiresAt == nil || time.Now().Before(*expiresAt)
}

42
internal/auth/issue.go Normal file
View File

@@ -0,0 +1,42 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
// URL-safe, no padding
return base64.RawURLEncoding.EncodeToString(b), nil
}
// IssueUserAPIKey creates a single-token user API key (X-API-KEY)
func IssueUserAPIKey(db *gorm.DB, userID uuid.UUID, name string, ttl *time.Duration) (plaintext string, rec models.APIKey, err error) {
plaintext, err = randomToken(32)
if err != nil {
return "", models.APIKey{}, err
}
rec = models.APIKey{
Name: name,
Scope: "user",
UserID: &userID,
KeyHash: SHA256Hex(plaintext), // deterministic lookup
}
if ttl != nil {
ex := time.Now().Add(*ttl)
rec.ExpiresAt = &ex
}
if err = db.Create(&rec).Error; err != nil {
return "", models.APIKey{}, err
}
return plaintext, rec, nil
}

View File

@@ -0,0 +1,71 @@
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
)
// base64url (no padding)
func b64url(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// convert small int (RSA exponent) to big-endian bytes
func fromInt(i int) []byte {
var x big.Int
x.SetInt64(int64(i))
return x.Bytes()
}
// --- public accessors for JWKS ---
// KeyMeta is a minimal metadata view exposed for JWKS rendering.
type KeyMeta struct {
Alg string
}
// MetaFor returns minimal metadata (currently the alg) for a given kid.
// If not found, returns zero value (Alg == "").
func MetaFor(kid string) KeyMeta {
kc.mu.RLock()
defer kc.mu.RUnlock()
if m, ok := kc.meta[kid]; ok {
return KeyMeta{Alg: m.Alg}
}
return KeyMeta{}
}
// KcCopy invokes fn with a shallow copy of the public key map (kid -> public key instance).
// Useful to iterate without holding the lock during JSON building.
func KcCopy(fn func(map[string]interface{})) {
kc.mu.RLock()
defer kc.mu.RUnlock()
out := make(map[string]interface{}, len(kc.pub))
for kid, pk := range kc.pub {
out[kid] = pk
}
fmt.Println(out)
fn(out)
}
// PubToJWK converts a parsed public key into bare JWK parameters + kty.
// - RSA: returns n/e (base64url) and kty="RSA"
// - Ed25519: returns x (base64url) and kty="OKP"
func PubToJWK(_kid, _alg string, pub any) (map[string]string, string) {
switch k := pub.(type) {
case *rsa.PublicKey:
return map[string]string{
"n": b64url(k.N.Bytes()),
"e": b64url(fromInt(k.E)),
}, "RSA"
case ed25519.PublicKey:
return map[string]string{
"x": b64url([]byte(k)),
}, "OKP"
default:
return nil, ""
}
}

View File

@@ -0,0 +1,55 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type IssueOpts struct {
Subject string
Issuer string
Audience string
TTL time.Duration
Claims map[string]any // extra app claims
}
func IssueAccessToken(opts IssueOpts) (string, error) {
kc.mu.RLock()
defer kc.mu.RUnlock()
if kc.selPriv == nil || kc.selKid == "" || kc.selAlg == "" {
return "", errors.New("no active signing key")
}
claims := jwt.MapClaims{
"iss": opts.Issuer,
"aud": opts.Audience,
"sub": opts.Subject,
"iat": time.Now().Unix(),
"exp": time.Now().Add(opts.TTL).Unix(),
}
for k, v := range opts.Claims {
claims[k] = v
}
var method jwt.SigningMethod
switch kc.selAlg {
case "RS256":
method = jwt.SigningMethodRS256
case "RS384":
method = jwt.SigningMethodRS384
case "RS512":
method = jwt.SigningMethodRS512
case "EdDSA":
method = jwt.SigningMethodEdDSA
default:
return "", errors.New("unsupported alg")
}
token := jwt.NewWithClaims(method, claims)
token.Header["kid"] = kc.selKid
return token.SignedString(kc.selPriv)
}

138
internal/auth/jwt_signer.go Normal file
View File

@@ -0,0 +1,138 @@
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"sync"
"time"
"github.com/glueops/autoglue/internal/keys"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type keyCache struct {
mu sync.RWMutex
pub map[string]interface{} // kid -> public key object
meta map[string]models.SigningKey
selKid string
selAlg string
selPriv any
}
var kc keyCache
// Refresh loads active keys into memory. Call on startup and periodically (ticker/cron).
func Refresh(db *gorm.DB, encKeyB64 string) error {
var rows []models.SigningKey
if err := db.Where("is_active = true AND (expires_at IS NULL OR expires_at > ?)", time.Now()).
Order("created_at desc").Find(&rows).Error; err != nil {
return err
}
pub := make(map[string]interface{}, len(rows))
meta := make(map[string]models.SigningKey, len(rows))
var selKid string
var selAlg string
var selPriv any
for i, r := range rows {
// parse public
block, _ := pem.Decode([]byte(r.PublicPEM))
if block == nil {
continue
}
var pubKey any
switch r.Alg {
case "RS256", "RS384", "RS512":
pubKey, _ = x509.ParsePKCS1PublicKey(block.Bytes)
if pubKey == nil {
// also allow PKIX format
if k, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
pubKey = k
}
}
case "EdDSA":
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err == nil {
if edk, ok := k.(ed25519.PublicKey); ok {
pubKey = edk
}
}
}
if pubKey == nil {
continue
}
pub[r.Kid] = pubKey
meta[r.Kid] = r
// pick first row as current signer (most recent because of order desc)
if i == 0 {
privPEM := r.PrivatePEM
// decrypt if necessary
if len(privPEM) > 10 && privPEM[:10] == "enc:aesgcm" {
pt, err := keysDecrypt(encKeyB64, privPEM)
if err != nil {
continue
}
privPEM = string(pt)
}
blockPriv, _ := pem.Decode([]byte(privPEM))
if blockPriv == nil {
continue
}
switch r.Alg {
case "RS256", "RS384", "RS512":
if k, err := x509.ParsePKCS1PrivateKey(blockPriv.Bytes); err == nil {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
} else if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(*rsa.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
case "EdDSA":
if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(ed25519.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
}
}
}
kc.mu.Lock()
defer kc.mu.Unlock()
kc.pub = pub
kc.meta = meta
kc.selKid = selKid
kc.selAlg = selAlg
kc.selPriv = selPriv
return nil
}
func keysDecrypt(encKey, enc string) ([]byte, error) {
return keysDecryptImpl(encKey, enc)
}
// indirection for same package
var keysDecryptImpl = func(encKey, enc string) ([]byte, error) {
return nil, errors.New("not wired")
}
// Wire up from keys package
func init() {
keysDecryptImpl = keysDecryptShim
}
func keysDecryptShim(encKey, enc string) ([]byte, error) {
return keys.Decrypt(encKey, enc)
}

View File

@@ -0,0 +1,56 @@
package auth
import (
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ValidateJWT verifies RS256/RS384/RS512/EdDSA tokens using the in-memory key cache.
// It honors kid when present, and falls back to any active key.
func ValidateJWT(tokenStr string, db *gorm.DB) *models.User {
cfg, _ := config.Load()
parser := jwt.NewParser(
jwt.WithIssuer(cfg.JWTIssuer),
jwt.WithAudience(cfg.JWTAudience),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "EdDSA"}),
)
token, err := parser.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// Resolve by kid first
kid, _ := t.Header["kid"].(string)
kc.mu.RLock()
defer kc.mu.RUnlock()
if kid != "" {
if k, ok := kc.pub[kid]; ok {
return k, nil
}
}
// Fallback: try first active key
for _, k := range kc.pub {
return k, nil
}
return nil, jwt.ErrTokenUnverifiable
})
if err != nil || !token.Valid {
return nil
}
claims, _ := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
uid, err := uuid.Parse(sub)
if err != nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", uid).Error; err != nil {
return nil
}
return &u
}

105
internal/auth/refresh.go Normal file
View File

@@ -0,0 +1,105 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
// random opaque token (returned to client once)
func generateOpaqueToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
type RefreshPair struct {
Plain string
Record models.RefreshToken
}
// Issue a new refresh token (new family if familyID == nil)
func IssueRefreshToken(db *gorm.DB, userID uuid.UUID, ttl time.Duration, familyID *uuid.UUID) (RefreshPair, error) {
plain, err := generateOpaqueToken(32)
if err != nil {
return RefreshPair{}, err
}
hash, err := HashSecretArgon2id(plain)
if err != nil {
return RefreshPair{}, err
}
fid := uuid.New()
if familyID != nil {
fid = *familyID
}
rec := models.RefreshToken{
UserID: userID,
FamilyID: fid,
TokenHash: hash,
ExpiresAt: time.Now().Add(ttl),
}
if err := db.Create(&rec).Error; err != nil {
return RefreshPair{}, err
}
return RefreshPair{Plain: plain, Record: rec}, nil
}
// ValidateRefreshToken refresh token; returns record if valid & not revoked/expired
func ValidateRefreshToken(db *gorm.DB, plain string) (*models.RefreshToken, error) {
if plain == "" {
return nil, errors.New("empty")
}
// var rec models.RefreshToken
// We can't query by hash w/ Argon; scan candidates by expiry window. Keep small TTL (e.g. 30d).
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Find(&[]models.RefreshToken{}).Error; err != nil {
return nil, err
}
// Better: add a prefix column to narrow scan; omitted for brevity.
// Pragmatic approach: single SELECT per token:
// Add a TokenHashSHA256 column for deterministic lookup if you want O(1). (Optional)
// Minimal: iterate limited set; for simplicity we fetch by created window:
var recs []models.RefreshToken
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Order("created_at desc").Limit(500).Find(&recs).Error; err != nil {
return nil, err
}
for _, r := range recs {
ok, _ := VerifySecretArgon2id(r.TokenHash, plain)
if ok {
return &r, nil
}
}
return nil, errors.New("invalid")
}
// RevokeFamily revokes all tokens in a family (logout everywhere)
func RevokeFamily(db *gorm.DB, familyID uuid.UUID) error {
now := time.Now()
return db.Model(&models.RefreshToken{}).
Where("family_id = ? AND revoked_at IS NULL", familyID).
Update("revoked_at", &now).Error
}
// RotateRefreshToken replaces one token with a fresh one within the same family
func RotateRefreshToken(db *gorm.DB, used *models.RefreshToken, ttl time.Duration) (RefreshPair, error) {
// revoke the used token (one-time use)
now := time.Now()
if err := db.Model(&models.RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", used.ID).
Update("revoked_at", &now).Error; err != nil {
return RefreshPair{}, err
}
return IssueRefreshToken(db, used.UserID, ttl, &used.FamilyID)
}

View File

@@ -0,0 +1,88 @@
package auth
import (
"time"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
// ValidateAPIKey validates a single-token user API key sent via X-API-KEY.
func ValidateAPIKey(rawKey string, db *gorm.DB) *models.User {
if rawKey == "" {
return nil
}
digest := SHA256Hex(rawKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
if k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
// Optional: touch last_used_at here if you've added it on the model.
return &u
}
// ValidateAppKeyPair validates a user key/secret pair via X-APP-KEY / X-APP-SECRET.
func ValidateAppKeyPair(appKey, secret string, db *gorm.DB) *models.User {
if appKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(appKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
return &u
}
// ValidateOrgKeyPair validates an org key/secret via X-ORG-KEY / X-ORG-SECRET.
func ValidateOrgKeyPair(orgKey, secret string, db *gorm.DB) *models.Organization {
if orgKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(orgKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "org", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.OrgID == nil {
return nil
}
var o models.Organization
if err := db.First(&o, "id = ?", *k.OrgID).Error; err != nil {
return nil
}
return &o
}
// local helper; avoids nil-deref when comparing secrets
func zeroIfNil(s *string) string {
if s == nil {
return ""
}
return *s
}

194
internal/config/config.go Normal file
View File

@@ -0,0 +1,194 @@
package config
import (
"errors"
"fmt"
"strings"
"sync"
"github.com/joho/godotenv"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
type Config struct {
DbURL string
Port string
Host string
JWTIssuer string
JWTAudience string
JWTPrivateEncKey string
OAuthRedirectBase string
GoogleClientID string
GoogleClientSecret string
GithubClientID string
GithubClientSecret string
UIDev bool
Env string
Debug bool
Swagger bool
}
var (
once sync.Once
cached Config
loadErr error
)
func Load() (Config, error) {
once.Do(func() {
_ = godotenv.Load()
// Use a private viper to avoid global mutation/races
v := viper.New()
// Defaults
v.SetDefault("bind.address", "127.0.0.1")
v.SetDefault("bind.port", "8080")
v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
v.SetDefault("ui.dev", false)
v.SetDefault("env", "development")
v.SetDefault("debug", false)
v.SetDefault("swagger", false)
// Env setup and binding
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
keys := []string{
"bind.address",
"bind.port",
"database.url",
"jwt.issuer",
"jwt.audience",
"jwt.private.enc.key",
"oauth.redirect.base",
"google.client.id",
"google.client.secret",
"github.client.id",
"github.client.secret",
"ui.dev",
"env",
"debug",
"swagger",
}
for _, k := range keys {
_ = v.BindEnv(k)
}
// Build config
cfg := Config{
DbURL: v.GetString("database.url"),
Port: v.GetString("bind.port"),
Host: v.GetString("bind.address"),
JWTIssuer: v.GetString("jwt.issuer"),
JWTAudience: v.GetString("jwt.audience"),
JWTPrivateEncKey: v.GetString("jwt.private.enc.key"),
OAuthRedirectBase: v.GetString("oauth.redirect.base"),
GoogleClientID: v.GetString("google.client.id"),
GoogleClientSecret: v.GetString("google.client.secret"),
GithubClientID: v.GetString("github.client.id"),
GithubClientSecret: v.GetString("github.client.secret"),
UIDev: v.GetBool("ui.dev"),
Env: v.GetString("env"),
Debug: v.GetBool("debug"),
Swagger: v.GetBool("swagger"),
}
// Validate
if err := validateConfig(cfg); err != nil {
loadErr = err
return
}
cached = cfg
})
return cached, loadErr
}
func validateConfig(cfg Config) error {
var errs []string
// Required general settings
req := map[string]string{
"jwt.issuer": cfg.JWTIssuer,
"jwt.audience": cfg.JWTAudience,
"jwt.private.enc.key": cfg.JWTPrivateEncKey,
"oauth.redirect.base": cfg.OAuthRedirectBase,
}
for k, v := range req {
if strings.TrimSpace(v) == "" {
errs = append(errs, fmt.Sprintf("missing required config key %q (env %s)", k, envNameFromKey(k)))
}
}
// OAuth provider requirements:
googleOK := strings.TrimSpace(cfg.GoogleClientID) != "" && strings.TrimSpace(cfg.GoogleClientSecret) != ""
githubOK := strings.TrimSpace(cfg.GithubClientID) != "" && strings.TrimSpace(cfg.GithubClientSecret) != ""
// If partially configured, report what's missing for each
if !googleOK && (cfg.GoogleClientID != "" || cfg.GoogleClientSecret != "") {
if cfg.GoogleClientID == "" {
errs = append(errs, fmt.Sprintf("google.client.id is missing (env %s) while google.client.secret is set", envNameFromKey("google.client.id")))
}
if cfg.GoogleClientSecret == "" {
errs = append(errs, fmt.Sprintf("google.client.secret is missing (env %s) while google.client.id is set", envNameFromKey("google.client.secret")))
}
}
if !githubOK && (cfg.GithubClientID != "" || cfg.GithubClientSecret != "") {
if cfg.GithubClientID == "" {
errs = append(errs, fmt.Sprintf("github.client.id is missing (env %s) while github.client.secret is set", envNameFromKey("github.client.id")))
}
if cfg.GithubClientSecret == "" {
errs = append(errs, fmt.Sprintf("github.client.secret is missing (env %s) while github.client.id is set", envNameFromKey("github.client.secret")))
}
}
// Enforce minimum: at least one full provider
if !googleOK && !githubOK {
errs = append(errs, "at least one OAuth provider must be fully configured: either Google (google.client.id + google.client.secret) or GitHub (github.client.id + github.client.secret)")
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
func envNameFromKey(key string) string {
return strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
}
func DebugPrintConfig() {
cfg, _ := Load()
b, err := yaml.Marshal(cfg)
if err != nil {
fmt.Println("error marshalling config:", err)
return
}
fmt.Println("Loaded configuration:")
fmt.Println(string(b))
}
func IsUIDev() bool {
cfg, _ := Load()
return cfg.UIDev
}
func IsDev() bool {
cfg, _ := Load()
return strings.EqualFold(cfg.Env, "development")
}
func IsDebug() bool {
cfg, _ := Load()
return cfg.Debug
}
func IsSwaggerEnabled() bool {
cfg, _ := Load()
return cfg.Swagger
}

17
internal/db/db.go Normal file
View File

@@ -0,0 +1,17 @@
package db
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Open(dsn string) *gorm.DB {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
if err != nil {
log.Fatalf("failed to connect to db: %v", err)
}
return db
}

25
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,25 @@
package db
import (
"fmt"
"gorm.io/gorm"
)
func Run(db *gorm.DB, models ...any) error {
return db.Transaction(func(tx *gorm.DB) error {
// 0) Extensions
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS pgcrypto`).Error; err != nil {
return fmt.Errorf("enable pgcrypto: %w", err)
}
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS citext`).Error; err != nil {
return fmt.Errorf("enable citext: %w", err)
}
// 1) AutoMigrate (pass parents before children in caller)
if err := tx.AutoMigrate(models...); err != nil {
return fmt.Errorf("automigrate: %w", err)
}
return nil
})
}

477
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,477 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
type oauthProvider struct {
Name string
Issuer string
Scopes []string
ClientID string
Secret string
}
func providerConfig(cfg config.Config, name string) (oauthProvider, bool) {
switch strings.ToLower(name) {
case "google":
return oauthProvider{
Name: "google",
Issuer: "https://accounts.google.com",
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
ClientID: cfg.GoogleClientID,
Secret: cfg.GoogleClientSecret,
}, true
case "github":
// GitHub is not a pure OIDC provider; we use OAuth2 + user email API
return oauthProvider{
Name: "github",
Issuer: "github",
Scopes: []string{"read:user", "user:email"},
ClientID: cfg.GithubClientID, Secret: cfg.GithubClientSecret,
}, true
}
return oauthProvider{}, false
}
// AuthStart godoc
// @ID AuthStart
// @Summary Begin social login
// @Description Returns provider authorization URL for the frontend to redirect
// @Tags Auth
// @Param provider path string true "google|github"
// @Produce json
// @Success 200 {object} dto.AuthStartResponse
// @Router /auth/{provider}/start [post]
func AuthStart(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
provider := strings.ToLower(chi.URLParam(r, "provider"))
p, ok := providerConfig(cfg, provider)
if !ok || p.ClientID == "" || p.Secret == "" {
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
return
}
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
// Optional SPA hints to be embedded into state
mode := r.URL.Query().Get("mode") // "spa" enables postMessage callback page
origin := r.URL.Query().Get("origin") // e.g. http://localhost:5173
state := uuid.NewString()
if mode == "spa" && origin != "" {
state = state + "|mode=spa|origin=" + url.QueryEscape(origin)
}
var authURL string
if p.Issuer == "github" {
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
} else {
// Google OIDC
ctx := context.Background()
prov, err := oidc.NewProvider(ctx, p.Issuer)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "oidc_discovery_failed", err.Error())
return
}
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Endpoint: prov.Endpoint(),
Scopes: p.Scopes,
}
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
utils.WriteJSON(w, http.StatusOK, dto.AuthStartResponse{AuthURL: authURL})
}
}
// AuthCallback godoc
// @ID AuthCallback
// @Summary Handle social login callback
// @Tags Auth
// @Param provider path string true "google|github"
// @Produce json
// @Success 200 {object} dto.TokenPair
// @Router /auth/{provider}/callback [get]
func AuthCallback(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
provider := strings.ToLower(chi.URLParam(r, "provider"))
p, ok := providerConfig(cfg, provider)
if !ok {
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
return
}
code := r.URL.Query().Get("code")
if code == "" {
utils.WriteError(w, http.StatusBadRequest, "invalid_request", "missing code")
return
}
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
var email, display, subject string
if p.Issuer == "github" {
// OAuth2 code exchange
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
tok, err := o.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, http.StatusUnauthorized, "exchange_failed", err.Error())
return
}
// Fetch user primary email
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
req.Header.Set("Authorization", "token "+tok.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
utils.WriteError(w, http.StatusUnauthorized, "email_fetch_failed", "github user/emails")
return
}
defer resp.Body.Close()
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil || len(emails) == 0 {
utils.WriteError(w, http.StatusUnauthorized, "email_parse_failed", err.Error())
return
}
email = emails[0].Email
for _, e := range emails {
if e.Primary {
email = e.Email
break
}
}
subject = "github:" + email
display = strings.Split(email, "@")[0]
} else {
// Google OIDC
oidcProv, err := oidc.NewProvider(r.Context(), p.Issuer)
if err != nil {
utils.WriteError(w, 500, "oidc_discovery_failed", err.Error())
return
}
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Endpoint: oidcProv.Endpoint(),
Scopes: p.Scopes,
}
tok, err := o.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, 401, "exchange_failed", err.Error())
return
}
verifier := oidcProv.Verifier(&oidc.Config{ClientID: p.ClientID})
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok {
utils.WriteError(w, 401, "no_id_token", "")
return
}
idt, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
utils.WriteError(w, 401, "id_token_invalid", err.Error())
return
}
var claims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Sub string `json:"sub"`
}
if err := idt.Claims(&claims); err != nil {
utils.WriteError(w, 401, "claims_parse_error", err.Error())
return
}
email = strings.ToLower(claims.Email)
display = claims.Name
subject = "google:" + claims.Sub
}
// Upsert Account + User; domain auto-join (member)
user, err := upsertAccountAndUser(db, p.Name, subject, email, display)
if err != nil {
utils.WriteError(w, 500, "account_upsert_failed", err.Error())
return
}
// Org auto-join: Organization.Domain == email domain
_ = ensureAutoMembership(db, user.ID, email)
// Issue tokens
accessTTL := 1 * time.Hour
refreshTTL := 30 * 24 * time.Hour
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: user.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
TTL: accessTTL,
Claims: map[string]any{
"email": email,
"name": display,
},
})
if err != nil {
utils.WriteError(w, 500, "issue_access_failed", err.Error())
return
}
rp, err := auth.IssueRefreshToken(db, user.ID, refreshTTL, nil)
if err != nil {
utils.WriteError(w, 500, "issue_refresh_failed", err.Error())
return
}
// If the state indicates SPA popup mode, postMessage tokens to the opener and close
state := r.URL.Query().Get("state")
if strings.Contains(state, "mode=spa") {
origin := ""
for _, part := range strings.Split(state, "|") {
if strings.HasPrefix(part, "origin=") {
origin, _ = url.QueryUnescape(strings.TrimPrefix(part, "origin="))
break
}
}
// fallback: restrict to backend origin if none supplied
if origin == "" {
origin = cfg.OAuthRedirectBase
}
payload := dto.TokenPair{
AccessToken: access,
RefreshToken: rp.Plain,
TokenType: "Bearer",
ExpiresIn: int64(accessTTL.Seconds()),
}
writePostMessageHTML(w, origin, payload)
return
}
// Default JSON response
utils.WriteJSON(w, http.StatusOK, dto.TokenPair{
AccessToken: access,
RefreshToken: rp.Plain,
TokenType: "Bearer",
ExpiresIn: int64(accessTTL.Seconds()),
})
}
}
// Refresh godoc
// @ID Refresh
// @Summary Rotate refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body dto.RefreshRequest true "Refresh token"
// @Success 200 {object} dto.TokenPair
// @Router /auth/refresh [post]
func Refresh(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
var req dto.RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
utils.WriteError(w, 401, "invalid_refresh", "")
return
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", rec.UserID).Error; err != nil {
utils.WriteError(w, 401, "user_disabled", "")
return
}
// rotate
newPair, err := auth.RotateRefreshToken(db, rec, 30*24*time.Hour)
if err != nil {
utils.WriteError(w, 500, "rotate_failed", err.Error())
return
}
// new access
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: u.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
TTL: 1 * time.Hour,
})
if err != nil {
utils.WriteError(w, 500, "issue_access_failed", err.Error())
return
}
utils.WriteJSON(w, 200, dto.TokenPair{
AccessToken: access,
RefreshToken: newPair.Plain,
TokenType: "Bearer",
ExpiresIn: 3600,
})
}
}
// Logout godoc
// @ID Logout
// @Summary Revoke refresh token family (logout everywhere)
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body dto.LogoutRequest true "Refresh token"
// @Success 204 "No Content"
// @Router /auth/logout [post]
func Logout(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req dto.LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
w.WriteHeader(204) // already invalid/revoked
return
}
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
utils.WriteError(w, 500, "revoke_failed", err.Error())
return
}
w.WriteHeader(204)
}
}
// Helpers
func upsertAccountAndUser(db *gorm.DB, provider, subject, email, display string) (*models.User, error) {
email = strings.ToLower(email)
var acc models.Account
if err := db.Where("provider = ? AND subject = ?", provider, subject).First(&acc).Error; err == nil {
var u models.User
if err := db.First(&u, "id = ?", acc.UserID).Error; err != nil {
return nil, err
}
return &u, nil
}
// Link by email if exists
var ue models.UserEmail
if err := db.Where("LOWER(email) = ?", email).First(&ue).Error; err == nil {
acc = models.Account{
UserID: ue.UserID,
Provider: provider,
Subject: subject,
Email: &email,
EmailVerified: true,
}
if err := db.Create(&acc).Error; err != nil {
return nil, err
}
var u models.User
if err := db.First(&u, "id = ?", ue.UserID).Error; err != nil {
return nil, err
}
return &u, nil
}
// Create user
u := models.User{DisplayName: &display, PrimaryEmail: &email}
if err := db.Create(&u).Error; err != nil {
return nil, err
}
ue = models.UserEmail{UserID: u.ID, Email: email, IsVerified: true, IsPrimary: true}
_ = db.Create(&ue).Error
acc = models.Account{UserID: u.ID, Provider: provider, Subject: subject, Email: &email, EmailVerified: true}
_ = db.Create(&acc).Error
return &u, nil
}
func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
parts := strings.SplitN(strings.ToLower(email), "@", 2)
if len(parts) != 2 {
return nil
}
domain := parts[1]
var org models.Organization
if err := db.Where("LOWER(domain) = ?", domain).First(&org).Error; err != nil {
return nil
}
// if already member, done
var c int64
db.Model(&models.Membership{}).
Where("user_id = ? AND organization_id = ?", userID, org.ID).
Count(&c)
if c > 0 {
return nil
}
return db.Create(&models.Membership{
UserID: userID, OrganizationID: org.ID, Role: "member",
}).Error
}
// writePostMessageHTML sends a tiny HTML page that posts tokens to the SPA and closes the window.
func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
b, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!doctype html><html><body><script>
(function(){
try {
var data = ` + string(b) + `;
if (window.opener) {
window.opener.postMessage({ type: 'autoglue:auth', payload: data }, '` + origin + `');
}
} catch (e) {}
window.close();
})();
</script></body></html>`))
}

View File

@@ -0,0 +1,24 @@
package dto
// swagger:model AuthStartResponse
type AuthStartResponse struct {
AuthURL string `json:"auth_url" example:"https://accounts.google.com/o/oauth2/v2/auth?client_id=..."`
}
// swagger:model TokenPair
type TokenPair struct {
AccessToken string `json:"access_token" example:"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ij..."`
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf...."`
TokenType string `json:"token_type" example:"Bearer"`
ExpiresIn int64 `json:"expires_in" example:"3600"`
}
// swagger:model RefreshRequest
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
}
// swagger:model LogoutRequest
type LogoutRequest struct {
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
}

View File

@@ -0,0 +1,19 @@
package dto
// JWK represents a single JSON Web Key (public only).
// swagger:model JWK
type JWK struct {
Kty string `json:"kty" example:"RSA" gorm:"-"`
Use string `json:"use,omitempty" example:"sig" gorm:"-"`
Kid string `json:"kid,omitempty" example:"7c6f1d0a-7a98-4e6a-9dbf-6b1af4b9f345" gorm:"-"`
Alg string `json:"alg,omitempty" example:"RS256" gorm:"-"`
N string `json:"n,omitempty" gorm:"-"`
E string `json:"e,omitempty" example:"AQAB" gorm:"-"`
X string `json:"x,omitempty" gorm:"-"`
}
// JWKS is a JSON Web Key Set container.
// swagger:model JWKS
type JWKS struct {
Keys []JWK `json:"keys" gorm:"-"`
}

View File

@@ -0,0 +1,37 @@
package dto
import "github.com/google/uuid"
type CreateServerRequest struct {
Hostname string `json:"hostname,omitempty"`
PublicIPAddress string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID string `json:"ssh_key_id"`
Role string `json:"role" example:"master|worker|bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
}
type UpdateServerRequest struct {
Hostname *string `json:"hostname,omitempty"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress *string `json:"private_ip_address,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SshKeyID *string `json:"ssh_key_id,omitempty"`
Role *string `json:"role,omitempty" example:"master|worker|bastion"`
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
}
type ServerResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Hostname string `json:"hostname"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID uuid.UUID `json:"ssh_key_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}

View File

@@ -0,0 +1,38 @@
package dto
import "github.com/google/uuid"
type CreateSSHRequest struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
Bits *int `json:"bits,omitempty"` // Only for RSA
Type *string `json:"type,omitempty"` // "rsa" (default) or "ed25519"
}
type SshResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type SshRevealResponse struct {
SshResponse
PrivateKey string `json:"private_key"`
}
type SshMaterialJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
// Exactly one of the following will be populated for part=public/private.
PublicKey *string `json:"public_key,omitempty"` // OpenSSH authorized_key (string)
PrivatePEM *string `json:"private_pem,omitempty"` // PKCS#1/PEM (string)
// For part=both with mode=json we'll return a base64 zip
ZipBase64 *string `json:"zip_base64,omitempty"` // base64-encoded zip
// Suggested filenames (SDKs can save to disk without inferring names)
Filenames []string `json:"filenames"`
}

View File

@@ -0,0 +1,22 @@
package dto
import "github.com/google/uuid"
type TaintResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}
type CreateTaintRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}
type UpdateTaintRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
Effect *string `json:"effect,omitempty"`
}

56
internal/handlers/jwks.go Normal file
View File

@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/utils"
)
type jwk struct {
Kty string `json:"kty"`
Use string `json:"use,omitempty"`
Kid string `json:"kid,omitempty"`
Alg string `json:"alg,omitempty"`
N string `json:"n,omitempty"` // RSA modulus (base64url)
E string `json:"e,omitempty"` // RSA exponent (base64url)
X string `json:"x,omitempty"` // Ed25519 public key (base64url)
}
type jwks struct {
Keys []jwk `json:"keys"`
}
// JWKSHandler godoc
// @ID getJWKS
// @Summary Get JWKS
// @Description Returns the JSON Web Key Set for token verification
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.JWKS
// @Router /.well-known/jwks.json [get]
func JWKSHandler(w http.ResponseWriter, _ *http.Request) {
out := dto.JWKS{Keys: make([]dto.JWK, 0)}
auth.KcCopy(func(pub map[string]interface{}) {
for kid, pk := range pub {
meta := auth.MetaFor(kid)
params, kty := auth.PubToJWK(kid, meta.Alg, pk)
if kty == "" {
continue
}
j := dto.JWK{
Kty: kty,
Use: "sig",
Kid: kid,
Alg: meta.Alg,
N: params["n"],
E: params["e"],
X: params["x"],
}
out.Keys = append(out.Keys, j)
}
})
utils.WriteJSON(w, http.StatusOK, out)
}

120
internal/handlers/me.go Normal file
View File

@@ -0,0 +1,120 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"gorm.io/gorm"
)
type meResponse struct {
models.User `json:",inline"`
Emails []models.UserEmail `json:"emails"`
Organizations []models.Organization `json:"organizations"`
}
// GetMe godoc
// @ID GetMe
// @Summary Get current user profile
// @Tags Me
// @Produce json
// @Success 200 {object} meResponse
// @Router /me [get]
// @Security BearerAuth
// @Security ApiKeyAuth
func GetMe(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var user models.User
if err := db.First(&user, "id = ? AND is_disabled = false", u.ID).Error; err != nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user not found/disabled")
return
}
var emails []models.UserEmail
_ = db.Where("user_id = ?", user.ID).Order("is_primary desc, created_at asc").Find(&emails).Error
var orgs []models.Organization
{
var rows []models.Membership
_ = db.Where("user_id = ?", user.ID).Find(&rows).Error
if len(rows) > 0 {
var ids []interface{}
for _, m := range rows {
ids = append(ids, m.OrganizationID)
}
_ = db.Find(&orgs, "id IN ?", ids).Error
}
}
utils.WriteJSON(w, http.StatusOK, meResponse{
User: user,
Emails: emails,
Organizations: orgs,
})
}
}
type updateMeRequest struct {
DisplayName *string `json:"display_name,omitempty"`
// You can add more editable fields here (timezone, avatar, etc)
}
// UpdateMe godoc
// @ID UpdateMe
// @Summary Update current user profile
// @Tags Me
// @Accept json
// @Produce json
// @Param body body updateMeRequest true "Patch profile"
// @Success 200 {object} models.User
// @Router /me [patch]
// @Security BearerAuth
// @Security ApiKeyAuth
func UpdateMe(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var req updateMeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_json", err.Error())
}
updates := map[string]interface{}{}
if req.DisplayName != nil {
updates["display_name"] = req.DisplayName
}
if len(updates) == 0 {
var user models.User
if err := db.First(&user, "id = ?", u.ID).Error; err != nil {
utils.WriteError(w, 404, "not_found", "user")
return
}
utils.WriteJSON(w, 200, user)
return
}
if err := db.Model(&models.User{}).Where("id = ?", u.ID).Updates(updates).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
var out models.User
_ = db.First(&out, "id = ?", u.ID).Error
utils.WriteJSON(w, 200, out)
}
}

View File

@@ -0,0 +1,175 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
type userAPIKeyOut struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name *string `json:"name,omitempty"`
Scope string `json:"scope"` // "user"
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Plain *string `json:"plain,omitempty"` // Shown only on create:
}
// ListUserAPIKeys godoc
// @ID ListUserAPIKeys
// @Summary List my API keys
// @Tags Me / API Keys
// @Produce json
// @Success 200 {array} userAPIKeyOut
// @Router /me/api-keys [get]
// @Security BearerAuth
// @Security ApiKeyAuth
func ListUserAPIKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var rows []models.APIKey
if err := db.
Where("scope = ? AND user_id = ?", "user", u.ID).
Order("created_at desc").
Find(&rows).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
out := make([]userAPIKeyOut, 0, len(rows))
for _, k := range rows {
out = append(out, toUserKeyOut(k, nil))
}
utils.WriteJSON(w, 200, out)
}
}
type createUserKeyRequest struct {
Name string `json:"name,omitempty"`
ExpiresInHours *int `json:"expires_in_hours,omitempty"` // optional TTL
}
// CreateUserAPIKey godoc
// @ID CreateUserAPIKey
// @Summary Create a new user API key
// @Description Returns the plaintext key once. Store it securely on the client side.
// @Tags Me / API Keys
// @Accept json
// @Produce json
// @Param body body createUserKeyRequest true "Key options"
// @Success 201 {object} userAPIKeyOut
// @Router /me/api-keys [post]
// @Security BearerAuth
// @Security ApiKeyAuth
func CreateUserAPIKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var req createUserKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
plain, err := generateUserAPIKey()
if err != nil {
utils.WriteError(w, 500, "gen_failed", err.Error())
return
}
hash := auth.SHA256Hex(plain)
var exp *time.Time
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
t := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
exp = &t
}
rec := models.APIKey{
Scope: "user",
UserID: &u.ID,
KeyHash: hash,
Name: req.Name, // if field exists
ExpiresAt: exp,
// SecretHash: nil (not used for user keys)
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, toUserKeyOut(rec, &plain))
}
}
// DeleteUserAPIKey godoc
// @ID DeleteUserAPIKey
// @Summary Delete a user API key
// @Tags Me / API Keys
// @Produce json
// @Param id path string true "Key ID (UUID)"
// @Success 204 "No Content"
// @Router /me/api-keys/{id} [delete]
// @Security BearerAuth
func DeleteUserAPIKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 400, "invalid_id", "must be uuid")
return
}
tx := db.Where("id = ? AND scope = ? AND user_id = ?", id, "user", u.ID).
Delete(&models.APIKey{})
if tx.Error != nil {
utils.WriteError(w, 500, "db_error", tx.Error.Error())
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "key not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func toUserKeyOut(k models.APIKey, plain *string) userAPIKeyOut {
return userAPIKeyOut{
ID: k.ID,
Name: &k.Name, // if your model has it; else remove
Scope: k.Scope,
CreatedAt: k.CreatedAt,
ExpiresAt: k.ExpiresAt,
LastUsedAt: k.LastUsedAt, // if present; else remove
Plain: plain,
}
}
func generateUserAPIKey() (string, error) {
// 24 random bytes → base64url (no padding), with "u_" prefix
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
s := base64.RawURLEncoding.EncodeToString(b)
return "u_" + s, nil
}

647
internal/handlers/orgs.go Normal file
View File

@@ -0,0 +1,647 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ---------- Helpers ----------
func mustUser(r *http.Request) (*models.User, bool) {
return httpmiddleware.UserFrom(r.Context())
}
func isOrgRole(db *gorm.DB, userID, orgID uuid.UUID, want ...string) (bool, string) {
var m models.Membership
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err != nil {
return false, ""
}
got := strings.ToLower(m.Role)
for _, w := range want {
if got == strings.ToLower(w) {
return true, got
}
}
return false, got
}
func mustMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
ok, _ := isOrgRole(db, userID, orgID, "owner", "admin", "member")
return ok
}
func randomB64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// ---------- Orgs: list/create/get/update/delete ----------
type orgCreateReq struct {
Name string `json:"name" example:"Acme Corp"`
Domain *string `json:"domain,omitempty" example:"acme.com"`
}
// CreateOrg godoc
// @ID CreateOrg
// @Summary Create organization
// @Tags Orgs
// @Accept json
// @Produce json
// @Param body body orgCreateReq true "Org payload"
// @Success 201 {object} models.Organization
// @Failure 400 {object} utils.ErrorResponse
// @Failure 401 {object} utils.ErrorResponse
// @Failure 409 {object} utils.ErrorResponse
// @Router /orgs [post]
// @ID createOrg
// @Security BearerAuth
func CreateOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
return
}
var req orgCreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
utils.WriteError(w, 400, "validation_error", "name is required")
return
}
org := models.Organization{Name: req.Name}
if req.Domain != nil && strings.TrimSpace(*req.Domain) != "" {
org.Domain = req.Domain
}
if err := db.Create(&org).Error; err != nil {
utils.WriteError(w, 409, "conflict", err.Error())
return
}
// creator is owner
_ = db.Create(&models.Membership{
UserID: u.ID, OrganizationID: org.ID, Role: "owner",
}).Error
utils.WriteJSON(w, 201, org)
}
}
// ListMyOrgs godoc
// @ID ListMyOrgs
// @Summary List organizations I belong to
// @Tags Orgs
// @Produce json
// @Success 200 {array} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs [get]
// @ID listMyOrgs
// @Security BearerAuth
func ListMyOrgs(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
return
}
var orgs []models.Organization
if err := db.
Joins("join memberships m on m.organization_id = organizations.id").
Where("m.user_id = ?", u.ID).
Order("organizations.created_at desc").
Find(&orgs).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, 200, orgs)
}
}
// GetOrg godoc
// @ID GetOrg
// @Summary Get organization
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {object} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [get]
// @ID getOrg
// @Security BearerAuth
func GetOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "not a member")
return
}
var org models.Organization
if err := db.First(&org, "id = ?", oid).Error; err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
utils.WriteJSON(w, 200, org)
}
}
type orgUpdateReq struct {
Name *string `json:"name,omitempty"`
Domain *string `json:"domain,omitempty"`
}
// UpdateOrg godoc
// @ID UpdateOrg
// @Summary Update organization (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body orgUpdateReq true "Update payload"
// @Success 200 {object} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [patch]
// @ID updateOrg
// @Security BearerAuth
func UpdateOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req orgUpdateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
changes := map[string]any{}
if req.Name != nil {
changes["name"] = strings.TrimSpace(*req.Name)
}
if req.Domain != nil {
if d := strings.TrimSpace(*req.Domain); d == "" {
changes["domain"] = nil
} else {
changes["domain"] = d
}
}
if len(changes) > 0 {
if err := db.Model(&models.Organization{}).Where("id = ?", oid).Updates(changes).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
}
var out models.Organization
_ = db.First(&out, "id = ?", oid).Error
utils.WriteJSON(w, 200, out)
}
}
// DeleteOrg godoc
// @ID DeleteOrg
// @Summary Delete organization (owner)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 204 "Deleted"
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [delete]
// @ID deleteOrg
// @Security BearerAuth
func DeleteOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner"); !ok {
utils.WriteError(w, 401, "forbidden", "owner required")
return
}
// Optional safety: deny if members >1 or resources exist; here we just delete.
res := db.Delete(&models.Organization{}, "id = ?", oid)
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
w.WriteHeader(204)
}
}
// ---------- Members: list/add/update/delete ----------
type memberOut struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Email string `json:"email"`
Role string `json:"role"` // owner/admin/member
}
type memberUpsertReq struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Role string `json:"role" example:"member"`
}
// ListMembers godoc
// @ID ListMembers
// @Summary List members in org
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {array} memberOut
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members [get]
// @ID listMembers
// @Security BearerAuth
func ListMembers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "")
return
}
var ms []models.Membership
if err := db.Where("organization_id = ?", oid).Find(&ms).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
// load emails
userIDs := make([]uuid.UUID, 0, len(ms))
for _, m := range ms {
userIDs = append(userIDs, m.UserID)
}
var emails []models.UserEmail
if len(userIDs) > 0 {
_ = db.Where("user_id in ?", userIDs).Where("is_primary = true").Find(&emails).Error
}
emailByUser := map[uuid.UUID]string{}
for _, e := range emails {
emailByUser[e.UserID] = e.Email
}
out := make([]memberOut, 0, len(ms))
for _, m := range ms {
out = append(out, memberOut{
UserID: m.UserID,
Email: emailByUser[m.UserID],
Role: m.Role,
})
}
utils.WriteJSON(w, 200, out)
}
}
// AddOrUpdateMember godoc
// @ID AddOrUpdateMember
// @Summary Add or update a member (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body memberUpsertReq true "User & role"
// @Success 200 {object} memberOut
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members [post]
// @ID addOrUpdateMember
// @Security BearerAuth
func AddOrUpdateMember(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req memberUpsertReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "owner" && role != "admin" && role != "member" {
utils.WriteError(w, 400, "validation_error", "role must be owner|admin|member")
return
}
var m models.Membership
tx := db.Where("user_id = ? AND organization_id = ?", req.UserID, oid).First(&m)
if tx.Error == nil {
// update
if err := db.Model(&m).Update("role", role).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
} else if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
m = models.Membership{UserID: req.UserID, OrganizationID: oid, Role: role}
if err := db.Create(&m).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
} else {
utils.WriteError(w, 500, "db_error", tx.Error.Error())
return
}
// make response
var ue models.UserEmail
_ = db.Where("user_id = ? AND is_primary = true", req.UserID).First(&ue).Error
utils.WriteJSON(w, 200, memberOut{
UserID: req.UserID, Email: ue.Email, Role: m.Role,
})
}
}
// RemoveMember godoc
// @ID RemoveMember
// @Summary Remove a member (owner/admin)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param user_id path string true "User ID (UUID)"
// @Success 204 "Removed"
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members/{user_id} [delete]
// @ID removeMember
// @Security BearerAuth
func RemoveMember(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
uid, err := uuid.Parse(chi.URLParam(r, "user_id"))
if err != nil {
utils.WriteError(w, 400, "invalid_user_id", "")
return
}
res := db.Where("user_id = ? AND organization_id = ?", uid, oid).Delete(&models.Membership{})
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
w.WriteHeader(204)
}
}
// ---------- Org API Keys (key/secret pair) ----------
type orgKeyCreateReq struct {
Name string `json:"name,omitempty" example:"automation-bot"`
ExpiresInHours *int `json:"expires_in_hours,omitempty" example:"720"`
}
type orgKeyCreateResp struct {
ID uuid.UUID `json:"id"`
Name string `json:"name,omitempty"`
Scope string `json:"scope"` // "org"
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
OrgKey string `json:"org_key"` // shown once:
OrgSecret string `json:"org_secret"` // shown once:
}
// ListOrgKeys godoc
// @ID ListOrgKeys
// @Summary List org-scoped API keys (no secrets)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {array} models.APIKey
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys [get]
// @ID listOrgKeys
// @Security BearerAuth
func ListOrgKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "")
return
}
var keys []models.APIKey
if err := db.Where("org_id = ? AND scope = ?", oid, "org").
Order("created_at desc").
Find(&keys).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
// SecretHash must not be exposed; your json tags likely hide it already.
utils.WriteJSON(w, 200, keys)
}
}
// CreateOrgKey godoc
// @ID CreateOrgKey
// @Summary Create org key/secret pair (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body orgKeyCreateReq true "Key name + optional expiry"
// @Success 201 {object} orgKeyCreateResp
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys [post]
// @ID createOrgKey
// @Security BearerAuth
func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req orgKeyCreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
// generate
keySuffix, err := randomB64URL(16)
if err != nil {
utils.WriteError(w, 500, "entropy_error", err.Error())
return
}
sec, err := randomB64URL(32)
if err != nil {
utils.WriteError(w, 500, "entropy_error", err.Error())
return
}
orgKey := "org_" + keySuffix
secretPlain := sec
keyHash := auth.SHA256Hex(orgKey)
secretHash, err := auth.HashSecretArgon2id(secretPlain)
if err != nil {
utils.WriteError(w, 500, "hash_error", err.Error())
return
}
var exp *time.Time
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
e := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
exp = &e
}
rec := models.APIKey{
OrgID: &oid,
Scope: "org",
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, 201, orgKeyCreateResp{
ID: rec.ID,
Name: rec.Name,
Scope: rec.Scope,
CreatedAt: rec.CreatedAt,
ExpiresAt: rec.ExpiresAt,
OrgKey: orgKey,
OrgSecret: secretPlain,
})
}
}
// DeleteOrgKey godoc
// @ID DeleteOrgKey
// @Summary Delete org key (owner/admin)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param key_id path string true "Key ID (UUID)"
// @Success 204 "Deleted"
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys/{key_id} [delete]
// @ID deleteOrgKey
// @Security BearerAuth
func DeleteOrgKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
kid, err := uuid.Parse(chi.URLParam(r, "key_id"))
if err != nil {
utils.WriteError(w, 400, "invalid_key_id", "")
return
}
res := db.Where("id = ? AND org_id = ? AND scope = ?", kid, oid, "org").Delete(&models.APIKey{})
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "key not found")
return
}
w.WriteHeader(204)
}
}

View File

@@ -0,0 +1,388 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListServers godoc
// @ID ListServers
// @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
// @Param role query string false "Filter by role"
// @Success 200 {array} dto.ServerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list servers"
// @Router /servers [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListServers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
if !validStatus(s) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
q = q.Where("status = ?", strings.ToLower(s))
}
if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
q = q.Where("role = ?", role)
}
var rows []models.Server
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list servers")
return
}
out := make([]dto.ServerResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.ServerResponse{
ID: row.ID,
OrganizationID: row.OrganizationID,
Hostname: row.Hostname,
PublicIPAddress: row.PublicIPAddress,
PrivateIPAddress: row.PrivateIPAddress,
SSHUser: row.SSHUser,
SshKeyID: row.SshKeyID,
Role: row.Role,
Status: row.Status,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetServer godoc
// @ID GetServer
// @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /servers/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var row models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
utils.WriteJSON(w, http.StatusOK, row)
}
}
// CreateServer godoc
// @ID CreateServer
// @Summary Create server (org scoped)
// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateServerRequest true "Server payload"
// @Success 201 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /servers [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
req.Status = strings.ToLower(strings.TrimSpace(req.Status))
pub := strings.TrimSpace(req.PublicIPAddress)
if req.PrivateIPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "private_ip_address, ssh_user, ssh_key_id and role are required")
return
}
if req.Status != "" && !validStatus(req.Status) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
if req.Role == "bastion" && pub == "" {
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
return
}
keyID, err := uuid.Parse(req.SshKeyID)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
return
}
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
return
}
var publicPtr *string
if pub != "" {
publicPtr = &pub
}
s := models.Server{
OrganizationID: orgID,
Hostname: req.Hostname,
PublicIPAddress: publicPtr,
PrivateIPAddress: req.PrivateIPAddress,
SSHUser: req.SSHUser,
SshKeyID: keyID,
Role: req.Role,
Status: "pending",
}
if req.Status != "" {
s.Status = strings.ToLower(req.Status)
}
if err := db.Create(&s).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create server")
return
}
utils.WriteJSON(w, http.StatusCreated, s)
}
}
// UpdateServer godoc
// @ID UpdateServer
// @Summary Update server (org scoped)
// @Description Partially update fields; changing ssh_key_id validates ownership.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Param body body dto.UpdateServerRequest true "Fields to update"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /servers/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
var req dto.UpdateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
next := server
if req.Hostname != nil {
next.Hostname = *req.Hostname
}
if req.PrivateIPAddress != nil {
next.PrivateIPAddress = *req.PrivateIPAddress
}
if req.PublicIPAddress != nil {
next.PublicIPAddress = req.PublicIPAddress
}
if req.SSHUser != nil {
next.SSHUser = *req.SSHUser
}
if req.Role != nil {
next.Role = *req.Role
}
if req.Status != nil {
st := strings.ToLower(strings.TrimSpace(*req.Status))
if !validStatus(st) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
next.Status = st
}
if req.SshKeyID != nil {
keyID, err := uuid.Parse(*req.SshKeyID)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
return
}
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
return
}
next.SshKeyID = keyID
}
if strings.EqualFold(next.Role, "bastion") &&
(next.PublicIPAddress == nil || strings.TrimSpace(*next.PublicIPAddress) == "") {
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
return
}
if err := db.Save(&next).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to update server")
return
}
utils.WriteJSON(w, http.StatusOK, server)
}
}
// DeleteServer godoc
// @ID DeleteServer
// @Summary Delete server (org scoped)
// @Description Permanently deletes the server.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /servers/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&models.Server{}).Error; err != nil {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Server{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete server")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// --- Helpers ---
func validStatus(status string) bool {
switch strings.ToLower(status) {
case "pending", "provisioning", "ready", "failed", "":
return true
default:
return false
}
}
func ensureKeyBelongsToOrg(orgID, keyID uuid.UUID, db *gorm.DB) error {
var k models.SshKey
if err := db.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("ssh key not found for this organization")
}
return err
}
return nil
}

553
internal/handlers/ssh.go Normal file
View File

@@ -0,0 +1,553 @@
package handlers
import (
"archive/zip"
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// ListPublicSshKeys godoc
// @ID ListPublicSshKeys
// @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.SshResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list keys"
// @Router /ssh [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListPublicSshKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var rows []models.SshKey
if err := db.Where("organization_id = ?", orgID).Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list ssh keys")
return
}
out := make([]dto.SshResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.SshResponse{
ID: row.ID,
OrganizationID: row.OrganizationID,
Name: row.Name,
PublicKey: row.PublicKey,
Fingerprint: row.Fingerprint,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateSSHKey
// @ID CreateSSHKey
// @Summary Create ssh keypair (org scoped)
// @Description Generates an RSA or ED25519 keypair, saves it, and returns metadata. For RSA you may set bits (2048/3072/4096). Default is 4096. ED25519 ignores bits.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateSSHRequest true "Key generation options"
// @Success 201 {object} dto.SshResponse
// @Failure 400 {string} string "invalid json / invalid bits"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "generation/create failed"
// @Router /ssh [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateSSHRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
return
}
keyType := "rsa"
if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
keyType = strings.ToLower(strings.TrimSpace(*req.Type))
}
if keyType != "rsa" && keyType != "ed25519" {
utils.WriteError(w, http.StatusBadRequest, "invalid_type", "invalid type (rsa|ed25519)")
return
}
var (
privPEM string
pubAuth string
err error
)
switch keyType {
case "rsa":
bits := 4096
if req.Bits != nil {
if !allowedBits(*req.Bits) {
utils.WriteError(w, http.StatusBadRequest, "invalid_bits", "invalid bits (allowed: 2048, 3072, 4096)")
return
}
bits = *req.Bits
}
privPEM, pubAuth, err = GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
case "ed25519":
if req.Bits != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_bits_for_type", "bits is only valid for RSA")
return
}
privPEM, pubAuth, err = GenerateEd25519PEMAndAuthorized(strings.TrimSpace(req.Comment))
}
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "keygen_failure", "key generation failed")
return
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, []byte(privPEM), db)
if err != nil {
http.Error(w, "encryption failed", http.StatusInternalServerError)
return
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "ssh_failure", "ssh public key parsing failed")
return
}
fp := ssh.FingerprintSHA256(parsed)
key := models.SshKey{
OrganizationID: orgID,
Name: req.Name,
PublicKey: pubAuth,
EncryptedPrivateKey: cipher,
PrivateIV: iv,
PrivateTag: tag,
Fingerprint: fp,
}
if err := db.Create(&key).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create ssh key")
return
}
utils.WriteJSON(w, http.StatusCreated, dto.SshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
}
}
// GetSSHKey godoc
// @ID GetSSHKey
// @Summary Get ssh key by ID (org scoped)
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param reveal query bool false "Reveal private key PEM"
// @Success 200 {object} dto.SshResponse
// @Success 200 {object} dto.SshRevealResponse "When reveal=true"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /ssh/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
var key models.SshKey
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
if r.URL.Query().Get("reveal") != "true" {
utils.WriteJSON(w, http.StatusOK, dto.SshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
return
}
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
utils.WriteJSON(w, http.StatusOK, dto.SshRevealResponse{
SshResponse: dto.SshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
},
PrivateKey: plain,
})
}
}
// DeleteSSHKey godoc
// @ID DeleteSSHKey
// @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /ssh/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
Delete(&models.SshKey{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete ssh key")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DownloadSSHKey godoc
// @ID DownloadSSHKey
// @Summary Download ssh key files by ID (org scoped)
// @Description Download `part=public|private|both` of the keypair. `both` returns a zip file.
// @Tags Ssh
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param part query string true "Which part to download" Enums(public,private,both)
// @Success 200 {string} string "file content"
// @Failure 400 {string} string "invalid id / invalid part"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "download failed"
// @Router /ssh/{id}/download [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
var key models.SshKey
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
part := strings.ToLower(r.URL.Query().Get("part"))
if part == "" {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
mode := strings.ToLower(r.URL.Query().Get("mode"))
if mode != "" && mode != "json" {
utils.WriteError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (json|attachment[default])")
return
}
if mode == "json" {
resp := dto.SshMaterialJSON{
ID: key.ID.String(),
Name: key.Name,
Fingerprint: key.Fingerprint,
}
switch part {
case "public":
pub := key.PublicKey
resp.PublicKey = &pub
resp.Filenames = []string{fmt.Sprintf("id_rsa_%s.pub", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
case "private":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
resp.PrivatePEM = &plain
resp.Filenames = []string{fmt.Sprintf("id_rsa_%s.pem", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
case "both":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
b64 := utils.EncodeB64(buf.Bytes())
resp.ZipBase64 = &b64
resp.Filenames = []string{
fmt.Sprintf("id_rsa_%s.zip", key.ID.String()),
fmt.Sprintf("id_rsa_%s.pem", key.ID.String()),
fmt.Sprintf("id_rsa_%s.pub", key.ID.String()),
}
utils.WriteJSON(w, http.StatusOK, resp)
return
default:
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
}
prefix := keyFilenamePrefix(key.PublicKey)
switch part {
case "public":
filename := fmt.Sprintf("%s_%s.pub", prefix, key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(key.PublicKey))
return
case "private":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
filename := fmt.Sprintf("%s_%s.pem", prefix, key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(plain))
return
case "both":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("%s_%s.pem", prefix, key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s_%s.pub", prefix, key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write(buf.Bytes())
return
default:
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
}
}
// --- Helpers ---
func allowedBits(b int) bool {
return b == 2048 || b == 3072 || b == 4096
}
func GenerateRSA(bits int) (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, bits)
}
func RSAPrivateToPEMAndAuthorized(priv *rsa.PrivateKey, comment string) (privPEM string, authorized string, err error) {
der := x509.MarshalPKCS1PrivateKey(priv)
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err = pem.Encode(&buf, block); err != nil {
return "", "", err
}
pub, err := ssh.NewPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}
func GenerateRSAPEMAndAuthorized(bits int, comment string) (string, string, error) {
priv, err := GenerateRSA(bits)
if err != nil {
return "", "", err
}
return RSAPrivateToPEMAndAuthorized(priv, comment)
}
func toZipFile(filename string, content []byte, zw *zip.Writer) error {
f, err := zw.Create(filename)
if err != nil {
return err
}
_, err = f.Write(content)
return err
}
func keyFilenamePrefix(pubAuth string) string {
// OpenSSH authorized keys start with the algorithm name
if strings.HasPrefix(pubAuth, "ssh-ed25519 ") {
return "id_ed25519"
}
// default to RSA
return "id_rsa"
}
func GenerateEd25519PEMAndAuthorized(comment string) (privPEM string, authorized string, err error) {
// Generate ed25519 keypair
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
// Private: PKCS#8 PEM
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", "", err
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err := pem.Encode(&buf, block); err != nil {
return "", "", err
}
// Public: OpenSSH authorized_key
sshPub, err := ssh.NewPublicKey(ed25519.PublicKey(pub))
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}

335
internal/handlers/taints.go Normal file
View File

@@ -0,0 +1,335 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListTaints godoc
// @ID ListTaints
// @Summary List node pool taints (org scoped)
// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
// @Param value query string false "Exact value"
// @Param q query string false "key contains (case-insensitive)"
// @Success 200 {array} dto.TaintResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /taints [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListTaints(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
q = q.Where(`key = ?`, key)
}
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
q = q.Where(`value = ?`, val)
}
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
}
var rows []models.Taint
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.TaintResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.TaintResponse{
ID: row.ID,
Key: row.Key,
Value: row.Value,
Effect: row.Effect,
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetTaint godoc
// @ID GetTaint
// @Summary Get node taint by ID (org scoped)
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 200 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /taints/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var row models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: row.ID,
Key: row.Key,
Value: row.Value,
Effect: row.Effect,
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateTaint godoc
// @ID CreateTaint
// @Summary Create node taint (org scoped)
// @Description Creates a taint.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateTaintRequest true "Taint payload"
// @Success 201 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /taints [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Key = strings.TrimSpace(req.Key)
req.Value = strings.TrimSpace(req.Value)
req.Effect = strings.TrimSpace(req.Effect)
if req.Key == "" || req.Value == "" || req.Effect == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value/effect")
return
}
if _, ok := allowedEffects[req.Effect]; !ok {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
return
}
t := models.Taint{
OrganizationID: orgID,
Key: req.Key,
Value: req.Value,
Effect: req.Effect,
}
if err := db.Create(&t).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
}
utils.WriteJSON(w, http.StatusCreated, out)
}
}
// UpdateTaint godoc
// @ID UpdateTaint
// @Summary Update node taint (org scoped)
// @Description Partially update taint fields.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param body body dto.UpdateTaintRequest true "Fields to update"
// @Success 200 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /taints/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var t models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.UpdateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
next := t
if req.Key != nil {
next.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
next.Value = strings.TrimSpace(*req.Value)
}
if req.Effect != nil {
e := strings.TrimSpace(*req.Effect)
if e == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing effect")
return
}
if _, ok := allowedEffects[e]; !ok {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
return
}
next.Effect = e
}
if err := db.Save(&next).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: next.ID,
Key: next.Key,
Value: next.Value,
Effect: next.Effect,
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// DeleteTaint godoc
// @ID DeleteTaint
// @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /taints/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var row models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Delete(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// --- Helpers ---
var allowedEffects = map[string]struct{}{
"NoSchedule": {},
"PreferNoSchedule": {},
"NoExecute": {},
}

View File

@@ -0,0 +1,35 @@
package keys
import (
"encoding/base64"
"errors"
"strings"
)
func decode32ByteKey(s string) ([]byte, error) {
try := func(enc *base64.Encoding, v string) ([]byte, bool) {
if b, err := enc.DecodeString(v); err == nil && len(b) == 32 {
return b, true
}
return nil, false
}
// Try raw (no padding) variants first
if b, ok := try(base64.RawURLEncoding, s); ok {
return b, nil
}
if b, ok := try(base64.RawStdEncoding, s); ok {
return b, nil
}
// Try padded variants (add padding if missing)
pad := func(v string) string { return v + strings.Repeat("=", (4-len(v)%4)%4) }
if b, ok := try(base64.URLEncoding, pad(s)); ok {
return b, nil
}
if b, ok := try(base64.StdEncoding, pad(s)); ok {
return b, nil
}
return nil, errors.New("key must be 32 bytes in base64/base64url")
}

5
internal/keys/export.go Normal file
View File

@@ -0,0 +1,5 @@
package keys
func Decrypt(encKeyB64, enc string) ([]byte, error) {
return decryptAESGCM(encKeyB64, enc)
}

149
internal/keys/keys.go Normal file
View File

@@ -0,0 +1,149 @@
package keys
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type GenOpts struct {
Alg string // "RS256"|"RS384"|"RS512"|"EdDSA"
Bits int // RSA bits (2048/3072/4096). ignored for EdDSA
KID string // optional; if empty we generate one
NBF *time.Time
EXP *time.Time
}
func GenerateAndStore(db *gorm.DB, encKeyB64 string, opts GenOpts) (*models.SigningKey, error) {
if opts.KID == "" {
opts.KID = uuid.NewString()
}
var pubPEM, privPEM []byte
var alg = opts.Alg
switch alg {
case "RS256", "RS384", "RS512":
if opts.Bits == 0 {
opts.Bits = 3072
}
priv, err := rsa.GenerateKey(rand.Reader, opts.Bits)
if err != nil {
return nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(priv)
privPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
pubDER := x509.MarshalPKCS1PublicKey(&priv.PublicKey)
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubDER})
case "EdDSA":
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
privPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
pubDER, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return nil, err
}
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
default:
return nil, fmt.Errorf("unsupported alg: %s", alg)
}
privateOut := string(privPEM)
if encKeyB64 != "" {
enc, err := encryptAESGCM(encKeyB64, privPEM)
if err != nil {
return nil, err
}
privateOut = enc
}
rec := models.SigningKey{
Kid: opts.KID,
Alg: alg,
Use: "sig",
IsActive: true,
PublicPEM: string(pubPEM),
PrivatePEM: privateOut,
NotBefore: opts.NBF,
ExpiresAt: opts.EXP,
}
if err := db.Create(&rec).Error; err != nil {
return nil, err
}
return &rec, nil
}
func encryptAESGCM(b64 string, plaintext []byte) (string, error) {
key, err := decode32ByteKey(b64)
if err != nil {
return "", err
}
if len(key) != 32 {
return "", errors.New("JWT_PRIVATE_ENC_KEY must be 32 bytes (base64url)")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return "", err
}
out := aead.Seal(nonce, nonce, plaintext, nil)
return "enc:aesgcm:" + base64.RawStdEncoding.EncodeToString(out), nil
}
func decryptAESGCM(b64 string, enc string) ([]byte, error) {
if !bytes.HasPrefix([]byte(enc), []byte("enc:aesgcm:")) {
return nil, errors.New("not encrypted")
}
key, err := decode32ByteKey(b64)
if err != nil {
return nil, err
}
blob, err := base64.RawStdEncoding.DecodeString(enc[len("enc:aesgcm:"):])
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := aead.NonceSize()
if len(blob) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ct := blob[:nonceSize], blob[nonceSize:]
return aead.Open(nil, nonce, ct, nil)
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Account struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"-"`
Provider string `gorm:"not null" json:"provider"`
Subject string `gorm:"not null" json:"subject"`
Email *string `json:"email,omitempty"`
EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
Profile datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"profile"`
SecretHash *string `json:"-"`
CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
)
type APIKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Scope string `gorm:"not null;default:''" json:"scope"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
SecretHash *string `json:"-"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
Prefix *string `json:"prefix,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
"github.com/google/uuid"
)
type MasterKey struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
Key string `gorm:"not null"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"`
}

View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Membership struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"-"`
OrganizationID uuid.UUID `gorm:"index;not null" json:"org_id" format:"uuid"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"-"`
Role string `gorm:"not null;default:'member'" json:"role"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
)
type NodePool struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
//Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
//Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
//Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/google/uuid"
)
type OrganizationKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
MasterKeyID uuid.UUID `gorm:"type:uuid;not null"`
MasterKey MasterKey `gorm:"foreignKey:MasterKeyID;constraint:OnDelete:CASCADE" json:"master_key"`
EncryptedKey string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,16 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Organization struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null" json:"name"`
Domain *string `gorm:"index" json:"domain"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,17 @@
package models
import (
"time"
"github.com/google/uuid"
)
type RefreshToken struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id"`
FamilyID uuid.UUID `gorm:"type:uuid;index;not null" json:"family_id"`
TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
}

36
internal/models/server.go Normal file
View File

@@ -0,0 +1,36 @@
package models
import (
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Server struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Hostname string `json:"hostname"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `gorm:"not null" json:"private_ip_address"`
SSHUser string `gorm:"not null" json:"ssh_user"`
SshKeyID uuid.UUID `gorm:"type:uuid;not null" json:"ssh_key_id"`
SshKey SshKey `gorm:"foreignKey:SshKeyID" json:"ssh_key"`
Role string `gorm:"not null" json:"role"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'" json:"status"` // pending, provisioning, ready, failed
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}
func (s *Server) BeforeSave(tx *gorm.DB) error {
role := strings.ToLower(strings.TrimSpace(s.Role))
if role == "bastion" {
if s.PublicIPAddress == nil || strings.TrimSpace(*s.PublicIPAddress) == "" {
return errors.New("public_ip_address is required for role=bastion")
}
}
return nil
}

View File

@@ -0,0 +1,22 @@
package models
import (
"time"
"github.com/google/uuid"
)
type SigningKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Kid string `gorm:"uniqueIndex;not null" json:"kid"` // key id (header 'kid')
Alg string `gorm:"not null" json:"alg"` // RS256|RS384|RS512|EdDSA
Use string `gorm:"not null;default:'sig'" json:"use"` // "sig"
IsActive bool `gorm:"not null;default:true" json:"is_active"`
PublicPEM string `gorm:"type:text;not null" json:"-"`
PrivatePEM string `gorm:"type:text;not null" json:"-"`
NotBefore *time.Time `json:"-"`
ExpiresAt *time.Time `json:"-"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
RotatedFrom *uuid.UUID `json:"-"` // previous key id, if any
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
)
type SshKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
PublicKey string `gorm:"not null"`
EncryptedPrivateKey string `gorm:"not null"`
PrivateIV string `gorm:"not null"`
PrivateTag string `gorm:"not null"`
Fingerprint string `gorm:"not null;index" json:"fingerprint"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

18
internal/models/taint.go Normal file
View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Taint struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
Effect string `gorm:"not null" json:"effect"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

18
internal/models/user.go Normal file
View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
DisplayName *string `json:"display_name,omitempty"`
PrimaryEmail *string `json:"primary_email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
IsDisabled bool `json:"is_disabled"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,19 @@
package models
import (
"time"
"github.com/google/uuid"
)
type UserEmail struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"user"`
Email string `gorm:"not null" json:"email"`
IsVerified bool `gorm:"not null;default:false" json:"is_verified"`
IsPrimary bool `gorm:"not null;default:false" json:"is_primary"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

85
internal/utils/crypto.go Normal file
View File

@@ -0,0 +1,85 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
ErrNoActiveMasterKey = errors.New("no active master key found")
ErrInvalidOrgID = errors.New("invalid organization ID")
ErrCredentialNotFound = errors.New("credential not found")
ErrInvalidMasterKeyLen = errors.New("invalid master key length")
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
return b, nil
}
func encryptAESGCM(plaintext, key []byte) (cipherNoTag, iv, tag []byte, _ error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != 12 {
return nil, nil, nil, fmt.Errorf("unexpected nonce size: %d", gcm.NonceSize())
}
iv, err = randomBytes(gcm.NonceSize())
if err != nil {
return nil, nil, nil, err
}
// Gos GCM returns ciphertext||tag, with 16-byte tag.
cipherWithTag := gcm.Seal(nil, iv, plaintext, nil)
if len(cipherWithTag) < 16 {
return nil, nil, nil, errors.New("ciphertext too short")
}
tagLen := 16
cipherNoTag = cipherWithTag[:len(cipherWithTag)-tagLen]
tag = cipherWithTag[len(cipherWithTag)-tagLen:]
return cipherNoTag, iv, tag, nil
}
func decryptAESGCM(cipherNoTag, key, iv, tag []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != len(iv) {
return nil, fmt.Errorf("bad nonce size: %d", len(iv))
}
// Reattach tag
cipherWithTag := append(append([]byte{}, cipherNoTag...), tag...)
plain, err := gcm.Open(nil, iv, cipherWithTag, nil)
if err != nil {
return nil, fmt.Errorf("gcm open: %w", err)
}
return plain, nil
}
func EncodeB64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func DecodeB64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}

27
internal/utils/helpers.go Normal file
View File

@@ -0,0 +1,27 @@
package utils
import (
"encoding/json"
"net/http"
)
// ErrorResponse is a simple, reusable error payload.
// swagger:model ErrorResponse
type ErrorResponse struct {
// A machine-readable error code, e.g. "validation_error"
// example: validation_error
Code string `json:"code"`
// Human-readable message
// example: slug is required
Message string `json:"message"`
}
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, status int, code, msg string) {
WriteJSON(w, status, ErrorResponse{Code: code, Message: msg})
}

107
internal/utils/keys.go Normal file
View File

@@ -0,0 +1,107 @@
package utils
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func getMasterKey(db *gorm.DB) ([]byte, error) {
var mk models.MasterKey
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
keyBytes, err := base64.StdEncoding.DecodeString(mk.Key)
if err != nil {
return nil, fmt.Errorf("decoding master key: %w", err)
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("%w: got %d, want 32", ErrInvalidMasterKeyLen, len(keyBytes))
}
return keyBytes, nil
}
func getOrCreateTenantKey(orgID string, db *gorm.DB) ([]byte, error) {
var orgKey models.OrganizationKey
err := db.Where("organization_id = ?", orgID).First(&orgKey).Error
if err == nil {
encKeyB64 := orgKey.EncryptedKey
ivB64 := orgKey.IV
tagB64 := orgKey.Tag
encryptedKey, err := DecodeB64(encKeyB64)
if err != nil {
return nil, fmt.Errorf("decode enc key: %w", err)
}
iv, err := DecodeB64(ivB64)
if err != nil {
return nil, fmt.Errorf("decode iv: %w", err)
}
tag, err := DecodeB64(tagB64)
if err != nil {
return nil, fmt.Errorf("decode tag: %w", err)
}
masterKey, err := getMasterKey(db)
if err != nil {
return nil, err
}
return decryptAESGCM(encryptedKey, masterKey, iv, tag)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Create new tenant key and wrap with the current master key
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidOrgID, err)
}
tenantKey, err := randomBytes(32)
if err != nil {
return nil, fmt.Errorf("tenant key gen: %w", err)
}
masterKey, err := getMasterKey(db)
if err != nil {
return nil, err
}
encrypted, iv, tag, err := encryptAESGCM(tenantKey, masterKey)
if err != nil {
return nil, fmt.Errorf("wrap tenant key: %w", err)
}
var mk models.MasterKey
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
orgKey = models.OrganizationKey{
OrganizationID: orgUUID,
MasterKeyID: mk.ID,
EncryptedKey: EncodeB64(encrypted),
IV: EncodeB64(iv),
Tag: EncodeB64(tag),
}
if err := db.Create(&orgKey).Error; err != nil {
return nil, fmt.Errorf("persist org key: %w", err)
}
return tenantKey, nil
}

View File

@@ -0,0 +1,44 @@
package utils
import (
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
func EncryptForOrg(orgID uuid.UUID, plaintext []byte, db *gorm.DB) (cipherB64, ivB64, tagB64 string, err error) {
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
if err != nil {
return "", "", "", err
}
ct, iv, tag, err := encryptAESGCM(plaintext, tenantKey)
if err != nil {
return "", "", "", err
}
return EncodeB64(ct), EncodeB64(iv), EncodeB64(tag), nil
}
func DecryptForOrg(orgID uuid.UUID, cipherB64, ivB64, tagB64 string, db *gorm.DB) (string, error) {
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
if err != nil {
return "", err
}
ct, err := DecodeB64(cipherB64)
if err != nil {
return "", fmt.Errorf("decode cipher: %w", err)
}
iv, err := DecodeB64(ivB64)
if err != nil {
return "", fmt.Errorf("decode iv: %w", err)
}
tag, err := DecodeB64(tagB64)
if err != nil {
return "", fmt.Errorf("decode tag: %w", err)
}
plain, err := decryptAESGCM(ct, tenantKey, iv, tag)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@@ -0,0 +1,36 @@
package version
import (
"fmt"
"runtime"
"runtime/debug"
)
var (
Version = "dev"
Commit = "none"
Date = "unknown"
BuiltBy = "local"
)
func Info() string {
v := fmt.Sprintf("Version: %s\nCommit: %s\nBuilt: %s\nBuiltBy: %s\nGo: %s %s/%s",
Version, Commit, Date, BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
// Include VCS info from embedded build metadata (if available)
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs":
v += fmt.Sprintf("\nVCS: %s", s.Value)
case "vcs.revision":
v += fmt.Sprintf("\nRevision: %s", s.Value)
case "vcs.time":
v += fmt.Sprintf("\nCommitTime: %s", s.Value)
case "vcs.modified":
v += fmt.Sprintf("\nModified: %s", s.Value)
}
}
}
return v
}

16
internal/web/devproxy.go Normal file
View File

@@ -0,0 +1,16 @@
package web
import (
"net/http"
"net/http/httputil"
"net/url"
)
func DevProxy(target string) (http.Handler, error) {
u, err := url.Parse(target)
if err != nil {
return nil, err
}
p := httputil.NewSingleHostReverseProxy(u)
return p, nil
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

15
internal/web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
<script type="module" crossorigin src="/assets/index-CFwByDWI.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-BZmgNp9X.js">
<link rel="stylesheet" crossorigin href="/assets/index-BvUUUOIq.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

BIN
internal/web/dist/index.html.br vendored Normal file

Binary file not shown.

BIN
internal/web/dist/index.html.gz vendored Normal file

Binary file not shown.

1
internal/web/dist/vite.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

239
internal/web/static.go Normal file
View File

@@ -0,0 +1,239 @@
package web
import (
"embed"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"time"
)
// NOTE: Vite outputs to web/dist with assets in dist/assets.
// If you add more nested folders in the future, include them here too.
//go:embed dist
var distFS embed.FS
// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
type spaFileSystem struct {
fs fs.FS
}
func (s spaFileSystem) Open(name string) (fs.File, error) {
// Normalize, strip leading slash
if strings.HasPrefix(name, "/") {
name = name[1:]
}
// Try exact file
f, err := s.fs.Open(name)
if err == nil {
return f, nil
}
// If the requested file doesn't exist, fall back to index.html for SPA routes
// BUT only if it's not obviously a static asset extension
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg",
".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz":
return nil, fs.ErrNotExist
}
return s.fs.Open("index.html")
}
func newDistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
func SPAHandler() (http.Handler, error) {
sub, err := newDistFS()
if err != nil {
return nil, err
}
spa := spaFileSystem{fs: sub}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") ||
r.URL.Path == "/api" ||
strings.HasPrefix(r.URL.Path, "/swagger") ||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
http.NotFound(w, r)
return
}
filePath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if filePath == "" {
filePath = "index.html"
}
// Try compressed variants for assets and HTML
// NOTE: we only change *Content-Encoding*; Content-Type derives from original ext
// Always vary on Accept-Encoding
w.Header().Add("Vary", "Accept-Encoding")
enc := r.Header.Get("Accept-Encoding")
if tryServeCompressed(w, r, spa, filePath, enc) {
return
}
// Fallback: normal open (or SPA fallback)
f, err := spa.Open(filePath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
if strings.HasSuffix(filePath, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
http.ServeContent(w, r, filePath, modTime, file{f})
}), nil
}
func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool {
wantsBR := strings.Contains(enc, "br")
wantsGZ := strings.Contains(enc, "gzip")
type cand struct {
logical string // MIME/type decision uses this (uncompressed name)
physical string // actual file we open (with .br/.gz)
enc string
}
var cands []cand
// 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try)
if wantsBR {
cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"})
}
if wantsGZ {
cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"})
}
// 2) SPA route: fall back to compressed index.html
if filepath.Ext(filePath) == "" {
if wantsBR {
cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"})
}
if wantsGZ {
cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"})
}
}
for _, c := range cands {
f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback
if err != nil {
continue
}
defer f.Close()
// Cache headers
if strings.HasSuffix(c.logical, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
if ct := mimeByExt(path.Ext(c.logical)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Encoding", c.enc)
w.Header().Add("Vary", "Accept-Encoding")
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
// Serve the precompressed bytes
http.ServeContent(w, r, c.physical, modTime, file{f})
return true
}
return false
}
func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool {
cf := filePath + ext
f, err := spa.Open(cf)
if err != nil {
return false
}
defer f.Close()
// Set caching headers
if strings.HasSuffix(filePath, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
// Preserve original content type by extension of *uncompressed* file
if ct := mimeByExt(path.Ext(filePath)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Encoding", encoding)
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
// Serve the compressed bytes as an io.ReadSeeker if possible
http.ServeContent(w, r, cf, modTime, file{f})
return true
}
func mimeByExt(ext string) string {
switch strings.ToLower(ext) {
case ".html":
return "text/html; charset=utf-8"
case ".js":
return "application/javascript"
case ".css":
return "text/css; charset=utf-8"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".ico":
return "image/x-icon"
case ".woff2":
return "font/woff2"
case ".woff":
return "font/woff"
default:
return "" // let Go sniff if empty
}
}
// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
type file struct{ fs.File }
func (f file) Seek(offset int64, whence int) (int64, error) {
if s, ok := f.File.(io.Seeker); ok {
return s.Seek(offset, whence)
}
// Fallback: not seekable
return 0, fs.ErrInvalid
}

37
main.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import "github.com/glueops/autoglue/cmd"
// @title AutoGlue API
// @version 1.0
// @description API for managing K3s clusters across cloud providers
// @contact.name GlueOps
// @BasePath /api/v1
// @schemes http https
// @host localhost:8080
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token authentication
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-API-KEY
// @description User API key
// @securityDefinitions.apikey OrgKeyAuth
// @in header
// @name X-ORG-KEY
// @description Org-level key/secret authentication
// @securityDefinitions.apikey OrgSecretAuth
// @in header
// @name X-ORG-SECRET
// @description Org-level secret
func main() {
cmd.Execute()
}

7
openapitools.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.17.0"
}
}

10
postgres/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM postgres:latest@sha256:feff5b24fedd610975a1f5e743c51a4b360437f4dc3a11acf740dcd708f413f6
RUN cd /var/lib/postgresql/ && \
openssl req -new -text -passout pass:abcd -subj /CN=localhost -out server.req -keyout privkey.pem && \
openssl rsa -in privkey.pem -passin pass:abcd -out server.key && \
openssl req -x509 -in server.req -text -key server.key -out server.crt && \
chmod 600 server.key && \
chown postgres:postgres server.key
CMD ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/var/lib/postgresql/server.crt", "-c", "ssl_key_file=/var/lib/postgresql/server.key" ]

24
sdk/go/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof

View File

@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@@ -0,0 +1,86 @@
.gitignore
.travis.yml
README.md
api/openapi.yaml
api_auth.go
api_me.go
api_me_api_keys.go
api_orgs.go
api_servers.go
api_ssh.go
api_taints.go
client.go
configuration.go
docs/AuthAPI.md
docs/DtoAuthStartResponse.md
docs/DtoCreateSSHRequest.md
docs/DtoCreateServerRequest.md
docs/DtoCreateTaintRequest.md
docs/DtoJWK.md
docs/DtoJWKS.md
docs/DtoLogoutRequest.md
docs/DtoRefreshRequest.md
docs/DtoServerResponse.md
docs/DtoSshResponse.md
docs/DtoSshRevealResponse.md
docs/DtoTaintResponse.md
docs/DtoTokenPair.md
docs/DtoUpdateServerRequest.md
docs/DtoUpdateTaintRequest.md
docs/HandlersCreateUserKeyRequest.md
docs/HandlersMeResponse.md
docs/HandlersMemberOut.md
docs/HandlersMemberUpsertReq.md
docs/HandlersOrgCreateReq.md
docs/HandlersOrgKeyCreateReq.md
docs/HandlersOrgKeyCreateResp.md
docs/HandlersOrgUpdateReq.md
docs/HandlersUpdateMeRequest.md
docs/HandlersUserAPIKeyOut.md
docs/MeAPI.md
docs/MeAPIKeysAPI.md
docs/ModelsAPIKey.md
docs/ModelsOrganization.md
docs/ModelsUser.md
docs/ModelsUserEmail.md
docs/OrgsAPI.md
docs/ServersAPI.md
docs/SshAPI.md
docs/TaintsAPI.md
docs/UtilsErrorResponse.md
git_push.sh
go.mod
go.sum
model_dto_auth_start_response.go
model_dto_create_server_request.go
model_dto_create_ssh_request.go
model_dto_create_taint_request.go
model_dto_jwk.go
model_dto_jwks.go
model_dto_logout_request.go
model_dto_refresh_request.go
model_dto_server_response.go
model_dto_ssh_response.go
model_dto_ssh_reveal_response.go
model_dto_taint_response.go
model_dto_token_pair.go
model_dto_update_server_request.go
model_dto_update_taint_request.go
model_handlers_create_user_key_request.go
model_handlers_me_response.go
model_handlers_member_out.go
model_handlers_member_upsert_req.go
model_handlers_org_create_req.go
model_handlers_org_key_create_req.go
model_handlers_org_key_create_resp.go
model_handlers_org_update_req.go
model_handlers_update_me_request.go
model_handlers_user_api_key_out.go
model_models_api_key.go
model_models_organization.go
model_models_user.go
model_models_user_email.go
model_utils_error_response.go
response.go
test/api_taints_test.go
utils.go

View File

@@ -0,0 +1 @@
7.17.0

8
sdk/go/.travis.yml Normal file
View File

@@ -0,0 +1,8 @@
language: go
install:
- go get -d -v .
script:
- go build -v ./

261
sdk/go/README.md Normal file
View File

@@ -0,0 +1,261 @@
# Go API client for autoglue
API for managing K3s clusters across cloud providers
## Overview
This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [OpenAPI-spec](https://www.openapis.org/) from a remote server, you can easily generate an API client.
- API version: 1.0
- Package version: 1.0.0
- Generator version: 7.17.0
- Build package: org.openapitools.codegen.languages.GoClientCodegen
## Installation
Install the following dependencies:
```sh
go get github.com/stretchr/testify/assert
go get golang.org/x/net/context
```
Put the package under your project folder and add the following in import:
```go
import autoglue "github.com/glueops/autoglue-sdk"
```
To use a proxy, set the environment variable `HTTP_PROXY`:
```go
os.Setenv("HTTP_PROXY", "http://proxy_name:proxy_port")
```
## Configuration of Server URL
Default configuration comes with `Servers` field that contains server objects as defined in the OpenAPI specification.
### Select Server Configuration
For using other server than the one defined on index 0 set context value `autoglue.ContextServerIndex` of type `int`.
```go
ctx := context.WithValue(context.Background(), autoglue.ContextServerIndex, 1)
```
### Templated Server URL
Templated server URL is formatted using default variables from configuration or from context value `autoglue.ContextServerVariables` of type `map[string]string`.
```go
ctx := context.WithValue(context.Background(), autoglue.ContextServerVariables, map[string]string{
"basePath": "v2",
})
```
Note, enum values are always validated and all unused variables are silently ignored.
### URLs Configuration per Operation
Each operation can use different server URL defined using `OperationServers` map in the `Configuration`.
An operation is uniquely identified by `"{classname}Service.{nickname}"` string.
Similar rules for overriding default operation server index and variables applies by using `autoglue.ContextOperationServerIndices` and `autoglue.ContextOperationServerVariables` context maps.
```go
ctx := context.WithValue(context.Background(), autoglue.ContextOperationServerIndices, map[string]int{
"{classname}Service.{nickname}": 2,
})
ctx = context.WithValue(context.Background(), autoglue.ContextOperationServerVariables, map[string]map[string]string{
"{classname}Service.{nickname}": {
"port": "8443",
},
})
```
## Documentation for API Endpoints
All URIs are relative to *http://localhost:8080/api/v1*
Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
*AuthAPI* | [**AuthCallback**](docs/AuthAPI.md#authcallback) | **Get** /auth/{provider}/callback | Handle social login callback
*AuthAPI* | [**AuthStart**](docs/AuthAPI.md#authstart) | **Post** /auth/{provider}/start | Begin social login
*AuthAPI* | [**GetJWKS**](docs/AuthAPI.md#getjwks) | **Get** /.well-known/jwks.json | Get JWKS
*AuthAPI* | [**Logout**](docs/AuthAPI.md#logout) | **Post** /auth/logout | Revoke refresh token family (logout everywhere)
*AuthAPI* | [**Refresh**](docs/AuthAPI.md#refresh) | **Post** /auth/refresh | Rotate refresh token
*MeAPI* | [**GetMe**](docs/MeAPI.md#getme) | **Get** /me | Get current user profile
*MeAPI* | [**UpdateMe**](docs/MeAPI.md#updateme) | **Patch** /me | Update current user profile
*MeAPIKeysAPI* | [**CreateUserAPIKey**](docs/MeAPIKeysAPI.md#createuserapikey) | **Post** /me/api-keys | Create a new user API key
*MeAPIKeysAPI* | [**DeleteUserAPIKey**](docs/MeAPIKeysAPI.md#deleteuserapikey) | **Delete** /me/api-keys/{id} | Delete a user API key
*MeAPIKeysAPI* | [**ListUserAPIKeys**](docs/MeAPIKeysAPI.md#listuserapikeys) | **Get** /me/api-keys | List my API keys
*OrgsAPI* | [**AddOrUpdateMember**](docs/OrgsAPI.md#addorupdatemember) | **Post** /orgs/{id}/members | Add or update a member (owner/admin)
*OrgsAPI* | [**CreateOrg**](docs/OrgsAPI.md#createorg) | **Post** /orgs | Create organization
*OrgsAPI* | [**CreateOrgKey**](docs/OrgsAPI.md#createorgkey) | **Post** /orgs/{id}/api-keys | Create org key/secret pair (owner/admin)
*OrgsAPI* | [**DeleteOrg**](docs/OrgsAPI.md#deleteorg) | **Delete** /orgs/{id} | Delete organization (owner)
*OrgsAPI* | [**DeleteOrgKey**](docs/OrgsAPI.md#deleteorgkey) | **Delete** /orgs/{id}/api-keys/{key_id} | Delete org key (owner/admin)
*OrgsAPI* | [**GetOrg**](docs/OrgsAPI.md#getorg) | **Get** /orgs/{id} | Get organization
*OrgsAPI* | [**ListMembers**](docs/OrgsAPI.md#listmembers) | **Get** /orgs/{id}/members | List members in org
*OrgsAPI* | [**ListMyOrgs**](docs/OrgsAPI.md#listmyorgs) | **Get** /orgs | List organizations I belong to
*OrgsAPI* | [**ListOrgKeys**](docs/OrgsAPI.md#listorgkeys) | **Get** /orgs/{id}/api-keys | List org-scoped API keys (no secrets)
*OrgsAPI* | [**RemoveMember**](docs/OrgsAPI.md#removemember) | **Delete** /orgs/{id}/members/{user_id} | Remove a member (owner/admin)
*OrgsAPI* | [**UpdateOrg**](docs/OrgsAPI.md#updateorg) | **Patch** /orgs/{id} | Update organization (owner/admin)
*ServersAPI* | [**CreateServer**](docs/ServersAPI.md#createserver) | **Post** /servers | Create server (org scoped)
*ServersAPI* | [**DeleteServer**](docs/ServersAPI.md#deleteserver) | **Delete** /servers/{id} | Delete server (org scoped)
*ServersAPI* | [**GetServer**](docs/ServersAPI.md#getserver) | **Get** /servers/{id} | Get server by ID (org scoped)
*ServersAPI* | [**ListServers**](docs/ServersAPI.md#listservers) | **Get** /servers | List servers (org scoped)
*ServersAPI* | [**UpdateServer**](docs/ServersAPI.md#updateserver) | **Patch** /servers/{id} | Update server (org scoped)
*SshAPI* | [**CreateSSHKey**](docs/SshAPI.md#createsshkey) | **Post** /ssh | Create ssh keypair (org scoped)
*SshAPI* | [**DeleteSSHKey**](docs/SshAPI.md#deletesshkey) | **Delete** /ssh/{id} | Delete ssh keypair (org scoped)
*SshAPI* | [**DownloadSSHKey**](docs/SshAPI.md#downloadsshkey) | **Get** /ssh/{id}/download | Download ssh key files by ID (org scoped)
*SshAPI* | [**GetSSHKey**](docs/SshAPI.md#getsshkey) | **Get** /ssh/{id} | Get ssh key by ID (org scoped)
*SshAPI* | [**ListPublicSshKeys**](docs/SshAPI.md#listpublicsshkeys) | **Get** /ssh | List ssh keys (org scoped)
*TaintsAPI* | [**CreateTaint**](docs/TaintsAPI.md#createtaint) | **Post** /taints | Create node taint (org scoped)
*TaintsAPI* | [**DeleteTaint**](docs/TaintsAPI.md#deletetaint) | **Delete** /taints/{id} | Delete taint (org scoped)
*TaintsAPI* | [**GetTaint**](docs/TaintsAPI.md#gettaint) | **Get** /taints/{id} | Get node taint by ID (org scoped)
*TaintsAPI* | [**ListTaints**](docs/TaintsAPI.md#listtaints) | **Get** /taints | List node pool taints (org scoped)
*TaintsAPI* | [**UpdateTaint**](docs/TaintsAPI.md#updatetaint) | **Patch** /taints/{id} | Update node taint (org scoped)
## Documentation For Models
- [DtoAuthStartResponse](docs/DtoAuthStartResponse.md)
- [DtoCreateSSHRequest](docs/DtoCreateSSHRequest.md)
- [DtoCreateServerRequest](docs/DtoCreateServerRequest.md)
- [DtoCreateTaintRequest](docs/DtoCreateTaintRequest.md)
- [DtoJWK](docs/DtoJWK.md)
- [DtoJWKS](docs/DtoJWKS.md)
- [DtoLogoutRequest](docs/DtoLogoutRequest.md)
- [DtoRefreshRequest](docs/DtoRefreshRequest.md)
- [DtoServerResponse](docs/DtoServerResponse.md)
- [DtoSshResponse](docs/DtoSshResponse.md)
- [DtoSshRevealResponse](docs/DtoSshRevealResponse.md)
- [DtoTaintResponse](docs/DtoTaintResponse.md)
- [DtoTokenPair](docs/DtoTokenPair.md)
- [DtoUpdateServerRequest](docs/DtoUpdateServerRequest.md)
- [DtoUpdateTaintRequest](docs/DtoUpdateTaintRequest.md)
- [HandlersCreateUserKeyRequest](docs/HandlersCreateUserKeyRequest.md)
- [HandlersMeResponse](docs/HandlersMeResponse.md)
- [HandlersMemberOut](docs/HandlersMemberOut.md)
- [HandlersMemberUpsertReq](docs/HandlersMemberUpsertReq.md)
- [HandlersOrgCreateReq](docs/HandlersOrgCreateReq.md)
- [HandlersOrgKeyCreateReq](docs/HandlersOrgKeyCreateReq.md)
- [HandlersOrgKeyCreateResp](docs/HandlersOrgKeyCreateResp.md)
- [HandlersOrgUpdateReq](docs/HandlersOrgUpdateReq.md)
- [HandlersUpdateMeRequest](docs/HandlersUpdateMeRequest.md)
- [HandlersUserAPIKeyOut](docs/HandlersUserAPIKeyOut.md)
- [ModelsAPIKey](docs/ModelsAPIKey.md)
- [ModelsOrganization](docs/ModelsOrganization.md)
- [ModelsUser](docs/ModelsUser.md)
- [ModelsUserEmail](docs/ModelsUserEmail.md)
- [UtilsErrorResponse](docs/UtilsErrorResponse.md)
## Documentation For Authorization
Authentication schemes defined for the API:
### ApiKeyAuth
- **Type**: API key
- **API key parameter name**: X-API-KEY
- **Location**: HTTP header
Note, each API key must be added to a map of `map[string]APIKey` where the key is: ApiKeyAuth and passed in as the auth context for each request.
Example
```go
auth := context.WithValue(
context.Background(),
autoglue.ContextAPIKeys,
map[string]autoglue.APIKey{
"ApiKeyAuth": {Key: "API_KEY_STRING"},
},
)
r, err := client.Service.Operation(auth, args)
```
### BearerAuth
- **Type**: API key
- **API key parameter name**: Authorization
- **Location**: HTTP header
Note, each API key must be added to a map of `map[string]APIKey` where the key is: BearerAuth and passed in as the auth context for each request.
Example
```go
auth := context.WithValue(
context.Background(),
autoglue.ContextAPIKeys,
map[string]autoglue.APIKey{
"BearerAuth": {Key: "API_KEY_STRING"},
},
)
r, err := client.Service.Operation(auth, args)
```
### OrgKeyAuth
- **Type**: API key
- **API key parameter name**: X-ORG-KEY
- **Location**: HTTP header
Note, each API key must be added to a map of `map[string]APIKey` where the key is: OrgKeyAuth and passed in as the auth context for each request.
Example
```go
auth := context.WithValue(
context.Background(),
autoglue.ContextAPIKeys,
map[string]autoglue.APIKey{
"OrgKeyAuth": {Key: "API_KEY_STRING"},
},
)
r, err := client.Service.Operation(auth, args)
```
### OrgSecretAuth
- **Type**: API key
- **API key parameter name**: X-ORG-SECRET
- **Location**: HTTP header
Note, each API key must be added to a map of `map[string]APIKey` where the key is: OrgSecretAuth and passed in as the auth context for each request.
Example
```go
auth := context.WithValue(
context.Background(),
autoglue.ContextAPIKeys,
map[string]autoglue.APIKey{
"OrgSecretAuth": {Key: "API_KEY_STRING"},
},
)
r, err := client.Service.Operation(auth, args)
```
## Documentation for Utility Methods
Due to the fact that model structure members are all pointers, this package contains
a number of utility functions to easily obtain pointers to values of basic types.
Each of these functions takes a value of the given basic type and returns a pointer to it:
* `PtrBool`
* `PtrInt`
* `PtrInt32`
* `PtrInt64`
* `PtrFloat`
* `PtrFloat32`
* `PtrFloat64`
* `PtrString`
* `PtrTime`
## Author

2086
sdk/go/api/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

537
sdk/go/api_auth.go Normal file
View File

@@ -0,0 +1,537 @@
/*
AutoGlue API
API for managing K3s clusters across cloud providers
API version: 1.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package autoglue
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strings"
)
// AuthAPIService AuthAPI service
type AuthAPIService service
type ApiAuthCallbackRequest struct {
ctx context.Context
ApiService *AuthAPIService
provider string
}
func (r ApiAuthCallbackRequest) Execute() (*DtoTokenPair, *http.Response, error) {
return r.ApiService.AuthCallbackExecute(r)
}
/*
AuthCallback Handle social login callback
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param provider google|github
@return ApiAuthCallbackRequest
*/
func (a *AuthAPIService) AuthCallback(ctx context.Context, provider string) ApiAuthCallbackRequest {
return ApiAuthCallbackRequest{
ApiService: a,
ctx: ctx,
provider: provider,
}
}
// Execute executes the request
//
// @return DtoTokenPair
func (a *AuthAPIService) AuthCallbackExecute(r ApiAuthCallbackRequest) (*DtoTokenPair, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *DtoTokenPair
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthAPIService.AuthCallback")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/auth/{provider}/callback"
localVarPath = strings.Replace(localVarPath, "{"+"provider"+"}", url.PathEscape(parameterValueToString(r.provider, "provider")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiAuthStartRequest struct {
ctx context.Context
ApiService *AuthAPIService
provider string
}
func (r ApiAuthStartRequest) Execute() (*DtoAuthStartResponse, *http.Response, error) {
return r.ApiService.AuthStartExecute(r)
}
/*
AuthStart Begin social login
Returns provider authorization URL for the frontend to redirect
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param provider google|github
@return ApiAuthStartRequest
*/
func (a *AuthAPIService) AuthStart(ctx context.Context, provider string) ApiAuthStartRequest {
return ApiAuthStartRequest{
ApiService: a,
ctx: ctx,
provider: provider,
}
}
// Execute executes the request
//
// @return DtoAuthStartResponse
func (a *AuthAPIService) AuthStartExecute(r ApiAuthStartRequest) (*DtoAuthStartResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *DtoAuthStartResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthAPIService.AuthStart")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/auth/{provider}/start"
localVarPath = strings.Replace(localVarPath, "{"+"provider"+"}", url.PathEscape(parameterValueToString(r.provider, "provider")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetJWKSRequest struct {
ctx context.Context
ApiService *AuthAPIService
}
func (r ApiGetJWKSRequest) Execute() (*DtoJWKS, *http.Response, error) {
return r.ApiService.GetJWKSExecute(r)
}
/*
GetJWKS Get JWKS
Returns the JSON Web Key Set for token verification
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiGetJWKSRequest
*/
func (a *AuthAPIService) GetJWKS(ctx context.Context) ApiGetJWKSRequest {
return ApiGetJWKSRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
//
// @return DtoJWKS
func (a *AuthAPIService) GetJWKSExecute(r ApiGetJWKSRequest) (*DtoJWKS, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *DtoJWKS
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthAPIService.GetJWKS")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/.well-known/jwks.json"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiLogoutRequest struct {
ctx context.Context
ApiService *AuthAPIService
body *DtoLogoutRequest
}
// Refresh token
func (r ApiLogoutRequest) Body(body DtoLogoutRequest) ApiLogoutRequest {
r.body = &body
return r
}
func (r ApiLogoutRequest) Execute() (*http.Response, error) {
return r.ApiService.LogoutExecute(r)
}
/*
Logout Revoke refresh token family (logout everywhere)
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiLogoutRequest
*/
func (a *AuthAPIService) Logout(ctx context.Context) ApiLogoutRequest {
return ApiLogoutRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
func (a *AuthAPIService) LogoutExecute(r ApiLogoutRequest) (*http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthAPIService.Logout")
if err != nil {
return nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/auth/logout"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.body == nil {
return nil, reportError("body is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.body
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarHTTPResponse, newErr
}
return localVarHTTPResponse, nil
}
type ApiRefreshRequest struct {
ctx context.Context
ApiService *AuthAPIService
body *DtoRefreshRequest
}
// Refresh token
func (r ApiRefreshRequest) Body(body DtoRefreshRequest) ApiRefreshRequest {
r.body = &body
return r
}
func (r ApiRefreshRequest) Execute() (*DtoTokenPair, *http.Response, error) {
return r.ApiService.RefreshExecute(r)
}
/*
Refresh Rotate refresh token
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiRefreshRequest
*/
func (a *AuthAPIService) Refresh(ctx context.Context) ApiRefreshRequest {
return ApiRefreshRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
//
// @return DtoTokenPair
func (a *AuthAPIService) RefreshExecute(r ApiRefreshRequest) (*DtoTokenPair, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *DtoTokenPair
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthAPIService.Refresh")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/auth/refresh"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.body == nil {
return localVarReturnValue, nil, reportError("body is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.body
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}

286
sdk/go/api_me.go Normal file
View File

@@ -0,0 +1,286 @@
/*
AutoGlue API
API for managing K3s clusters across cloud providers
API version: 1.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package autoglue
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
)
// MeAPIService MeAPI service
type MeAPIService service
type ApiGetMeRequest struct {
ctx context.Context
ApiService *MeAPIService
}
func (r ApiGetMeRequest) Execute() (*HandlersMeResponse, *http.Response, error) {
return r.ApiService.GetMeExecute(r)
}
/*
GetMe Get current user profile
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiGetMeRequest
*/
func (a *MeAPIService) GetMe(ctx context.Context) ApiGetMeRequest {
return ApiGetMeRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
//
// @return HandlersMeResponse
func (a *MeAPIService) GetMeExecute(r ApiGetMeRequest) (*HandlersMeResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *HandlersMeResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MeAPIService.GetMe")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/me"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["ApiKeyAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["X-API-KEY"] = key
}
}
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["BearerAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["Authorization"] = key
}
}
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiUpdateMeRequest struct {
ctx context.Context
ApiService *MeAPIService
body *HandlersUpdateMeRequest
}
// Patch profile
func (r ApiUpdateMeRequest) Body(body HandlersUpdateMeRequest) ApiUpdateMeRequest {
r.body = &body
return r
}
func (r ApiUpdateMeRequest) Execute() (*ModelsUser, *http.Response, error) {
return r.ApiService.UpdateMeExecute(r)
}
/*
UpdateMe Update current user profile
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiUpdateMeRequest
*/
func (a *MeAPIService) UpdateMe(ctx context.Context) ApiUpdateMeRequest {
return ApiUpdateMeRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
//
// @return ModelsUser
func (a *MeAPIService) UpdateMeExecute(r ApiUpdateMeRequest) (*ModelsUser, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPatch
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *ModelsUser
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MeAPIService.UpdateMe")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/me"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.body == nil {
return localVarReturnValue, nil, reportError("body is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.body
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["ApiKeyAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["X-API-KEY"] = key
}
}
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["BearerAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["Authorization"] = key
}
}
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}

393
sdk/go/api_me_api_keys.go Normal file
View File

@@ -0,0 +1,393 @@
/*
AutoGlue API
API for managing K3s clusters across cloud providers
API version: 1.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package autoglue
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strings"
)
// MeAPIKeysAPIService MeAPIKeysAPI service
type MeAPIKeysAPIService service
type ApiCreateUserAPIKeyRequest struct {
ctx context.Context
ApiService *MeAPIKeysAPIService
body *HandlersCreateUserKeyRequest
}
// Key options
func (r ApiCreateUserAPIKeyRequest) Body(body HandlersCreateUserKeyRequest) ApiCreateUserAPIKeyRequest {
r.body = &body
return r
}
func (r ApiCreateUserAPIKeyRequest) Execute() (*HandlersUserAPIKeyOut, *http.Response, error) {
return r.ApiService.CreateUserAPIKeyExecute(r)
}
/*
CreateUserAPIKey Create a new user API key
Returns the plaintext key once. Store it securely on the client side.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiCreateUserAPIKeyRequest
*/
func (a *MeAPIKeysAPIService) CreateUserAPIKey(ctx context.Context) ApiCreateUserAPIKeyRequest {
return ApiCreateUserAPIKeyRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
//
// @return HandlersUserAPIKeyOut
func (a *MeAPIKeysAPIService) CreateUserAPIKeyExecute(r ApiCreateUserAPIKeyRequest) (*HandlersUserAPIKeyOut, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *HandlersUserAPIKeyOut
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MeAPIKeysAPIService.CreateUserAPIKey")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/me/api-keys"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.body == nil {
return localVarReturnValue, nil, reportError("body is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.body
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["ApiKeyAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["X-API-KEY"] = key
}
}
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["BearerAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["Authorization"] = key
}
}
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiDeleteUserAPIKeyRequest struct {
ctx context.Context
ApiService *MeAPIKeysAPIService
id string
}
func (r ApiDeleteUserAPIKeyRequest) Execute() (*http.Response, error) {
return r.ApiService.DeleteUserAPIKeyExecute(r)
}
/*
DeleteUserAPIKey Delete a user API key
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param id Key ID (UUID)
@return ApiDeleteUserAPIKeyRequest
*/
func (a *MeAPIKeysAPIService) DeleteUserAPIKey(ctx context.Context, id string) ApiDeleteUserAPIKeyRequest {
return ApiDeleteUserAPIKeyRequest{
ApiService: a,
ctx: ctx,
id: id,
}
}
// Execute executes the request
func (a *MeAPIKeysAPIService) DeleteUserAPIKeyExecute(r ApiDeleteUserAPIKeyRequest) (*http.Response, error) {
var (
localVarHTTPMethod = http.MethodDelete
localVarPostBody interface{}
formFiles []formFile
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MeAPIKeysAPIService.DeleteUserAPIKey")
if err != nil {
return nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/me/api-keys/{id}"
localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["BearerAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["Authorization"] = key
}
}
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarHTTPResponse, newErr
}
return localVarHTTPResponse, nil
}
type ApiListUserAPIKeysRequest struct {
ctx context.Context
ApiService *MeAPIKeysAPIService
}
func (r ApiListUserAPIKeysRequest) Execute() ([]HandlersUserAPIKeyOut, *http.Response, error) {
return r.ApiService.ListUserAPIKeysExecute(r)
}
/*
ListUserAPIKeys List my API keys
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiListUserAPIKeysRequest
*/
func (a *MeAPIKeysAPIService) ListUserAPIKeys(ctx context.Context) ApiListUserAPIKeysRequest {
return ApiListUserAPIKeysRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
//
// @return []HandlersUserAPIKeyOut
func (a *MeAPIKeysAPIService) ListUserAPIKeysExecute(r ApiListUserAPIKeysRequest) ([]HandlersUserAPIKeyOut, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue []HandlersUserAPIKeyOut
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MeAPIKeysAPIService.ListUserAPIKeys")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/me/api-keys"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["ApiKeyAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["X-API-KEY"] = key
}
}
}
if r.ctx != nil {
// API Key Authentication
if auth, ok := r.ctx.Value(ContextAPIKeys).(map[string]APIKey); ok {
if apiKey, ok := auth["BearerAuth"]; ok {
var key string
if apiKey.Prefix != "" {
key = apiKey.Prefix + " " + apiKey.Key
} else {
key = apiKey.Key
}
localVarHeaderParams["Authorization"] = key
}
}
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}

1476
sdk/go/api_orgs.go Normal file

File diff suppressed because it is too large Load Diff

1065
sdk/go/api_servers.go Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More