Orgs, Members, SSH and Admin page

This commit is contained in:
allanice001
2025-09-01 21:58:34 +01:00
parent 3f22521f49
commit 5425ed5dcc
61 changed files with 7138 additions and 819 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/glueops/autoglue/internal/handlers/authn"
"github.com/glueops/autoglue/internal/handlers/health"
"github.com/glueops/autoglue/internal/handlers/orgs"
"github.com/glueops/autoglue/internal/handlers/ssh"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/ui"
"github.com/go-chi/chi/v5"
@@ -21,6 +22,14 @@ func RegisterRoutes(r chi.Router) {
secret := viper.GetString("authentication.jwt_secret")
authMW := middleware.AuthMiddleware(secret)
v1.Route("/admin", func(ad chi.Router) {
ad.Use(authMW)
ad.Get("/users", authn.AdminListUsers)
ad.Post("/users", authn.AdminCreateUser)
ad.Patch("/users/{userId}", authn.AdminUpdateUser)
ad.Delete("/users/{userId}", authn.AdminDeleteUser)
})
v1.Route("/auth", func(a chi.Router) {
a.Post("/login", authn.Login)
a.Post("/register", authn.Register)
@@ -45,6 +54,20 @@ func RegisterRoutes(r chi.Router) {
o.Use(authMW)
o.Post("/", orgs.CreateOrganization)
o.Get("/", orgs.ListOrganizations)
o.Post("/invite", orgs.InviteMember)
o.Get("/members", orgs.ListMembers)
o.Delete("/members/{userId}", orgs.DeleteMember)
o.Patch("/{orgId}", orgs.UpdateOrganization)
o.Delete("/{orgId}", orgs.DeleteOrganization)
})
v1.Route("/ssh", func(s chi.Router) {
s.Use(authMW)
s.Get("/", ssh.ListPublicKeys)
s.Post("/", ssh.CreateSSHKey)
s.Get("/{id}", ssh.GetSSHKey)
s.Delete("/{id}", ssh.DeleteSSHKey)
s.Get("/{id}/download", ssh.DownloadSSHKey)
})
})
})

View File

@@ -26,13 +26,16 @@ func Connect() {
}
err = DB.AutoMigrate(
&models.Credential{},
&models.EmailVerification{},
&models.Invitation{},
&models.MasterKey{},
&models.Member{},
&models.Organization{},
&models.OrganizationKey{},
&models.PasswordReset{},
&models.RefreshToken{},
&models.SshKey{},
&models.User{},
)
if err != nil {

View File

@@ -0,0 +1,14 @@
package models
import "github.com/google/uuid"
type Credential struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider,where:deleted_at IS NULL" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Provider string `gorm:"type:varchar(50);not null;uniqueIndex:idx_credentials_org_provider,where:deleted_at IS NULL"`
EncryptedData string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
Timestamped
}

View File

