learn-platform-deploy/Backend/cmd/api/keycloak_theme_deployment.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 ""
}