555 lines
16 KiB
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"
|
|
}
|