@@ -0,0 +1,15 @@
package models
import "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"`
Timestamped
}

View File

@@ -0,0 +1,16 @@
package models
import "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"`
Timestamped
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/config"
@@ -11,6 +12,7 @@ import (
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
@@ -539,3 +541,299 @@ func RotateRefreshToken(w http.ResponseWriter, r *http.Request) {
"refresh_token": newRefresh.Token,
})
}
// AdminListUsers godoc
// @Summary Admin: list all users
// @Description Returns paginated list of users (admin only)
// @Tags admin
// @Produce json
// @Param page query int false "Page number (1-based)"
// @Param page_size query int false "Page size (max 200)"
// @Success 200 {object} ListUsersOut
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Security BearerAuth
// @Router /api/v1/admin/users [get]
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
ctx := middleware.GetAuthContext(r)
if ctx == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Load current user to check global role
var me models.User
if err := db.DB.Select("id, role").First(&me, "id = ?", ctx.UserID).Error; err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if me.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Pagination
page := mustInt(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
pageSize := mustInt(r.URL.Query().Get("page_size"), 50)
if pageSize < 1 {
pageSize = 50
}
if pageSize > 200 {
pageSize = 200
}
offset := (page - 1) * pageSize
// Query
var total int64
_ = db.DB.Model(&models.User{}).Count(&total).Error
var users []models.User
if err := db.DB.
Model(&models.User{}).
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&users).Error; err != nil {
http.Error(w, "failed to fetch users", http.StatusInternalServerError)
return
}
out := make([]UserListItem, len(users))
for i, u := range users {
out[i] = UserListItem{
ID: u.ID,
Name: u.Name,
Email: u.Email,
EmailVerified: u.EmailVerified,
Role: string(u.Role),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
_ = response.JSON(w, http.StatusOK, ListUsersOut{
Users: out,
Page: page,
PageSize: pageSize,
Total: total,
})
}
// AdminCreateUser godoc
// @Summary Admin: create user
// @Tags admin
// @Accept json
// @Produce json
// @Param body body AdminCreateUserRequest true "payload"
// @Success 201 {object} userOut
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 409 {string} string "conflict"
// @Security BearerAuth
// @Router /api/v1/admin/users [post]
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
if _, ok := requireGlobalAdmin(w, r); !ok {
return
}
var in struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"` // "user" | "admin"
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
in.Email = strings.TrimSpace(strings.ToLower(in.Email))
in.Role = strings.TrimSpace(in.Role)
if in.Email == "" || in.Password == "" {
http.Error(w, "email and password required", http.StatusBadRequest)
return
}
if in.Role == "" {
in.Role = "user"
}
if in.Role != "user" && in.Role != "admin" {
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
var exists int64
if err := db.DB.Model(&models.User{}).Where("LOWER(email)=?", in.Email).Count(&exists).Error; err == nil && exists > 0 {
http.Error(w, "email already in use", http.StatusConflict)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
u := models.User{
Name: in.Name,
Email: in.Email,
Password: string(hash),
Role: models.Role(in.Role),
}
if err := db.DB.Create(&u).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusCreated, asUserOut(u))
}
// AdminUpdateUser godoc
// @Summary Admin: update user
// @Tags admin
// @Accept json
// @Produce json
// @Param userId path string true "User ID"
// @Param body body AdminUpdateUserRequest true "payload"
// @Success 200 {object} userOut
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 404 {string} string "not found"
// @Failure 409 {string} string "conflict"
// @Security BearerAuth
// @Router /api/v1/admin/users/{userId} [patch]
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
_, ok := requireGlobalAdmin(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "userId")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "bad user id", http.StatusBadRequest)
return
}
var u models.User
if err := db.DB.First(&u, "id = ?", id).Error; err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
var in struct {
Name *string `json:"name"`
Email *string `json:"email"`
Password *string `json:"password"`
Role *string `json:"role"` // "user" | "admin"
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
updates := map[string]any{}
if in.Name != nil {
updates["name"] = *in.Name
}
if in.Email != nil {
email := strings.TrimSpace(strings.ToLower(*in.Email))
if email == "" {
http.Error(w, "email required", http.StatusBadRequest)
return
}
var exists int64
_ = db.DB.Model(&models.User{}).Where("LOWER(email)=? AND id <> ?", email, u.ID).Count(&exists).Error
if exists > 0 {
http.Error(w, "email already in use", http.StatusConflict)
return
}
updates["email"] = email
}
if in.Password != nil && *in.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
updates["password"] = string(hash)
}
if in.Role != nil {
role := strings.TrimSpace(*in.Role)
if role != "user" && role != "admin" {
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
// prevent demoting the last admin
if u.Role == "admin" && role == "user" {
n, _ := adminCount(&u.ID)
if n == 0 {
http.Error(w, "cannot demote last admin", http.StatusConflict)
return
}
}
updates["role"] = role
}
if len(updates) == 0 {
_ = response.JSON(w, http.StatusOK, asUserOut(u))
return
}
if err := db.DB.Model(&u).Updates(updates).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, asUserOut(u))
}
// AdminDeleteUser godoc
// @Summary Admin: delete user
// @Tags admin
// @Param userId path string true "User ID"
// @Success 204 {string} string "no content"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 404 {string} string "not found"
// @Failure 409 {string} string "conflict"
// @Security BearerAuth
// @Router /api/v1/admin/users/{userId} [delete]
func AdminDeleteUser(w http.ResponseWriter, r *http.Request) {
me, ok := requireGlobalAdmin(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "userId")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "bad user id", http.StatusBadRequest)
return
}
if me.ID == id {
http.Error(w, "cannot delete self", http.StatusBadRequest)
return
}
var u models.User
if err := db.DB.First(&u, "id = ?", id).Error; err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if u.Role == "admin" {
n, _ := adminCount(&u.ID)
if n == 0 {
http.Error(w, "cannot delete last admin", http.StatusConflict)
return
}
}
if err := db.DB.Delete(&models.User{}, "id = ?", id).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

