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[:]) }