learn-platform-deploy/Backend/cmd/api/keycloak_themes.go

555 lines
16 KiB
Go

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