View File

@@ -77,3 +77,62 @@ type PasswordResetData struct {
Token string
ResetURL string
}
type UserListItem struct {
ID any `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Role string `json:"role"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
type ListUsersOut struct {
Users []UserListItem `json:"users"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type userOut struct {
ID any `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Role string `json:"role"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
type AdminCreateUserRequest struct {
Name string `json:"name" example:"Jane Doe"`
Email string `json:"email" example:"jane@example.com"`
Password string `json:"password" example:"Secret123!"`
// Role allowed values: "user" or "admin"
Role string `json:"role" example:"user" enums:"user,admin"`
}
type AdminUpdateUserRequest struct {
Name *string `json:"name,omitempty" example:"Jane Doe"`
Email *string `json:"email,omitempty" example:"jane@example.com"`
Password *string `json:"password,omitempty" example:"NewSecret123!"`
Role *string `json:"role,omitempty" example:"admin" enums:"user,admin"`
}
type AdminUserResponse struct {
ID uuid.UUID `json:"id" example:"6aa012bc-ce8a-4cd9-9971-58f3917037f8"`
Name string `json:"name" example:"Jane Doe"`
Email string `json:"email" example:"jane@example.com"`
EmailVerified bool `json:"email_verified" example:"false"`
Role string `json:"role" example:"user"`
CreatedAt string `json:"created_at" example:"2025-09-01T08:38:12Z"`
UpdatedAt string `json:"updated_at" example:"2025-09-01T17:02:36Z"`
}
type AdminListUsersResponse struct {
Users []AdminUserResponse `json:"users"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"50"`
Total int64 `json:"total" example:"123"`
}

View File

@@ -4,11 +4,14 @@ import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"time"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
appsmtp "github.com/glueops/autoglue/internal/smtp"
"github.com/google/uuid"
)
@@ -90,3 +93,54 @@ func sendTemplatedEmail(to string, templateFile string, data any) error {
}
return m.Send(to, data, templateFile)
}
func mustInt(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func adminCount(except *uuid.UUID) (int64, error) {
q := db.DB.Model(&models.User{}).Where(`role = ?`, "admin")
if except != nil {
q = q.Where("id <> ?", *except)
}
var n int64
err := q.Count(&n).Error
return n, err
}
func requireGlobalAdmin(w http.ResponseWriter, r *http.Request) (*models.User, bool) {
ctx := middleware.GetAuthContext(r)
if ctx == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
var me models.User
if err := db.DB.Select("id, role").First(&me, "id = ?", ctx.UserID).Error; err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
if me.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return nil, false
}
return &me, true
}
func asUserOut(u models.User) userOut {
return userOut{
ID: u.ID,
Name: u.Name,
Email: u.Email,
EmailVerified: u.EmailVerified,
Role: string(u.Role),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}

View File

@@ -4,3 +4,8 @@ type OrgInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
type InviteInput struct {
Email string `json:"email"`
Role string `json:"role"`
}

View File

@@ -4,11 +4,14 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// CreateOrganization godoc
@@ -88,3 +91,178 @@ func ListOrganizations(w http.ResponseWriter, r *http.Request) {
_ = response.JSON(w, http.StatusOK, orgs)
}
// InviteMember godoc
// @Summary Invite user to organization
// @Tags organizations
// @Accept json
// @Produce plain
// @Param body body InviteInput true "Invite input"
// @Success 201 {string} string "invited"
// @Failure 403 {string} string "forbidden"
// @Failure 400 {string} string "bad request"
// @Router /api/v1/orgs/invite [post]
// @Param X-Org-ID header string true "Organization context"
// @Security BearerAuth
func InviteMember(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil || auth.OrgRole != "admin" || auth.OrganizationID == uuid.Nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var input InviteInput
json.NewDecoder(r.Body).Decode(&input)
var user models.User
err := db.DB.Where("email = ?", input.Email).First(&user).Error
if err != nil {
http.Error(w, "user not found", http.StatusBadRequest)
return
}
invite := models.Invitation{
OrganizationID: auth.OrganizationID,
Email: input.Email,
Role: input.Role,
Status: "pending",
ExpiresAt: time.Now().Add(48 * time.Hour),
InviterID: auth.UserID,
}
if err := db.DB.Create(&invite).Error; err != nil {
http.Error(w, "failed to invite", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusCreated, invite)
}
// ListMembers lists all members of the authenticated org
// @Summary List organization members
// @Description Returns a list of all members in the current organization
// @Tags organizations
// @Security BearerAuth
// @Produce json
// @Success 200 {array} models.Member
// @Failure 401 {string} string "unauthorized"
// @Router /api/v1/orgs/members [get]
// @Param X-Org-ID header string true "Organization context"
func ListMembers(w http.ResponseWriter, r *http.Request) {
authCtx := middleware.GetAuthContext(r)
if authCtx == nil || authCtx.OrganizationID == uuid.Nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var members []models.Member
if err := db.DB.Preload("User").Preload("Organization").Where("organization_id = ?", authCtx.OrganizationID).Find(&members).Error; err != nil {
http.Error(w, "failed to fetch members", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, members)
}
// DeleteMember godoc
// @Summary Remove member from organization
// @Tags organizations
// @Param userId path string true "User ID"
// @Success 204 {string} string "deleted"
// @Failure 403 {string} string "forbidden"
// @Router /api/v1/orgs/members/{userId} [delete]
// @Security BearerAuth
func DeleteMember(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil || auth.OrgRole != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
userId := chi.URLParam(r, "userId")
if err := db.DB.Where("user_id = ? AND organization_id = ?", userId, auth.OrganizationID).Delete(&models.Member{}).Error; err != nil {
http.Error(w, "failed to delete", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateOrganization godoc
// @Summary Update organization metadata
// @Tags organizations
// @Accept json
// @Produce json
// @Param orgId path string true "Org ID"
// @Param body body OrgInput true "Organization data"
// @Success 200 {object} models.Organization
// @Failure 403 {string} string "forbidden"
// @Router /api/v1/orgs/{orgId} [patch]
// @Security BearerAuth
func UpdateOrganization(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil || auth.OrgRole != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
orgId := chi.URLParam(r, "orgId")
var input OrgInput
json.NewDecoder(r.Body).Decode(&input)
var org models.Organization
db.DB.First(&org, "id = ?", orgId)
org.Name = input.Name
org.Slug = input.Slug
db.DB.Save(&org)
_ = response.JSON(w, http.StatusOK, org)
}
// DeleteOrganization godoc
// @Summary Delete organization
// @Tags organizations
// @Param orgId path string true "Organization ID"
// @Success 204 {string} string "deleted"
// @Failure 403 {string} string "forbidden"
// @Router /api/v1/orgs/{orgId} [delete]
// @Security BearerAuth
func DeleteOrganization(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
orgId := chi.URLParam(r, "orgId")
orgUUID, err := uuid.Parse(orgId)
if err != nil {
http.Error(w, "invalid organization id", http.StatusBadRequest)
return
}
var member models.Member
if err := db.DB.
Where("user_id = ? AND organization_id = ?", auth.UserID, orgUUID).
First(&member).Error; err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if member.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := db.DB.Where("organization_id = ?", orgUUID).Delete(&models.Member{}).Error; err != nil {
http.Error(w, "failed to delete members", http.StatusInternalServerError)
return
}
if err := db.DB.Delete(&models.Organization{}, "id = ?", orgUUID).Error; err != nil {
http.Error(w, "failed to delete org", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

View File

@@ -0,0 +1,25 @@
package ssh
import "github.com/google/uuid"
type createSSHRequest struct {
Bits *int `json:"bits,omitempty" example:"4096"`
Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
Download string `json:"download,omitempty" example:"both"`
Name string `json:"name"`
}
type sshResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
PublicKey string `json:"public_keys"`
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"`
}

View File

@@ -0,0 +1,60 @@
package ssh
import (
"archive/zip"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"strings"
gossh "golang.org/x/crypto/ssh"
)
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) {
// Private (PKCS#1) to PEM
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
}
// Public to authorized_keys
pub, err := gossh.NewPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(gossh.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
}

