226 lines
6.9 KiB
Go
226 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
themeDeployConfirmation = "DEPLOY"
|
|
themeDeployStatusSuccess = "success"
|
|
themeDeployStatusFailed = "failed"
|
|
)
|
|
|
|
type keycloakThemeDeploymentRequest struct {
|
|
Summary string `json:"summary"`
|
|
Confirmation string `json:"confirmation"`
|
|
}
|
|
|
|
func (a *app) deployKeycloakTheme(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
|
|
if !hasPermission(claims, permKeycloakThemesDeploy) {
|
|
writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.deploy")
|
|
return
|
|
}
|
|
|
|
if !a.cfg.KeycloakThemeDeployEnabled {
|
|
writeError(w, http.StatusServiceUnavailable, "theme deployment is disabled")
|
|
return
|
|
}
|
|
|
|
themeID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if themeID == "" {
|
|
writeError(w, http.StatusBadRequest, "id is required")
|
|
return
|
|
}
|
|
|
|
var payload keycloakThemeDeploymentRequest
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
if err := validateThemeDeploymentRequest(payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
theme, err := a.getByID("keycloak_themes", themeID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load keycloak theme")
|
|
return
|
|
}
|
|
|
|
status, _ := theme["status"].(string)
|
|
if strings.TrimSpace(strings.ToLower(status)) != keycloakThemeStatusPublished {
|
|
writeError(w, http.StatusBadRequest, "only published themes can be deployed")
|
|
return
|
|
}
|
|
|
|
actor := resolveThemeActor(claims)
|
|
deploymentKey := keycloakThemeDeploymentKey(theme)
|
|
|
|
current, err := a.getCurrentThemeDeployment(deploymentKey)
|
|
if err != nil {
|
|
a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusFailed, nil, "failed to resolve deployment state")
|
|
writeError(w, http.StatusInternalServerError, "failed to deploy keycloak theme")
|
|
return
|
|
}
|
|
|
|
rollback := buildThemeDeploymentRollback(current)
|
|
if err := a.upsertCurrentThemeDeployment(deploymentKey, theme, actor, rollback); err != nil {
|
|
a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusFailed, rollback, "failed to persist deployment state")
|
|
writeError(w, http.StatusInternalServerError, "failed to deploy keycloak theme")
|
|
return
|
|
}
|
|
|
|
a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusSuccess, rollback, "")
|
|
writeJSON(w, http.StatusOK, Doc{
|
|
"status": "deployed",
|
|
"themeId": strings.TrimSpace(themeID),
|
|
"rollbackThemeId": rollback["themeId"],
|
|
"rollbackVersion": rollback["version"],
|
|
})
|
|
}
|
|
|
|
func validateThemeDeploymentRequest(payload keycloakThemeDeploymentRequest) error {
|
|
if strings.TrimSpace(payload.Summary) == "" {
|
|
return errors.New("deployment summary is required")
|
|
}
|
|
if strings.TrimSpace(strings.ToUpper(payload.Confirmation)) != themeDeployConfirmation {
|
|
return errors.New("deployment confirmation must be DEPLOY")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveThemeActor(claims *accessClaims) string {
|
|
if claims == nil {
|
|
return "unknown"
|
|
}
|
|
if value := strings.TrimSpace(claims.PreferredUsername); value != "" {
|
|
return value
|
|
}
|
|
if value := strings.TrimSpace(claims.Email); value != "" {
|
|
return value
|
|
}
|
|
if value := strings.TrimSpace(claims.Sub); value != "" {
|
|
return value
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func keycloakThemeDeploymentKey(theme Doc) string {
|
|
target, _ := theme["target"].(Doc)
|
|
realm, _ := target["realm"].(string)
|
|
client, _ := target["client"].(string)
|
|
organizationID, _ := target["organizationId"].(string)
|
|
themeType, _ := theme["type"].(string)
|
|
scope, _ := theme["scope"].(string)
|
|
|
|
parts := []string{
|
|
"realm=" + strings.TrimSpace(realm),
|
|
"client=" + strings.TrimSpace(client),
|
|
"scope=" + strings.TrimSpace(scope),
|
|
"organization=" + strings.TrimSpace(organizationID),
|
|
"type=" + strings.TrimSpace(themeType),
|
|
}
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
func buildThemeDeploymentRollback(current Doc) Doc {
|
|
if current == nil {
|
|
return Doc{}
|
|
}
|
|
rollback := Doc{}
|
|
if themeID, ok := current["themeId"].(string); ok && strings.TrimSpace(themeID) != "" {
|
|
rollback["themeId"] = strings.TrimSpace(themeID)
|
|
}
|
|
if version, ok := current["version"].(string); ok && strings.TrimSpace(version) != "" {
|
|
rollback["version"] = strings.TrimSpace(version)
|
|
}
|
|
if len(rollback) == 0 {
|
|
return Doc{}
|
|
}
|
|
return rollback
|
|
}
|
|
|
|
func (a *app) getCurrentThemeDeployment(deploymentKey string) (Doc, error) {
|
|
entries, err := a.list("keycloak_theme_deployments_current", Doc{"deploymentKey": deploymentKey}, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil, nil
|
|
}
|
|
return entries[0], nil
|
|
}
|
|
|
|
func (a *app) upsertCurrentThemeDeployment(deploymentKey string, theme Doc, actor string, rollback Doc) error {
|
|
target, _ := theme["target"].(Doc)
|
|
now := time.Now().UnixMilli()
|
|
doc := Doc{
|
|
"deploymentKey": deploymentKey,
|
|
"themeId": strings.TrimSpace(anyToString(theme["id"])),
|
|
"version": strings.TrimSpace(anyToString(theme["version"])),
|
|
"scope": strings.TrimSpace(anyToString(theme["scope"])),
|
|
"type": strings.TrimSpace(anyToString(theme["type"])),
|
|
"realm": strings.TrimSpace(anyToString(target["realm"])),
|
|
"client": strings.TrimSpace(anyToString(target["client"])),
|
|
"organizationId": strings.TrimSpace(anyToString(target["organizationId"])),
|
|
"deployedBy": strings.TrimSpace(actor),
|
|
"deployedAt": now,
|
|
"rollback": rollback,
|
|
}
|
|
|
|
current, err := a.getCurrentThemeDeployment(deploymentKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current == nil {
|
|
_, err = a.create("keycloak_theme_deployments_current", doc)
|
|
return err
|
|
}
|
|
_, err = a.patch("keycloak_theme_deployments_current", strings.TrimSpace(anyToString(current["id"])), doc)
|
|
return err
|
|
}
|
|
|
|
func (a *app) recordThemeDeploymentAudit(theme Doc, summary, actor, status string, rollback Doc, errorMessage string) {
|
|
audit := buildThemeDeploymentAuditDoc(theme, summary, actor, status, rollback, errorMessage)
|
|
_, _ = a.create("keycloak_theme_deployments_audit", audit)
|
|
}
|
|
|
|
func buildThemeDeploymentAuditDoc(theme Doc, summary, actor, status string, rollback Doc, errorMessage string) Doc {
|
|
audit := Doc{
|
|
"themeId": strings.TrimSpace(anyToString(theme["id"])),
|
|
"themeSlug": strings.TrimSpace(anyToString(theme["slug"])),
|
|
"version": strings.TrimSpace(anyToString(theme["version"])),
|
|
"scope": strings.TrimSpace(anyToString(theme["scope"])),
|
|
"type": strings.TrimSpace(anyToString(theme["type"])),
|
|
"status": strings.TrimSpace(status),
|
|
"summary": strings.TrimSpace(summary),
|
|
"actor": strings.TrimSpace(actor),
|
|
"rollback": rollback,
|
|
}
|
|
if errorMessage != "" {
|
|
audit["errorMessage"] = strings.TrimSpace(errorMessage)
|
|
}
|
|
return audit
|
|
}
|
|
|
|
func anyToString(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
if raw, ok := value.(string); ok {
|
|
return raw
|
|
}
|
|
return ""
|
|
}
|