package main import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" ) const ( keycloakThemeScopePlatform = "platform" keycloakThemeScopeOrganization = "organization" keycloakThemeStatusDraft = "draft" keycloakThemeStatusPublished = "published" keycloakThemeStatusArchived = "archived" keycloakThemeTypeLogin = "login" keycloakThemeTypeAccount = "account" keycloakThemeTypeAdmin = "admin" keycloakThemeTypeEmail = "email" ) func (a *app) keycloakThemeReadMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasPermission(claims, permKeycloakThemesRead) { writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.read") return } next.ServeHTTP(w, r) }) } func (a *app) listPermissionRegistry(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "permissions": keycloakThemePermissionRegistry, }) } func (a *app) listKeycloakThemes(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) docs, err := a.list("keycloak_themes", Doc{}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list keycloak themes") return } result := make([]Doc, 0, len(docs)) for _, doc := range docs { result = append(result, sanitizeThemeDocument(doc, claims)) } writeJSON(w, http.StatusOK, result) } func (a *app) getKeycloakThemeFormSchema(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, Doc{ "sections": keycloakThemeFormSchemaSections(), }) } func (a *app) createKeycloakTheme(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareKeycloakThemePayload(Doc(payload), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } ensureThemeMetadataDefaults(doc) stampThemeEditorMetadata(doc, nil, claims) if err := validateKeycloakThemeFormValues(resolveThemeMetadata(nil, doc)); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKeycloakThemePolicy(nil, doc, claims); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := authorizeKeycloakThemeCreate(claims, doc); err != nil { writeError(w, http.StatusForbidden, err.Error()) return } created, err := a.create("keycloak_themes", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create keycloak theme") return } writeJSON(w, http.StatusCreated, sanitizeThemeDocument(created, claims)) } func (a *app) getKeycloakTheme(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { writeError(w, http.StatusBadRequest, "id is required") return } doc, err := a.getByID("keycloak_themes", id) 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 } writeJSON(w, http.StatusOK, sanitizeThemeDocument(doc, claims)) } func (a *app) patchKeycloakTheme(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { writeError(w, http.StatusBadRequest, "id is required") return } existing, err := a.getByID("keycloak_themes", id) 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 } var patch map[string]any if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareKeycloakThemePayload(Doc(patch), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } stampThemeEditorMetadata(doc, existing, claims) if err := validateKeycloakThemeFormValues(resolveThemeMetadata(existing, doc)); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKeycloakThemePolicy(existing, doc, claims); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := authorizeKeycloakThemePatch(claims, existing, doc); err != nil { writeError(w, http.StatusForbidden, err.Error()) return } updated, err := a.patch("keycloak_themes", id, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update keycloak theme") return } writeJSON(w, http.StatusOK, sanitizeThemeDocument(updated, claims)) } func (a *app) deleteKeycloakTheme(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { writeError(w, http.StatusBadRequest, "id is required") return } existing, err := a.getByID("keycloak_themes", id) 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 } if err := authorizeKeycloakThemeDelete(claims, existing); err != nil { writeError(w, http.StatusForbidden, err.Error()) return } if err := a.deleteByID("keycloak_themes", id); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to delete keycloak theme") return } w.WriteHeader(http.StatusNoContent) } func prepareKeycloakThemePayload(payload Doc, partial bool) (Doc, error) { doc := Doc{} if !partial { if _, ok := payload["status"]; !ok { doc["status"] = keycloakThemeStatusDraft } } for key, value := range payload { switch key { case "name", "slug", "version": trimmed, ok := value.(string) if !ok || strings.TrimSpace(trimmed) == "" { return nil, errors.New(key + " is required") } doc[key] = strings.TrimSpace(trimmed) case "status": status, ok := value.(string) if !ok { return nil, errors.New("status must be a string") } status = strings.TrimSpace(strings.ToLower(status)) if status != keycloakThemeStatusDraft && status != keycloakThemeStatusPublished && status != keycloakThemeStatusArchived { return nil, errors.New("status must be one of: draft, published, archived") } doc[key] = status case "scope": scope, ok := value.(string) if !ok { return nil, errors.New("scope must be a string") } scope = strings.TrimSpace(strings.ToLower(scope)) if scope != keycloakThemeScopePlatform && scope != keycloakThemeScopeOrganization { return nil, errors.New("scope must be one of: platform, organization") } doc[key] = scope case "type": typeValue, ok := value.(string) if !ok { return nil, errors.New("type must be a string") } typeValue = strings.TrimSpace(strings.ToLower(typeValue)) if typeValue != keycloakThemeTypeLogin && typeValue != keycloakThemeTypeAccount && typeValue != keycloakThemeTypeAdmin && typeValue != keycloakThemeTypeEmail { return nil, errors.New("type must be one of: login, account, admin, email") } doc[key] = typeValue case "target": target, ok := normalizeThemeTarget(value) if !ok { return nil, errors.New("target must include a realm and optional client") } doc[key] = target case "metadata": metadata, ok := normalizeThemeMetadata(value) if !ok { return nil, errors.New("metadata must be an object") } if hasSensitiveThemeMetadata(metadata) { return nil, errors.New("metadata contains restricted keys") } doc[key] = metadata } } if !partial { required := []string{"name", "slug", "scope", "type", "version", "target"} for _, key := range required { if _, ok := doc[key]; !ok { return nil, errors.New(key + " is required") } } } if scope, ok := doc["scope"].(string); ok && scope == keycloakThemeScopeOrganization { target, _ := doc["target"].(Doc) orgID, _ := target["organizationId"].(string) if strings.TrimSpace(orgID) == "" { return nil, errors.New("target.organizationId is required for organization scope") } } return doc, nil } func normalizeThemeTarget(value any) (Doc, bool) { targetMap, ok := value.(map[string]any) if !ok { bsonTarget, docOK := value.(Doc) if !docOK { return nil, false } targetMap = map[string]any(bsonTarget) } realm, ok := targetMap["realm"].(string) if !ok || strings.TrimSpace(realm) == "" { return nil, false } normalized := Doc{"realm": strings.TrimSpace(realm)} if client, ok := targetMap["client"].(string); ok && strings.TrimSpace(client) != "" { normalized["client"] = strings.TrimSpace(client) } if organizationID, ok := targetMap["organizationId"].(string); ok && strings.TrimSpace(organizationID) != "" { normalized["organizationId"] = strings.TrimSpace(organizationID) } return normalized, true } func normalizeThemeMetadata(value any) (Doc, bool) { metadataMap, ok := value.(map[string]any) if !ok { bsonMetadata, docOK := value.(Doc) if !docOK { return nil, false } metadataMap = map[string]any(bsonMetadata) } normalized := Doc{} for key, val := range metadataMap { normalized[strings.TrimSpace(key)] = val } return normalized, true } func hasSensitiveThemeMetadata(metadata Doc) bool { for key := range metadata { k := strings.ToLower(strings.TrimSpace(key)) if strings.Contains(k, "secret") || strings.Contains(k, "password") || strings.Contains(k, "token") || strings.Contains(k, "credential") || strings.Contains(k, "filesystem") || strings.Contains(k, "path") { return true } } return false } func authorizeKeycloakThemeCreate(claims *accessClaims, doc Doc) error { scope, _ := doc["scope"].(string) status, _ := doc["status"].(string) if scope == keycloakThemeScopePlatform { if !hasAnyRole(claims, "platform_owner", "platform_admin") { return errors.New("only platform internal roles can create platform themes") } if !hasPermission(claims, permKeycloakThemesCreate) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) { return errors.New("missing permissions for platform theme management") } if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { return errors.New("missing permission: keycloak_themes.publish") } return nil } if scope == keycloakThemeScopeOrganization { if !hasPermission(claims, permKeycloakThemesCreate) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) { return errors.New("missing permissions for organisation theme management") } isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") if !isPlatformRole { if !hasAnyRole(claims, "organisation_owner", "organisation_admin") { return errors.New("only organisation admins can manage organisation-scoped themes") } if status != keycloakThemeStatusDraft { return errors.New("organisation admins can only manage draft organisation themes") } } if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { return errors.New("missing permission: keycloak_themes.publish") } return nil } return errors.New("invalid theme scope") } func authorizeKeycloakThemePatch(claims *accessClaims, existing, patch Doc) error { scope, _ := existing["scope"].(string) if scopePatch, ok := patch["scope"].(string); ok { scope = scopePatch } status, _ := existing["status"].(string) if statusPatch, ok := patch["status"].(string); ok { status = statusPatch } if scope == keycloakThemeScopePlatform { if !hasAnyRole(claims, "platform_owner", "platform_admin") { return errors.New("only platform internal roles can edit platform themes") } if !hasPermission(claims, permKeycloakThemesEdit) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) { return errors.New("missing permissions for platform theme management") } if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { return errors.New("missing permission: keycloak_themes.publish") } return nil } if !hasPermission(claims, permKeycloakThemesEdit) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) { return errors.New("missing permissions for organisation theme management") } isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") if !isPlatformRole { if !hasAnyRole(claims, "organisation_owner", "organisation_admin") { return errors.New("only organisation admins can edit organisation themes") } if status != keycloakThemeStatusDraft { return errors.New("organisation admins can only manage draft organisation themes") } } if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) { return errors.New("missing permission: keycloak_themes.publish") } return nil } func authorizeKeycloakThemeDelete(claims *accessClaims, existing Doc) error { scope, _ := existing["scope"].(string) status, _ := existing["status"].(string) if scope == keycloakThemeScopePlatform { if !hasAnyRole(claims, "platform_owner", "platform_admin") { return errors.New("only platform internal roles can delete platform themes") } if !hasPermission(claims, permKeycloakThemesArchive) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) { return errors.New("missing permissions for platform theme management") } return nil } if !hasPermission(claims, permKeycloakThemesArchive) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) { return errors.New("missing permissions for organisation theme management") } isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin") if !isPlatformRole { if !hasAnyRole(claims, "organisation_owner", "organisation_admin") { return errors.New("only organisation admins can delete organisation themes") } if status != keycloakThemeStatusDraft { return errors.New("organisation admins can only manage draft organisation themes") } } return nil } func sanitizeThemeDocument(doc Doc, claims *accessClaims) Doc { if hasAnyRole(claims, "platform_owner", "platform_admin", "admin") { return doc } sanitized := Doc{} for key, value := range doc { if isSensitiveThemeField(key) { continue } if key == "metadata" { metadata, ok := value.(Doc) if !ok { sanitized[key] = value continue } clean := Doc{} for mk, mv := range metadata { if !isSensitiveThemeField(mk) { clean[mk] = mv } } sanitized[key] = clean continue } sanitized[key] = value } return sanitized } func isSensitiveThemeField(key string) bool { k := strings.ToLower(strings.TrimSpace(key)) return strings.Contains(k, "secret") || strings.Contains(k, "password") || strings.Contains(k, "token") || strings.Contains(k, "credential") || strings.Contains(k, "filesystem") || strings.Contains(k, "path") } func stampThemeEditorMetadata(doc, existing Doc, claims *accessClaims) { metadata := resolveThemeMetadata(existing, doc) if metadata == nil { metadata = Doc{} } now := time.Now().UnixMilli() if _, exists := metadata["createdAt"]; !exists { metadata["createdAt"] = now } if _, exists := metadata["createdBy"]; !exists { metadata["createdBy"] = themeEditorActor(claims) } metadata["updatedAt"] = now metadata["updatedBy"] = themeEditorActor(claims) doc["metadata"] = metadata } func themeEditorActor(claims *accessClaims) string { if claims == nil { return "unknown" } if strings.TrimSpace(claims.PreferredUsername) != "" { return strings.TrimSpace(claims.PreferredUsername) } if strings.TrimSpace(claims.Email) != "" { return strings.TrimSpace(claims.Email) } if strings.TrimSpace(claims.Sub) != "" { return strings.TrimSpace(claims.Sub) } return "unknown" }