View File

@@ -0,0 +1,361 @@
package ssh
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// ListPublicKeys godoc
// @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 true "Organization UUID"
// @Security BearerAuth
// @Success 200 {array} sshResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list keys"
// @Router /api/v1/ssh [get]
func ListPublicKeys(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var rows []models.SshKey
if err := db.DB.Where("organization_id = ?", ac.OrganizationID).
Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list ssh keys", http.StatusInternalServerError)
return
}
out := make([]sshResponse, 0, len(rows))
for _, s := range rows {
out = append(out, sshResponse{
ID: s.ID,
OrganizationID: s.OrganizationID,
Name: s.Name,
PublicKey: s.PublicKey,
Fingerprint: s.Fingerprint,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
})
}
_ = response.JSON(w, http.StatusOK, out)
}
// CreateSSHKey godoc
// @Summary Create ssh keypair (org scoped)
// @Description Generates an RSA keypair, saves it, and returns metadata. Optionally set `download` to "public", "private", or "both" to download files immediately.
// @Tags ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createSSHRequest true "Key generation options"
// @Security BearerAuth
// @Success 201 {object} sshResponse
// @Header 201 {string} Content-Disposition "When download is requested"
// @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 /api/v1/ssh [post]
func CreateSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createSSHRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
bits := 4096
if req.Bits != nil {
if !allowedBits(*req.Bits) {
http.Error(w, "invalid bits (allowed: 2048, 3072, 4096)", http.StatusBadRequest)
return
}
bits = *req.Bits
}
privPEM, pubAuth, err := GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
if err != nil {
http.Error(w, "key generation failed", http.StatusInternalServerError)
return
}
cipher, iv, tag, err := utils.EncryptForOrg(ac.OrganizationID, []byte(privPEM))
if err != nil {
http.Error(w, "encryption failed", http.StatusInternalServerError)
return
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
if err != nil {
http.Error(w, "failed to parse public key", http.StatusInternalServerError)
return
}
fp := ssh.FingerprintSHA256(parsed)
key := models.SshKey{
OrganizationID: ac.OrganizationID,
Name: req.Name,
PublicKey: pubAuth,
EncryptedPrivateKey: cipher,
PrivateIV: iv,
PrivateTag: tag,
Fingerprint: fp,
}
if err := db.DB.Create(&key).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
// Immediate download if requested
switch strings.ToLower(strings.TrimSpace(req.Download)) {
case "public":
filename := fmt.Sprintf("id_rsa_%s.pub", key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(pubAuth))
return
case "private":
filename := fmt.Sprintf("id_rsa_%s.pem", key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(privPEM))
return
case "both":
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pem", key.ID.String()), []byte(privPEM), zw)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pub", key.ID.String()), []byte(pubAuth), 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.WriteHeader(http.StatusCreated)
_, _ = w.Write(buf.Bytes())
return
}
_ = response.JSON(w, http.StatusCreated, sshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
}
// GetSSHKey godoc
// @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 true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param reveal query bool false "Reveal private key PEM"
// @Security BearerAuth
// @Success 200 {object} sshResponse
// @Success 200 {object} 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 /api/v1/ssh/{id} [get]
func GetSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var key models.SshKey
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("reveal") != "true" {
_ = response.JSON(w, http.StatusOK, sshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
PublicKey: key.PublicKey,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
return
}
plain, err := utils.DecryptForOrg(ac.OrganizationID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag)
if err != nil {
http.Error(w, "failed to decrypt", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, sshRevealResponse{
sshResponse: sshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
PublicKey: key.PublicKey,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
},
PrivateKey: plain,
})
}
// DeleteSSHKey godoc
// @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair.
// @Tags ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Security BearerAuth
// @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 /api/v1/ssh/{id} [delete]
func DeleteSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
idStr := strings.TrimPrefix(r.URL.Path, "/api/v1/ssh/")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
Delete(&models.SshKey{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}
// DownloadSSHKey godoc
// @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 text/plain
// @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)
// @Security BearerAuth
// @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 /api/v1/ssh/{id}/download [get]
func DownloadSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var key models.SshKey
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
switch strings.ToLower(r.URL.Query().Get("part")) {
case "public":
filename := fmt.Sprintf("id_rsa_%s.pub", 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))
case "private":
plain, err := utils.DecryptForOrg(ac.OrganizationID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag)
if err != nil {
http.Error(w, "decrypt failed", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("id_rsa_%s.pem", 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))
case "both":
plain, err := utils.DecryptForOrg(ac.OrganizationID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag)
if err != nil {
http.Error(w, "decrypt failed", http.StatusInternalServerError)
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()
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())
default:
http.Error(w, "invalid part (public|private|both)", http.StatusBadRequest)
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
@@ -59,14 +60,32 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler {
Claims: claims,
}
if orgID := r.Header.Get("X-Org-ID"); orgID != "" {
orgUUID, _ := uuid.Parse(orgID)
orgIDStr := r.Header.Get("X-Org-ID")
if orgIDStr == "" {
if rc := chi.RouteContext(r.Context()); rc != nil {
if v := rc.URLParam("orgId"); v != "" {
orgIDStr = v
} else if v := rc.URLParam("organizationId"); v != "" {
orgIDStr = v
}
}
}
var member models.Member
if err := db.DB.Where("user_id = ? AND organization_id = ?", claims.Subject, orgID).First(&member).Error; err != nil {
http.Error(w, "User not a member of the organization", http.StatusForbidden)
if orgIDStr != "" {
orgUUID, err := uuid.Parse(orgIDStr)
if err != nil {
http.Error(w, "invalid organization id", http.StatusBadRequest)
return
}
var member models.Member
if err := db.DB.
Where("user_id = ? AND organization_id = ?", userUUID, orgUUID).
First(&member).Error; err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
authCtx.OrganizationID = orgUUID
authCtx.OrgRole = string(member.Role)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,16 @@
<!doctype html>
<!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>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/index-DrmAfy-p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Nf4c5zdA.css">
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-YQeQnKJK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CyXg69m3.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-Cnbx_Mrt.js">
<link rel="modulepreload" crossorigin href="/assets/radix-DN_DrUzo.js">
<link rel="modulepreload" crossorigin href="/assets/icons-CHRYRpwL.js">
<link rel="stylesheet" crossorigin href="/assets/index-CJrhsj7s.css">
</head>
<body>
<div id="root"></div>

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

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

@@ -0,0 +1,101 @@
package utils
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func getMasterKey() ([]byte, error) {
var mk models.MasterKey
if err := db.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) ([]byte, error) {
var orgKey models.OrganizationKey
err := db.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()
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()
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.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.DB.Create(&orgKey).Error; err != nil {
return nil, fmt.Errorf("persist org key: %w", err)
}
return tenantKey, nil
}

View File

@@ -0,0 +1,71 @@
package utils
import (
"fmt"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func EncryptForOrg(orgID uuid.UUID, plaintext []byte) (cipherB64, ivB64, tagB64 string, err error) {
tenantKey, err := getOrCreateTenantKey(orgID.String())
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) (string, error) {
tenantKey, err := getOrCreateTenantKey(orgID.String())
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
}
func EncryptAndUpsertCredential(orgID uuid.UUID, provider, plaintext string) error {
data, iv, tag, err := EncryptForOrg(orgID, []byte(plaintext))
if err != nil {
return err
}
var cred models.Credential
err = db.DB.Where("organization_id = ? AND provider = ?", orgID, provider).
First(&cred).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
cred.OrganizationID = orgID
cred.Provider = provider
cred.EncryptedData = data
cred.IV = iv
cred.Tag = tag
if cred.ID != uuid.Nil {
return db.DB.Save(&cred).Error
}
return db.DB.Create(&cred).Error
}