teg/backend/admin_milestone_roles.go
2026-06-25 20:08:45 +02:00

846 lines
24 KiB
Go

package main
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"github.com/jackc/pgx/v5"
)
type milestoneRoleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Users []milestoneRoleUserItem `json:"users,omitempty"`
UserCount int `json:"userCount"`
CanEdit bool `json:"canEdit"`
CanDelete bool `json:"canDelete"`
}
type milestoneRoleUserItem struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Name string `json:"name"`
UserName string `json:"userName"`
Username string `json:"username"`
Email string `json:"email"`
Domain string `json:"domain"`
SID string `json:"sid"`
DistinguishedName string `json:"distinguishedName"`
Source string `json:"source"`
}
type saveMilestoneRoleRequest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
type addMilestoneRoleUsersRequest struct {
Users []milestoneRoleUserInput `json:"users"`
}
type milestoneRoleUserInput struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Username string `json:"username"`
UserName string `json:"userName"`
Name string `json:"name"`
Email string `json:"email"`
Domain string `json:"domain"`
DistinguishedName string `json:"distinguishedName"`
SID string `json:"sid"`
Source string `json:"source"`
}
func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) {
currentUser, _ := userFromContext(r.Context())
isAdministrator := userHasRight(currentUser, "admin")
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
roles, err := listOperationMilestoneRoles(r.Context(), config)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rollen konnten nicht geladen werden")
return
}
responseRoles := make([]milestoneRoleResponse, 0, len(roles))
for _, role := range roles {
if milestoneRoleIsAdministrators(role) && !isAdministrator {
continue
}
responseRole := milestoneRoleResponseFromRole(role, nil)
responseRoles = append(responseRoles, responseRole)
}
writeJSON(w, http.StatusOK, map[string]any{
"roles": responseRoles,
})
}
func (s *Server) handleAdminGetMilestoneRole(w http.ResponseWriter, r *http.Request) {
roleID := strings.TrimSpace(r.PathValue("id"))
if roleID == "" {
writeError(w, http.StatusBadRequest, "Rollen-ID fehlt")
return
}
currentUser, _ := userFromContext(r.Context())
isAdministrator := userHasRight(currentUser, "admin")
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
role, err := getOperationMilestoneRole(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden")
return
}
if milestoneRoleIsAdministrators(*role) && !isAdministrator {
writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden")
return
}
users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"role": milestoneRoleResponseFromRole(*role, users),
})
}
func (s *Server) handleAdminCreateMilestoneRole(w http.ResponseWriter, r *http.Request) {
var input saveMilestoneRoleRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
roleName := strings.TrimSpace(input.Name)
if roleName == "" {
roleName = strings.TrimSpace(input.DisplayName)
}
if roleName == "" {
writeError(w, http.StatusBadRequest, "Rollenname ist erforderlich")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
existingRole, err := findOperationMilestoneRoleByName(r.Context(), config, roleName)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rollen konnten nicht geprüft werden")
return
}
if existingRole != nil {
writeError(w, http.StatusConflict, "Eine Rolle mit diesem Namen existiert bereits")
return
}
payload := map[string]any{
"name": roleName,
"displayName": strings.TrimSpace(input.DisplayName),
"description": strings.TrimSpace(input.Description),
}
if strings.TrimSpace(input.DisplayName) == "" {
payload["displayName"] = roleName
}
body, _, err := operationMilestoneRequestJSON(
r.Context(),
config,
http.MethodPost,
"/API/rest/v1/roles",
payload,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht angelegt werden")
return
}
role := operationMilestoneRoleFromItem(decodeOperationMilestoneObject(body))
if role.ID == "" {
roleFromList, err := findOperationMilestoneRoleByName(r.Context(), config, roleName)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde angelegt, konnte aber nicht geladen werden")
return
}
if roleFromList == nil {
writeError(w, http.StatusBadGateway, "Milestone hat keine Rollen-ID zurückgegeben")
return
}
role = *roleFromList
}
roleDetail, err := getOperationMilestoneRole(r.Context(), config, role.ID)
if err == nil && roleDetail != nil {
role = *roleDetail
}
writeJSON(w, http.StatusCreated, map[string]any{
"role": milestoneRoleResponseFromRole(role, nil),
})
}
func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.Request) {
roleID := strings.TrimSpace(r.PathValue("id"))
if roleID == "" {
writeError(w, http.StatusBadRequest, "Rollen-ID fehlt")
return
}
currentUser, _ := userFromContext(r.Context())
isAdministrator := userHasRight(currentUser, "admin")
var input saveMilestoneRoleRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
roleName := strings.TrimSpace(input.Name)
if roleName == "" {
roleName = strings.TrimSpace(input.DisplayName)
}
if roleName == "" {
writeError(w, http.StatusBadRequest, "Rollenname ist erforderlich")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
role, err := getOperationMilestoneRole(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden")
return
}
if milestoneRoleIsAdministrators(*role) && !isAdministrator {
writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden")
return
}
users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden")
return
}
role.Name = roleName
role.DisplayName = strings.TrimSpace(input.DisplayName)
if role.DisplayName == "" {
role.DisplayName = roleName
}
if role.Data == nil {
role.Data = map[string]any{}
}
role.Data["name"] = roleName
role.Data["displayName"] = role.DisplayName
role.Data["description"] = strings.TrimSpace(input.Description)
if err := putOperationMilestoneRole(r.Context(), config, *role, users); err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden")
return
}
role, err = getOperationMilestoneRole(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden")
return
}
users, _ = listOperationMilestoneRoleUsers(r.Context(), config, roleID)
writeJSON(w, http.StatusOK, map[string]any{
"role": milestoneRoleResponseFromRole(*role, users),
})
}
func (s *Server) handleAdminDeleteMilestoneRole(w http.ResponseWriter, r *http.Request) {
roleID := strings.TrimSpace(r.PathValue("id"))
if roleID == "" {
writeError(w, http.StatusBadRequest, "Rollen-ID fehlt")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
role, err := getOperationMilestoneRole(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden")
return
}
if milestoneRoleIsAdministrators(*role) {
writeError(w, http.StatusForbidden, "Die Rolle Administrators darf nicht gelöscht werden")
return
}
_, _, err = operationMilestoneRequestJSON(
r.Context(),
config,
http.MethodDelete,
"/API/rest/v1/roles/"+url.PathEscape(roleID),
nil,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gelöscht werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminAddMilestoneRoleUsers(w http.ResponseWriter, r *http.Request) {
roleID := strings.TrimSpace(r.PathValue("id"))
if roleID == "" {
writeError(w, http.StatusBadRequest, "Rollen-ID fehlt")
return
}
currentUser, _ := userFromContext(r.Context())
isAdministrator := userHasRight(currentUser, "admin")
var input addMilestoneRoleUsersRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if len(input.Users) == 0 {
writeError(w, http.StatusBadRequest, "Keine Benutzer ausgewählt")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
role, users, err := getMilestoneAdminRoleWithUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden")
return
}
if milestoneRoleIsAdministrators(*role) && !isAdministrator {
writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden")
return
}
nextUsers := users
for _, userInput := range input.Users {
user := milestoneRoleUserReferenceFromInput(userInput)
if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
continue
}
nextUsers = appendOperationMilestoneObjectByID(nextUsers, user)
}
if len(nextUsers) == len(users) {
writeError(w, http.StatusBadRequest, "Kein gültiger AD-Benutzer ausgewählt")
return
}
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil {
writeError(w, http.StatusBadGateway, "AD-Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden")
return
}
role, users, err = getMilestoneAdminRoleWithUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"role": milestoneRoleResponseFromRole(*role, users),
})
}
func (s *Server) handleAdminRemoveMilestoneRoleUser(w http.ResponseWriter, r *http.Request) {
roleID := strings.TrimSpace(r.PathValue("id"))
userID := strings.TrimSpace(r.PathValue("userId"))
if roleID == "" || userID == "" {
writeError(w, http.StatusBadRequest, "Rollen-ID oder Benutzer-ID fehlt")
return
}
currentUser, _ := userFromContext(r.Context())
isAdministrator := userHasRight(currentUser, "admin")
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
role, users, err := getMilestoneAdminRoleWithUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden")
return
}
if milestoneRoleIsAdministrators(*role) && !isAdministrator {
writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden")
return
}
nextUsers := make([]map[string]any, 0, len(users))
for _, user := range users {
if strings.EqualFold(milestoneItemID(user), userID) {
if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden")
return
}
continue
}
nextUsers = append(nextUsers, user)
}
if len(nextUsers) == len(users) {
writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden")
return
}
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil {
writeError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden")
return
}
role, users, err = getMilestoneAdminRoleWithUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"role": milestoneRoleResponseFromRole(*role, users),
})
}
func (s *Server) handleAdminListActiveDirectoryUsers(w http.ResponseWriter, r *http.Request) {
credentials, err := s.getActiveDirectoryCredentials(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{
"configured": false,
"users": []activeDirectoryUser{},
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden")
return
}
if !credentials.Configured() {
writeJSON(w, http.StatusOK, map[string]any{
"configured": false,
"users": []activeDirectoryUser{},
})
return
}
users, err := listActiveDirectoryUsers(r.Context(), credentials)
if err != nil {
logError("AD-Benutzer konnten nicht geladen werden", err, LogFields{
"baseDn": credentials.BaseDN,
})
writeError(w, http.StatusBadGateway, "AD-Benutzer konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"baseDn": credentials.BaseDN,
"users": users,
})
}
func (s *Server) handleAdminListMilestoneBasicUsers(w http.ResponseWriter, r *http.Request) {
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
items, err := listOperationMilestoneObjectsAtPath(r.Context(), config, "/API/rest/v1/basicUsers")
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Basisbenutzer konnten nicht geladen werden")
return
}
users := make([]milestoneRoleUserItem, 0, len(items))
for _, item := range items {
user := milestoneRoleUserItemFromObject(item)
if user.ID == "" || milestoneRoleUserItemIsHidden(user) {
continue
}
user.Source = "basic"
if user.Domain == "" {
user.Domain = "Basic"
}
users = append(users, user)
}
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
})
}
func getMilestoneAdminRoleWithUsers(
ctx context.Context,
config milestoneRuntimeConfig,
roleID string,
) (*operationMilestoneRole, []map[string]any, error) {
role, err := getOperationMilestoneRole(ctx, config, roleID)
if err != nil {
return nil, nil, err
}
users, err := listOperationMilestoneRoleUsers(ctx, config, roleID)
if err != nil {
return nil, nil, err
}
return role, users, nil
}
func milestoneRoleResponseFromRole(
role operationMilestoneRole,
users []map[string]any,
) milestoneRoleResponse {
roleData := role.Data
if roleData == nil {
roleData = map[string]any{}
}
if users == nil {
users = operationMilestoneObjectListFromValue(roleData["users"])
}
responseUsers := make([]milestoneRoleUserItem, 0, len(users))
for _, user := range users {
if item := milestoneRoleUserItemFromObject(user); item.ID != "" {
if milestoneRoleUserItemIsHidden(item) {
continue
}
responseUsers = append(responseUsers, item)
}
}
canDelete := !milestoneRoleIsAdministrators(role)
return milestoneRoleResponse{
ID: role.ID,
Name: role.Name,
DisplayName: operationMilestoneRoleDisplayName(role),
Description: strings.TrimSpace(milestoneStringValue(roleData["description"])),
Users: responseUsers,
UserCount: len(responseUsers),
CanEdit: true,
CanDelete: canDelete,
}
}
func milestoneRoleUserItemFromObject(user map[string]any) milestoneRoleUserItem {
id := milestoneItemID(user)
displayName := strings.TrimSpace(operationMilestoneItemDisplayName(user))
name := strings.TrimSpace(milestoneStringValue(user["name"]))
userName := strings.TrimSpace(milestoneStringValue(user["userName"]))
if userName == "" {
userName = strings.TrimSpace(milestoneStringValue(user["username"]))
}
if userName == "" {
userName = strings.TrimSpace(milestoneStringValue(user["accountName"]))
}
if userName == "" {
userName = strings.TrimSpace(milestoneStringValue(user["loginName"]))
}
email := strings.TrimSpace(milestoneStringValue(user["email"]))
if email == "" {
email = strings.TrimSpace(milestoneStringValue(user["mail"]))
}
sid := strings.TrimSpace(milestoneStringValue(user["sid"]))
if sid == "" {
sid = strings.TrimSpace(milestoneStringValue(user["objectSid"]))
}
distinguishedName := strings.TrimSpace(milestoneStringValue(user["distinguishedName"]))
source := milestoneRoleUserSourceFromObject(user)
domain := milestoneRoleUserDomainFromObject(user)
if splitDomain, splitUserName := splitMilestoneDomainUser(userName); splitDomain != "" {
if domain == "" {
domain = splitDomain
}
userName = splitUserName
}
if splitDomain, splitName := splitMilestoneDomainUser(name); splitDomain != "" {
if domain == "" {
domain = splitDomain
}
name = splitName
}
if splitDomain, splitDisplayName := splitMilestoneDomainUser(displayName); splitDomain != "" {
if domain == "" {
domain = splitDomain
}
if userName == "" {
userName = splitDisplayName
}
displayName = splitDisplayName
}
if domain == "" {
domain = activeDirectoryDomainFromPrincipal(milestoneStringValue(user["userPrincipalName"]))
}
if domain == "" && email != "" {
domain = activeDirectoryDomainFromPrincipal(email)
}
if domain == "" && distinguishedName != "" {
domain = activeDirectoryDomainFromDN(distinguishedName)
}
if domain == "" && source == "basic" {
domain = "Basic"
}
if displayName == "" {
displayName = userName
}
if displayName == "" {
displayName = name
}
if displayName == "" {
displayName = id
}
return milestoneRoleUserItem{
ID: id,
DisplayName: displayName,
Name: name,
UserName: userName,
Username: userName,
Email: email,
Domain: domain,
SID: sid,
DistinguishedName: distinguishedName,
Source: source,
}
}
func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[string]any {
userID := strings.TrimSpace(input.ID)
userSID := strings.TrimSpace(input.SID)
userDN := strings.TrimSpace(input.DistinguishedName)
displayName := strings.TrimSpace(input.DisplayName)
username := strings.TrimSpace(input.Username)
if username == "" {
username = strings.TrimSpace(input.UserName)
}
name := strings.TrimSpace(input.Name)
email := strings.TrimSpace(input.Email)
domain := strings.TrimSpace(input.Domain)
source := milestoneRoleUserSource(input.Source)
if source == "" {
source = "ad"
}
if userID == "" {
userID = userSID
}
if userID == "" {
userID = userDN
}
if userID == "" {
userID = username
}
if displayName == "" {
displayName = name
}
if displayName == "" {
displayName = username
}
if displayName == "" {
displayName = email
}
if displayName == "" {
displayName = userID
}
user := map[string]any{
"id": userID,
"name": displayName,
"displayName": displayName,
"userName": username,
"username": username,
"email": email,
"domain": domain,
"source": source,
"relations": map[string]any{
"self": map[string]any{
"type": milestoneRoleUserRelationType(source),
"id": userID,
},
},
}
if userSID != "" {
user["sid"] = userSID
user["objectSid"] = userSID
}
if userDN != "" {
user["distinguishedName"] = userDN
}
return user
}
func milestoneRoleUserSourceFromObject(user map[string]any) string {
source := milestoneRoleUserSource(milestoneStringValue(user["source"]))
if source != "" {
return source
}
if relations, ok := user["relations"].(map[string]any); ok {
if self, ok := relations["self"].(map[string]any); ok {
relationType := strings.TrimSpace(milestoneStringValue(self["type"]))
if strings.EqualFold(relationType, "basicUsers") ||
strings.EqualFold(relationType, "basicUser") {
return "basic"
}
}
}
itemType := strings.TrimSpace(milestoneStringValue(user["type"]))
if strings.EqualFold(itemType, "basicUsers") ||
strings.EqualFold(itemType, "basicUser") {
return "basic"
}
return "ad"
}
func milestoneRoleUserSource(source string) string {
source = strings.TrimSpace(strings.ToLower(source))
switch source {
case "basic", "basicuser", "basicusers":
return "basic"
case "ad", "active-directory", "activedirectory", "directory":
return "ad"
default:
return ""
}
}
func milestoneRoleUserRelationType(source string) string {
if milestoneRoleUserSource(source) == "basic" {
return "basicUsers"
}
return "users"
}
func milestoneRoleUserDomainFromObject(user map[string]any) string {
for _, key := range []string{
"domain",
"domainName",
"accountDomain",
"windowsDomain",
"netbiosDomain",
"directoryDomain",
} {
if value := strings.TrimSpace(milestoneStringValue(user[key])); value != "" {
return value
}
}
return ""
}
func milestoneRoleIsAdministrators(role operationMilestoneRole) bool {
for _, value := range []string{role.Name, role.DisplayName, operationMilestoneRoleDisplayName(role)} {
if normalizeMilestoneRoleProtectionValue(value) == "administrators" {
return true
}
}
return false
}
func milestoneRoleUserItemIsHidden(user milestoneRoleUserItem) bool {
candidates := []string{
milestoneProtectedUserQualifiedName(user.Domain, user.Username),
milestoneProtectedUserQualifiedName(user.Domain, user.UserName),
milestoneProtectedUserQualifiedName(user.Domain, user.Name),
milestoneProtectedUserQualifiedName(user.Domain, user.DisplayName),
}
for _, candidate := range candidates {
if milestoneProtectedUserNameIsHidden(candidate) {
return true
}
}
return false
}
func milestoneProtectedUserNameIsHidden(value string) bool {
value = normalizeMilestoneRoleProtectionValue(value)
switch value {
case "nt-autorität\\netzwerkdienst",
"nt authority\\network service",
"tegdssd\\tegadmin",
"vordefiniert\\administratoren",
"builtin\\administrators",
"tegdssd\\milestone_administratoren":
return true
}
domain, account, ok := strings.Cut(value, "\\")
return ok && domain == "tegdssd" && strings.HasSuffix(account, "$")
}
func milestoneProtectedUserQualifiedName(domain string, account string) string {
domain = strings.TrimSpace(domain)
account = strings.TrimSpace(account)
if account == "" {
return ""
}
if domain == "" {
return account
}
return domain + "\\" + account
}
func normalizeMilestoneRoleProtectionValue(value string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
}
func splitMilestoneDomainUser(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" {
return "", ""
}
domain, username, ok := strings.Cut(value, "\\")
if !ok || strings.TrimSpace(domain) == "" || strings.TrimSpace(username) == "" {
return "", value
}
return strings.TrimSpace(domain), strings.TrimSpace(username)
}