Files
gochat/backend/internal/handler/api/v1/account_saml_settings_handler.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

463 lines
15 KiB
Go

package v1
// Reference: M13 §2 — Account-scoped SAML config admin API
// CRUD endpoints for enterprise administrators to configure SAML SSO for their accounts.
// Pattern follows Chatwoot AccountSamlSettings API (super_admin scoped).
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
// AccountSamlSettingsHandler handles account-scoped SAML configuration endpoints.
// Only accessible to account administrators (role: administrator or super_admin).
type AccountSamlSettingsHandler struct {
repo *repository.AccountSamlSettingsRepo
}
// NewAccountSamlSettingsHandler creates a new AccountSamlSettings handler.
func NewAccountSamlSettingsHandler(repo *repository.AccountSamlSettingsRepo) *AccountSamlSettingsHandler {
return &AccountSamlSettingsHandler{repo: repo}
}
// Get retrieves SAML settings for an account.
// GET /api/v1/accounts/:account_id/saml_settings
func (h *AccountSamlSettingsHandler) Get(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid account ID",
},
})
return
}
settings, err := h.repo.GetByAccount(uint(accountID))
if err != nil {
applogger.L().Errorf("Get SAML settings for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get SAML settings")
return
}
if settings == nil {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML settings not found for this account",
},
})
return
}
c.JSON(http.StatusOK, serializeSamlSettings(settings))
}
// Create creates SAML settings for an account.
// POST /api/v1/accounts/:account_id/saml_settings
// Body: JSON with IdP configuration fields.
func (h *AccountSamlSettingsHandler) Create(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid account ID",
},
})
return
}
req, err := bindCreateSamlSettingsRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid request body",
Detail: err.Error(),
},
})
return
}
// Check if settings already exist for this account
existing, err := h.repo.GetByAccount(uint(accountID))
if err != nil {
applogger.L().Errorf("Check existing SAML settings for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to check existing settings")
return
}
if existing != nil {
c.JSON(http.StatusConflict, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "SAML settings already exist for this account",
},
})
return
}
settings := req.ToModel(uint(accountID))
if err := h.repo.Create(&settings); err != nil {
applogger.L().Errorf("Create SAML settings for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create SAML settings")
return
}
applogger.L().Infof("SAML settings created for account %d", accountID)
c.JSON(http.StatusCreated, serializeSamlSettings(&settings))
}
// Update updates SAML settings for an account.
// PUT /api/v1/accounts/:account_id/saml_settings
// Body: JSON with fields to update (partial update supported).
func (h *AccountSamlSettingsHandler) Update(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid account ID",
},
})
return
}
req, err := bindUpdateSamlSettingsRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid request body",
Detail: err.Error(),
},
})
return
}
// Verify settings exist
existing, err := h.repo.GetByAccount(uint(accountID))
if err != nil {
applogger.L().Errorf("Get SAML settings for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get SAML settings")
return
}
if existing == nil {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML settings not found for this account",
},
})
return
}
// Build updates map
updates := req.ToUpdatesMap()
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "No fields to update",
},
})
return
}
if err := h.repo.UpdateFields(uint(accountID), updates); err != nil {
applogger.L().Errorf("Update SAML settings for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update SAML settings")
return
}
// Return updated settings
settings, _ := h.repo.GetByAccount(uint(accountID))
applogger.L().Infof("SAML settings updated for account %d", accountID)
c.JSON(http.StatusOK, serializeSamlSettings(settings))
}
// Delete removes SAML settings for an account.
// DELETE /api/v1/accounts/:account_id/saml_settings
func (h *AccountSamlSettingsHandler) Delete(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid account ID",
},
})
return
}
if err := h.repo.Delete(uint(accountID)); err != nil {
applogger.L().Errorf("Delete SAML settings for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete SAML settings")
return
}
applogger.L().Infof("SAML settings deleted for account %d", accountID)
c.JSON(http.StatusOK, response.APIResponse{
Success: true,
Data: map[string]string{
"message": "SAML settings deleted",
},
})
}
// ToggleActive enables or disables SAML SSO for an account.
// POST /api/v1/accounts/:account_id/saml_settings/toggle_active
// Body: { "active": true/false }
func (h *AccountSamlSettingsHandler) ToggleActive(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid account ID",
},
})
return
}
var req ToggleActiveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid request body",
Detail: err.Error(),
},
})
return
}
if err := h.repo.SetActive(uint(accountID), req.Active); err != nil {
applogger.L().Errorf("Toggle SAML active for account %d: %v", accountID, err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to toggle SAML active status")
return
}
status := "disabled"
if req.Active {
status = "enabled"
}
applogger.L().Infof("SAML SSO %s for account %d", status, accountID)
c.JSON(http.StatusOK, response.APIResponse{
Success: true,
Data: map[string]interface{}{
"account_id": accountID,
"active": req.Active,
"status": status,
},
})
}
// --- Request/Response types ---
// CreateSamlSettingsRequest is the request body for creating SAML settings.
type CreateSamlSettingsRequest struct {
SsoURL string `json:"sso_url"`
Certificate string `json:"certificate"`
IdpEntityID string `json:"idp_entity_id" binding:"required"`
IdpSsoTargetURL string `json:"idp_sso_target_url" binding:"required"`
IdpSloTargetURL string `json:"idp_slo_target_url"`
IdpCertificate string `json:"idp_certificate" binding:"required"`
SpEntityID string `json:"sp_entity_id"`
SpX509Certificate string `json:"sp_x509_certificate"`
SpPrivateKey string `json:"sp_private_key"`
RoleMappings json.RawMessage `json:"role_mappings"`
Active bool `json:"active"`
}
// ToModel converts a CreateSamlSettingsRequest to an AccountSamlSettings model.
func (req *CreateSamlSettingsRequest) ToModel(accountID uint) model.AccountSamlSettings {
req.normalizeChatwootAliases()
return model.AccountSamlSettings{
AccountID: accountID,
IdpEntityID: req.IdpEntityID,
IdpSsoTargetURL: req.IdpSsoTargetURL,
IdpSloTargetURL: req.IdpSloTargetURL,
IdpCertificate: req.IdpCertificate,
SpEntityID: req.SpEntityID,
SpX509Certificate: req.SpX509Certificate,
SpPrivateKey: req.SpPrivateKey,
RoleMappings: req.RoleMappings,
Active: req.Active,
}
}
func (req *CreateSamlSettingsRequest) normalizeChatwootAliases() {
if req.IdpSsoTargetURL == "" {
req.IdpSsoTargetURL = req.SsoURL
}
if req.IdpCertificate == "" {
req.IdpCertificate = req.Certificate
}
}
// UpdateSamlSettingsRequest is the request body for updating SAML settings.
// All fields are optional — only non-nil/non-zero fields will be updated.
type UpdateSamlSettingsRequest struct {
SsoURL *string `json:"sso_url"`
Certificate *string `json:"certificate"`
IdpEntityID *string `json:"idp_entity_id"`
IdpSsoTargetURL *string `json:"idp_sso_target_url"`
IdpSloTargetURL *string `json:"idp_slo_target_url"`
IdpCertificate *string `json:"idp_certificate"`
SpEntityID *string `json:"sp_entity_id"`
SpX509Certificate *string `json:"sp_x509_certificate"`
SpPrivateKey *string `json:"sp_private_key"`
RoleMappings json.RawMessage `json:"role_mappings"`
Active *bool `json:"active"`
}
// ToUpdatesMap converts an UpdateSamlSettingsRequest to a map of fields to update.
func (req *UpdateSamlSettingsRequest) ToUpdatesMap() map[string]interface{} {
updates := make(map[string]interface{})
if req.IdpSsoTargetURL == nil {
req.IdpSsoTargetURL = req.SsoURL
}
if req.IdpCertificate == nil {
req.IdpCertificate = req.Certificate
}
if req.IdpEntityID != nil {
updates["idp_entity_id"] = *req.IdpEntityID
}
if req.IdpSsoTargetURL != nil {
updates["idp_sso_target_url"] = *req.IdpSsoTargetURL
}
if req.IdpSloTargetURL != nil {
updates["idp_slo_target_url"] = *req.IdpSloTargetURL
}
if req.IdpCertificate != nil {
updates["idp_certificate"] = *req.IdpCertificate
}
if req.SpEntityID != nil {
updates["sp_entity_id"] = *req.SpEntityID
}
if req.SpX509Certificate != nil {
updates["sp_x509_certificate"] = *req.SpX509Certificate
}
if req.SpPrivateKey != nil {
updates["sp_private_key"] = *req.SpPrivateKey
}
if req.RoleMappings != nil {
updates["role_mappings"] = req.RoleMappings
}
if req.Active != nil {
updates["active"] = *req.Active
}
return updates
}
// ToggleActiveRequest toggles the active status of SAML settings.
type ToggleActiveRequest struct {
Active bool `json:"active"`
}
// RegisterAccountSamlSettingsRoutes maps account-scoped SAML config admin routes.
// Only accessible to account administrators (enforced by AccountScope middleware in router).
// Reference: Chatwoot AccountSamlSettings API — enterprise SSO configuration
func RegisterAccountSamlSettingsRoutes(g *gin.RouterGroup, h *AccountSamlSettingsHandler) {
g.GET("", h.Get) // Chatwoot frontend: fetch account SAML config
g.POST("", h.Create) // Chatwoot frontend: create account SAML config
g.PUT("", h.Update) // Chatwoot frontend: update account SAML config
g.DELETE("", h.Delete) // Chatwoot frontend: delete account SAML config
g.POST("/", h.Create) // Backward compatibility for trailing slash clients
g.GET("/:id", h.Get) // Backward compatibility for id-based callers
g.PUT("/:id", h.Update) // Backward compatibility for id-based callers
g.DELETE("/:id", h.Delete) // Backward compatibility for id-based callers
g.POST("/:id/toggle_active", h.ToggleActive) // Enable/disable SAML for a config
}
func bindCreateSamlSettingsRequest(c *gin.Context) (CreateSamlSettingsRequest, error) {
req := CreateSamlSettingsRequest{}
if err := bindSamlSettingsPayload(c, &req); err != nil {
return req, err
}
req.normalizeChatwootAliases()
if req.IdpEntityID == "" || req.IdpSsoTargetURL == "" || req.IdpCertificate == "" {
return req, errors.New("idp_entity_id, idp_sso_target_url, and idp_certificate are required")
}
return req, nil
}
func bindUpdateSamlSettingsRequest(c *gin.Context) (UpdateSamlSettingsRequest, error) {
req := UpdateSamlSettingsRequest{}
return req, bindSamlSettingsPayload(c, &req)
}
func bindSamlSettingsPayload(c *gin.Context, req interface{}) error {
var payload map[string]json.RawMessage
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil {
return err
}
if nested, ok := payload["saml_settings"]; ok {
return json.Unmarshal(nested, req)
}
flat, err := json.Marshal(payload)
if err != nil {
return err
}
return json.Unmarshal(flat, req)
}
func serializeSamlSettings(settings *model.AccountSamlSettings) gin.H {
if settings == nil {
return gin.H{}
}
return gin.H{
"id": settings.ID,
"account_id": settings.AccountID,
"idp_entity_id": settings.IdpEntityID,
"idp_sso_target_url": settings.IdpSsoTargetURL,
"idp_slo_target_url": settings.IdpSloTargetURL,
"idp_certificate": settings.IdpCertificate,
"sso_url": settings.IdpSsoTargetURL,
"certificate": settings.IdpCertificate,
"sp_entity_id": settings.SpEntityID,
"sp_x509_certificate": settings.SpX509Certificate,
"role_mappings": settings.RoleMappings,
"active": settings.Active,
"fingerprint": samlCertificateFingerprint(settings.IdpCertificate),
"created_at": settings.CreatedAt,
"updated_at": settings.UpdatedAt,
}
}
func samlCertificateFingerprint(certificate string) string {
if certificate == "" {
return ""
}
sum := sha1.Sum([]byte(certificate))
return hex.EncodeToString(sum[:])
}