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