forked from baron/baron-sso
3052 lines
97 KiB
Go
3052 lines
97 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/pagination"
|
|
"baron-sso-backend/internal/repository"
|
|
"baron-sso-backend/internal/service"
|
|
"baron-sso-backend/internal/utils"
|
|
"context"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// OryProviderAPI defines the subset of Ory Provider used by UserHandler
|
|
type OryProviderAPI interface {
|
|
CreateUser(user *domain.BrokerUser, password string) (string, error)
|
|
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
|
|
GetPasswordPolicy() (*domain.PasswordPolicy, error)
|
|
}
|
|
|
|
type UserHandler struct {
|
|
KratosAdmin service.KratosAdminService
|
|
OryProvider OryProviderAPI
|
|
TenantService service.TenantService
|
|
KetoService service.KetoService
|
|
KetoOutboxRepo repository.KetoOutboxRepository
|
|
UserRepo repository.UserRepository
|
|
UserProjectionRepo repository.UserProjectionRepository
|
|
UserGroupRepo repository.UserGroupRepository
|
|
AuditRepo domain.AuditRepository
|
|
Worksmobile service.WorksmobileSyncer
|
|
}
|
|
|
|
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
|
|
return &UserHandler{
|
|
KratosAdmin: kratosAdmin,
|
|
OryProvider: oryProvider,
|
|
TenantService: tenantService,
|
|
KetoService: ketoService,
|
|
KetoOutboxRepo: ketoOutboxRepo,
|
|
UserRepo: userRepo,
|
|
UserGroupRepo: userGroupRepo,
|
|
AuditRepo: auditRepo,
|
|
}
|
|
}
|
|
|
|
func (h *UserHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
|
h.Worksmobile = syncer
|
|
}
|
|
|
|
func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[string]any, primaryTenantID string, primaryTenantName string, primaryTenantIsOwner *bool) map[string]any {
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
}
|
|
if len(appointments) > 0 {
|
|
values := make([]any, 0, len(appointments))
|
|
for _, appointment := range appointments {
|
|
values = append(values, appointment)
|
|
}
|
|
metadata["additionalAppointments"] = values
|
|
}
|
|
if strings.TrimSpace(primaryTenantID) != "" {
|
|
metadata["primaryTenantId"] = strings.TrimSpace(primaryTenantID)
|
|
}
|
|
if strings.TrimSpace(primaryTenantName) != "" {
|
|
metadata["primaryTenantName"] = strings.TrimSpace(primaryTenantName)
|
|
}
|
|
if primaryTenantIsOwner != nil {
|
|
metadata["primaryTenantIsOwner"] = *primaryTenantIsOwner
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
func sanitizeUserMetadata(metadata map[string]any) map[string]any {
|
|
if metadata == nil {
|
|
return nil
|
|
}
|
|
sanitized := make(map[string]any, len(metadata))
|
|
for key, value := range metadata {
|
|
if key == "hanmacFamily" || key == "userType" {
|
|
continue
|
|
}
|
|
sanitized[key] = value
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
|
|
if value := strings.TrimSpace(primaryTenantID); value != "" {
|
|
return value
|
|
}
|
|
if value := normalizeMetadataString(metadata["primaryTenantId"]); value != "" {
|
|
return value
|
|
}
|
|
for _, appointment := range appointments {
|
|
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary {
|
|
if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
if len(appointments) > 0 {
|
|
return normalizeMetadataString(appointments[0]["tenantId"])
|
|
}
|
|
if raw, ok := metadata["additionalAppointments"].([]any); ok {
|
|
for _, item := range raw {
|
|
appointment, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary {
|
|
if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
if len(raw) > 0 {
|
|
if appointment, ok := raw[0].(map[string]any); ok {
|
|
return normalizeMetadataString(appointment["tenantId"])
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func bulkUserEmailDomainCandidates(emailDomain string, email string) []string {
|
|
values := make([]string, 0, 2)
|
|
seen := map[string]bool{}
|
|
add := func(value string) {
|
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
if normalized == "" || seen[normalized] {
|
|
return
|
|
}
|
|
seen[normalized] = true
|
|
values = append(values, normalized)
|
|
}
|
|
for _, value := range strings.FieldsFunc(emailDomain, func(r rune) bool {
|
|
return r == ',' || r == ';' || r == '\n' || r == '\r'
|
|
}) {
|
|
add(value)
|
|
}
|
|
if _, domainPart, err := domain.SplitEmailDomain(email); err == nil {
|
|
add(domainPart)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func bulkUserAssignmentContainsTenant(appointments []any, primaryTenantID string, tenantID string) bool {
|
|
if strings.TrimSpace(tenantID) == "" {
|
|
return true
|
|
}
|
|
if primaryTenantID != "" && primaryTenantID == tenantID {
|
|
return true
|
|
}
|
|
for _, item := range appointments {
|
|
appointment, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if normalizeMetadataString(appointment["tenantId"]) == tenantID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
|
|
for _, key := range keys {
|
|
value, ok := metadata[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v, true
|
|
case string:
|
|
normalized := strings.ToLower(strings.TrimSpace(v))
|
|
if normalized == "true" || normalized == "1" || normalized == "yes" {
|
|
return true, true
|
|
}
|
|
if normalized == "false" || normalized == "0" || normalized == "no" {
|
|
return false, true
|
|
}
|
|
case float64:
|
|
return v != 0, true
|
|
case int:
|
|
return v != 0, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func roleFromTraits(traits map[string]interface{}) string {
|
|
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
|
|
return role
|
|
}
|
|
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "grade")); ok {
|
|
return role
|
|
}
|
|
return domain.RoleUser
|
|
}
|
|
|
|
func normalizeAssignableSystemRole(value string) (string, bool) {
|
|
role, ok := domain.NormalizeRoleAlias(value)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
|
|
}
|
|
|
|
func gradeFromTraits(traits map[string]interface{}) string {
|
|
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if _, ok := domain.NormalizeRoleAlias(value); ok {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func rejectLegacyCompanyCode(value string) error {
|
|
if strings.TrimSpace(value) != "" {
|
|
return errors.New("companyCode is deprecated; use tenantSlug")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rejectLegacyCompanyCodePointer(value *string) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return rejectLegacyCompanyCode(*value)
|
|
}
|
|
|
|
func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) (string, error) {
|
|
if err := rejectLegacyCompanyCode(legacyCompanyCode); err != nil {
|
|
return "", err
|
|
}
|
|
if value := strings.TrimSpace(tenantSlug); value != "" {
|
|
return value, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) (*string, error) {
|
|
if err := rejectLegacyCompanyCodePointer(legacyCompanyCode); err != nil {
|
|
return nil, err
|
|
}
|
|
if tenantSlug != nil {
|
|
value := strings.TrimSpace(*tenantSlug)
|
|
return &value, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func identityTenantAccessKeys(traits map[string]interface{}) []string {
|
|
keys := make([]string, 0, 2)
|
|
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
|
|
keys = append(keys, tenantID)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool {
|
|
for _, key := range keys {
|
|
if allowed[key] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func profileTenantAccessKeys(profile *domain.UserProfileResponse) map[string]bool {
|
|
allowed := make(map[string]bool)
|
|
if profile == nil {
|
|
return allowed
|
|
}
|
|
if profile.TenantID != nil {
|
|
if id := strings.ToLower(strings.TrimSpace(*profile.TenantID)); id != "" {
|
|
allowed[id] = true
|
|
}
|
|
}
|
|
for _, tenant := range profile.ManageableTenants {
|
|
if id := strings.ToLower(strings.TrimSpace(tenant.ID)); id != "" {
|
|
allowed[id] = true
|
|
}
|
|
if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
|
|
allowed[slug] = true
|
|
}
|
|
}
|
|
for _, tenant := range profile.JoinedTenants {
|
|
if id := strings.ToLower(strings.TrimSpace(tenant.ID)); id != "" {
|
|
allowed[id] = true
|
|
}
|
|
if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
|
|
allowed[slug] = true
|
|
}
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
func profileCanAccessTenant(profile *domain.UserProfileResponse, tenantID, tenantSlug string) bool {
|
|
allowed := profileTenantAccessKeys(profile)
|
|
if id := strings.ToLower(strings.TrimSpace(tenantID)); id != "" && allowed[id] {
|
|
return true
|
|
}
|
|
if slug := strings.ToLower(strings.TrimSpace(tenantSlug)); slug != "" && allowed[slug] {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func userTenantID(user domain.User) string {
|
|
if user.TenantID == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(*user.TenantID)
|
|
}
|
|
|
|
func userTenantSlug(user domain.User) string {
|
|
if user.Tenant != nil {
|
|
return strings.TrimSpace(user.Tenant.Slug)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type userSummary struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
LoginID string `json:"loginId,omitempty"`
|
|
CustomLoginIDs []string `json:"customLoginIds,omitempty"` // [New] 다중 로그인 ID 목록
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
TenantSlug string `json:"tenantSlug,omitempty"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
|
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
|
Department string `json:"department"`
|
|
Grade string `json:"grade"`
|
|
Position string `json:"position"`
|
|
JobTitle string `json:"jobTitle"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
InitialPassword string `json:"initialPassword,omitempty"`
|
|
}
|
|
|
|
type userListResponse struct {
|
|
Items []userSummary `json:"items"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
Total int64 `json:"total"`
|
|
Cursor string `json:"cursor,omitempty"`
|
|
NextCursor string `json:"nextCursor,omitempty"`
|
|
}
|
|
|
|
func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string) {
|
|
timestamp := identity.CreatedAt
|
|
if timestamp.IsZero() {
|
|
timestamp = time.Unix(0, 0).UTC()
|
|
}
|
|
return timestamp, identity.ID
|
|
}
|
|
|
|
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|
// [New] Get requester profile from middleware
|
|
var requesterRole string
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
|
requesterRole = domain.NormalizeRole(profile.Role)
|
|
}
|
|
|
|
limit := c.QueryInt("limit", 50)
|
|
offset := c.QueryInt("offset", 0)
|
|
search := strings.TrimSpace(c.Query("search"))
|
|
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
|
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
|
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
// [New] Manageable Tenants Map for efficient lookup
|
|
manageableSlugs := make(map[string]bool)
|
|
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
|
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if profile != nil {
|
|
var baseTenantIDs []string
|
|
for _, t := range profile.ManageableTenants {
|
|
manageableSlugs[strings.ToLower(t.Slug)] = true
|
|
manageableSlugs[strings.ToLower(t.ID)] = true
|
|
baseTenantIDs = append(baseTenantIDs, t.ID)
|
|
}
|
|
for _, t := range profile.JoinedTenants {
|
|
manageableSlugs[strings.ToLower(t.Slug)] = true
|
|
manageableSlugs[strings.ToLower(t.ID)] = true
|
|
baseTenantIDs = append(baseTenantIDs, t.ID)
|
|
}
|
|
if profile.TenantID != nil {
|
|
manageableSlugs[strings.ToLower(*profile.TenantID)] = true
|
|
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
|
|
}
|
|
|
|
// Expand manageableSlugs to the entire tenant tree (root + all descendants)
|
|
if h.TenantService != nil && len(baseTenantIDs) > 0 {
|
|
allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "")
|
|
if err == nil {
|
|
parentMap := make(map[string]string)
|
|
for _, t := range allTenants {
|
|
if t.ParentID != nil {
|
|
parentMap[t.ID] = *t.ParentID
|
|
}
|
|
}
|
|
|
|
// Function to find the root of any given tenant
|
|
findRoot := func(id string) string {
|
|
curr := id
|
|
for {
|
|
p, exists := parentMap[curr]
|
|
if !exists || p == "" {
|
|
break
|
|
}
|
|
curr = p
|
|
}
|
|
return curr
|
|
}
|
|
|
|
// Collect root IDs for all base tenants
|
|
roots := make(map[string]bool)
|
|
for _, id := range baseTenantIDs {
|
|
roots[findRoot(id)] = true
|
|
}
|
|
|
|
// If a tenant shares a root with any base tenant, it's in the same tree family
|
|
for _, t := range allTenants {
|
|
if roots[findRoot(t.ID)] {
|
|
manageableSlugs[strings.ToLower(t.Slug)] = true
|
|
manageableSlugs[strings.ToLower(t.ID)] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var targetTenantID string
|
|
if tenantSlug != "" && h.TenantService != nil {
|
|
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
|
if err == nil && t != nil {
|
|
targetTenantID = strings.ToLower(t.ID)
|
|
}
|
|
}
|
|
|
|
if h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
|
}
|
|
|
|
identities, err := h.KratosAdmin.ListIdentities(c.Context())
|
|
if err == nil {
|
|
filtered := make([]service.KratosIdentity, 0, len(identities))
|
|
searchLower := strings.ToLower(search)
|
|
|
|
for _, identity := range identities {
|
|
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
|
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
|
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
|
|
|
// Tenant Admin & Member filtering
|
|
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
|
|
hasAccess := manageableSlugs[tID]
|
|
if !hasAccess {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Dedicated tenantSlug filter
|
|
if tenantSlug != "" {
|
|
matches := tID == targetTenantID
|
|
if !matches {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Search filtering
|
|
if search != "" {
|
|
matchesSearch := strings.Contains(email, searchLower) ||
|
|
strings.Contains(name, searchLower)
|
|
|
|
if !matchesSearch {
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, identity)
|
|
}
|
|
|
|
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
|
|
total := int64(len(filtered))
|
|
nextCursor := ""
|
|
var pageIdentities []service.KratosIdentity
|
|
if cursorRaw != "" {
|
|
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
|
}
|
|
offset = 0
|
|
} else {
|
|
if offset > len(filtered) {
|
|
offset = len(filtered)
|
|
}
|
|
end := offset + limit
|
|
if end > len(filtered) {
|
|
end = len(filtered)
|
|
}
|
|
pageIdentities = filtered[offset:end]
|
|
if total > int64(end) && len(pageIdentities) > 0 {
|
|
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
|
nextCursor = pagination.Encode(lastTimestamp, lastID)
|
|
}
|
|
}
|
|
|
|
items := make([]userSummary, 0, len(pageIdentities))
|
|
for _, identity := range pageIdentities {
|
|
summary := h.mapIdentitySummary(c.Context(), identity)
|
|
items = append(items, summary)
|
|
}
|
|
|
|
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
|
|
// This ensures that member counts (which use local DB) eventually match reality
|
|
if h.UserRepo != nil {
|
|
go func(ids []service.KratosIdentity) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
for _, identity := range ids {
|
|
localUser := h.mapToLocalUser(identity)
|
|
_ = h.UserRepo.Update(ctx, localUser)
|
|
}
|
|
}(filtered)
|
|
}
|
|
|
|
return c.JSON(userListResponse{
|
|
Items: items,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
Total: total,
|
|
Cursor: cursorRaw,
|
|
NextCursor: nextCursor,
|
|
})
|
|
}
|
|
|
|
slog.Warn("Kratos unavailable for user list", "error", err)
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
|
|
}
|
|
|
|
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
|
}
|
|
|
|
userID := strings.TrimSpace(c.Params("id"))
|
|
if userID == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
if identity == nil {
|
|
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
|
}
|
|
|
|
// [New] Check access scope
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
allowedKeys := profileTenantAccessKeys(requester)
|
|
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
|
|
}
|
|
}
|
|
|
|
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
|
|
}
|
|
|
|
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|
if h.OryProvider == nil || h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
|
}
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
LoginID string `json:"loginId"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
TenantSlug string `json:"tenantSlug"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Department string `json:"department"`
|
|
Grade string `json:"grade"`
|
|
Position string `json:"position"`
|
|
JobTitle string `json:"jobTitle"`
|
|
PrimaryTenantID string `json:"primaryTenantId"`
|
|
PrimaryTenantName string `json:"primaryTenantName"`
|
|
PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"`
|
|
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
tenantSlug, err := tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
req.CompanyCode = tenantSlug
|
|
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
|
|
|
email := strings.TrimSpace(req.Email)
|
|
if email == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "email is required")
|
|
}
|
|
if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid email format")
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
|
}
|
|
|
|
password := strings.TrimSpace(req.Password)
|
|
policy, err := h.OryProvider.GetPasswordPolicy()
|
|
if err != nil || policy == nil {
|
|
policy = &domain.PasswordPolicy{
|
|
MinLength: 12,
|
|
Lowercase: true,
|
|
Uppercase: false,
|
|
Number: true,
|
|
NonAlphanumeric: true,
|
|
MinCharacterTypes: 0,
|
|
}
|
|
}
|
|
|
|
generatedPassword := ""
|
|
if password == "" {
|
|
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
|
if genErr != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate password")
|
|
}
|
|
password = generated
|
|
generatedPassword = generated
|
|
} else {
|
|
if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
role := domain.RoleUser
|
|
if strings.TrimSpace(req.Role) != "" {
|
|
normalizedRole, ok := normalizeAssignableSystemRole(req.Role)
|
|
if !ok {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
|
}
|
|
if normalizedRole == domain.RoleSuperAdmin {
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can assign super admin role")
|
|
}
|
|
}
|
|
role = normalizedRole
|
|
}
|
|
|
|
attributes := map[string]interface{}{
|
|
"department": req.Department,
|
|
"grade": strings.TrimSpace(req.Grade),
|
|
"position": req.Position,
|
|
"jobTitle": req.JobTitle,
|
|
"affiliationType": "internal",
|
|
}
|
|
|
|
// [Override with explicit LoginID if provided]
|
|
if req.LoginID != "" {
|
|
if ids, ok := attributes["custom_login_ids"].([]string); ok {
|
|
attributes["custom_login_ids"] = append(ids, req.LoginID)
|
|
} else {
|
|
attributes["custom_login_ids"] = []string{req.LoginID}
|
|
}
|
|
}
|
|
|
|
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
|
var tenantID string
|
|
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments)
|
|
if req.CompanyCode == "" && h.TenantService != nil {
|
|
if requestedPrimaryTenantID != "" {
|
|
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
|
|
tenantID = tenant.ID
|
|
req.CompanyCode = tenant.Slug
|
|
}
|
|
}
|
|
}
|
|
if req.CompanyCode != "" && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
|
tenantID = tenant.ID
|
|
}
|
|
}
|
|
if tenantID == "" {
|
|
if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
|
|
}
|
|
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
|
|
}
|
|
tenantID = tenant.ID
|
|
req.CompanyCode = tenant.Slug
|
|
}
|
|
|
|
attributes["role"] = role
|
|
if tenantID != "" {
|
|
attributes["tenant_id"] = tenantID
|
|
}
|
|
|
|
// Collect and sync all custom login IDs based on tenant schemas
|
|
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
|
|
|
|
if h.UserRepo != nil {
|
|
if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil {
|
|
if strings.Contains(err.Error(), "한맥가족") {
|
|
return errorJSON(c, fiber.StatusConflict, err.Error())
|
|
}
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
// Merge custom metadata into attributes
|
|
for k, v := range req.Metadata {
|
|
// Don't overwrite core fields
|
|
if _, exists := attributes[k]; !exists {
|
|
attributes[k] = v
|
|
}
|
|
}
|
|
|
|
// Validate all collected LoginIDs
|
|
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
|
|
for _, lid := range collectedIDs {
|
|
if err := domain.ValidateLoginID(lid, []string{email}, normalizePhoneNumber(req.Phone)); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
brokerUser := &domain.BrokerUser{
|
|
Email: email,
|
|
Name: name,
|
|
PhoneNumber: normalizePhoneNumber(req.Phone),
|
|
Attributes: attributes,
|
|
}
|
|
|
|
// [Validation] Based on Tenant Schema
|
|
if tenantID != "" && h.TenantService != nil {
|
|
tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
|
|
if err == nil && tenant != nil {
|
|
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
|
|
return errorJSON(c, fiber.StatusConflict, "이미 사용 중인 식별자(이메일/전화번호/사번 등)입니다.")
|
|
}
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
// Fetch the newly created identity to ensure we have all traits
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
if identity == nil {
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
|
}
|
|
|
|
// [New] Local DB Sync - Ensure user exists in read-model
|
|
if h.UserRepo != nil {
|
|
localUser := h.mapToLocalUser(*identity)
|
|
|
|
// Sync to local DB (Synchronous for immediate consistency)
|
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
|
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
|
|
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
|
}
|
|
if h.Worksmobile != nil {
|
|
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
|
|
slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Update User Login IDs in local DB
|
|
for i := range loginIDRecords {
|
|
loginIDRecords[i].UserID = localUser.ID
|
|
}
|
|
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
|
slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
|
|
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
|
}
|
|
|
|
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
|
|
if h.KetoOutboxRepo != nil {
|
|
// 1. Role based relations
|
|
h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
|
|
|
|
// 2. Direct membership to the Tenant (for accurate counting)
|
|
if localUser.TenantID != nil && *localUser.TenantID != "" {
|
|
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: *localUser.TenantID,
|
|
Relation: "members",
|
|
Subject: "User:" + localUser.ID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
response := h.mapIdentitySummary(c.Context(), *identity)
|
|
if generatedPassword != "" {
|
|
response.InitialPassword = generatedPassword
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(response)
|
|
}
|
|
|
|
type bulkUserItem struct {
|
|
Email string `json:"email"`
|
|
LoginID string `json:"loginId"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
TenantID string `json:"tenantId"`
|
|
TenantSlug string `json:"tenantSlug"`
|
|
CompanyCode string `json:"companyCode"`
|
|
EmailDomain string `json:"emailDomain"`
|
|
Department string `json:"department"`
|
|
Grade string `json:"grade"`
|
|
Position string `json:"position"`
|
|
JobTitle string `json:"jobTitle"`
|
|
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
|
|
type bulkUserResult struct {
|
|
Email string `json:"email"`
|
|
OriginalEmail string `json:"originalEmail,omitempty"`
|
|
SuggestedEmail string `json:"suggestedEmail,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
Success bool `json:"success"`
|
|
Message string `json:"message,omitempty"`
|
|
UserID string `json:"userId,omitempty"`
|
|
}
|
|
|
|
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|
if h.OryProvider == nil || h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
|
}
|
|
|
|
var req struct {
|
|
Users []bulkUserItem `json:"users"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
if len(req.Users) == 0 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "no users provided")
|
|
}
|
|
|
|
policy, err := h.OryProvider.GetPasswordPolicy()
|
|
if err != nil || policy == nil {
|
|
policy = &domain.PasswordPolicy{
|
|
MinLength: 12, Number: true, NonAlphanumeric: true,
|
|
}
|
|
}
|
|
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
results := make([]bulkUserResult, 0, len(req.Users))
|
|
var hanmacScope *hanmacEmailScope
|
|
var hanmacLocalParts map[string]bool
|
|
hanmacScopeLoaded := false
|
|
|
|
// Pre-fetch tenant data to avoid redundant DB calls
|
|
type tenantCacheItem struct {
|
|
ID string
|
|
Slug string
|
|
Name string
|
|
Schema []interface{}
|
|
Groups []domain.UserGroup
|
|
LoginIDField string
|
|
}
|
|
tenantCache := make(map[string]tenantCacheItem)
|
|
tenantCacheByID := make(map[string]tenantCacheItem)
|
|
tenantCacheByDomain := make(map[string]tenantCacheItem)
|
|
|
|
buildTenantCacheItem := func(tenant *domain.Tenant) tenantCacheItem {
|
|
tItem := tenantCacheItem{
|
|
ID: tenant.ID,
|
|
Slug: tenant.Slug,
|
|
Name: tenant.Name,
|
|
}
|
|
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
tItem.Schema = s
|
|
}
|
|
if lf, ok := tenant.Config["loginIdField"].(string); ok {
|
|
tItem.LoginIDField = lf
|
|
}
|
|
if h.UserGroupRepo != nil {
|
|
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
|
|
tItem.Groups = groups
|
|
}
|
|
}
|
|
return tItem
|
|
}
|
|
|
|
cacheTenantItem := func(tItem tenantCacheItem) tenantCacheItem {
|
|
if tItem.Slug != "" {
|
|
tenantCache[strings.ToLower(strings.TrimSpace(tItem.Slug))] = tItem
|
|
}
|
|
if tItem.ID != "" {
|
|
tenantCacheByID[tItem.ID] = tItem
|
|
}
|
|
return tItem
|
|
}
|
|
|
|
resolveTenantBySlug := func(slug string) (tenantCacheItem, error) {
|
|
normalizedSlug := strings.ToLower(strings.TrimSpace(slug))
|
|
if normalizedSlug == "" {
|
|
return tenantCacheItem{}, errors.New("tenantSlug is required")
|
|
}
|
|
if tItem, exists := tenantCache[normalizedSlug]; exists {
|
|
return tItem, nil
|
|
}
|
|
if h.TenantService == nil {
|
|
return tenantCacheItem{}, errors.New("tenant service unavailable")
|
|
}
|
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), normalizedSlug)
|
|
if err != nil || tenant == nil {
|
|
return tenantCacheItem{}, errors.New("invalid tenantSlug: tenant not found")
|
|
}
|
|
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
|
}
|
|
|
|
resolveTenantByID := func(tenantID string) (tenantCacheItem, error) {
|
|
normalizedID := strings.TrimSpace(tenantID)
|
|
if normalizedID == "" {
|
|
return tenantCacheItem{}, errors.New("tenantId is required")
|
|
}
|
|
if tItem, exists := tenantCacheByID[normalizedID]; exists {
|
|
return tItem, nil
|
|
}
|
|
if h.TenantService == nil {
|
|
return tenantCacheItem{}, errors.New("tenant service unavailable")
|
|
}
|
|
tenant, err := h.TenantService.GetTenant(c.Context(), normalizedID)
|
|
if err != nil || tenant == nil {
|
|
return tenantCacheItem{}, errors.New("invalid tenantId: tenant not found")
|
|
}
|
|
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
|
}
|
|
|
|
resolveTenantByDomain := func(domainName string) (tenantCacheItem, bool) {
|
|
normalizedDomain := strings.ToLower(strings.TrimSpace(domainName))
|
|
if normalizedDomain == "" || h.TenantService == nil {
|
|
return tenantCacheItem{}, false
|
|
}
|
|
if tItem, exists := tenantCacheByDomain[normalizedDomain]; exists {
|
|
return tItem, true
|
|
}
|
|
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), normalizedDomain)
|
|
if err != nil || tenant == nil {
|
|
return tenantCacheItem{}, false
|
|
}
|
|
tItem := cacheTenantItem(buildTenantCacheItem(tenant))
|
|
tenantCacheByDomain[normalizedDomain] = tItem
|
|
return tItem, true
|
|
}
|
|
|
|
createPersonalTenantItem := func(email string) (tenantCacheItem, error) {
|
|
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
|
|
if err != nil {
|
|
return tenantCacheItem{}, err
|
|
}
|
|
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
|
}
|
|
|
|
for _, item := range req.Users {
|
|
email := strings.TrimSpace(item.Email)
|
|
name := strings.TrimSpace(item.Name)
|
|
tenantID := strings.TrimSpace(item.TenantID)
|
|
tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
|
|
dept := strings.TrimSpace(item.Department)
|
|
|
|
if email == "" || name == "" {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
|
|
continue
|
|
}
|
|
if tenantSlugErr != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
|
continue
|
|
}
|
|
|
|
var tItem tenantCacheItem
|
|
var err error
|
|
if tenantID != "" {
|
|
tItem, err = resolveTenantByID(tenantID)
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
|
continue
|
|
}
|
|
if tenantSlug != "" && !strings.EqualFold(tenantSlug, tItem.Slug) {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantId and tenantSlug do not match"})
|
|
continue
|
|
}
|
|
tenantSlug = tItem.Slug
|
|
} else if tenantSlug != "" {
|
|
tItem, err = resolveTenantBySlug(tenantSlug)
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
|
continue
|
|
}
|
|
tenantSlug = tItem.Slug
|
|
} else {
|
|
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
|
|
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
|
|
tItem = domainTenant
|
|
tenantSlug = domainTenant.Slug
|
|
break
|
|
}
|
|
}
|
|
if tenantSlug == "" {
|
|
tItem, err = createPersonalTenantItem(email)
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"})
|
|
continue
|
|
}
|
|
tenantSlug = tItem.Slug
|
|
}
|
|
}
|
|
|
|
// Role-based access check
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
|
continue
|
|
}
|
|
}
|
|
|
|
resolvedAppointments := make([]any, 0, len(item.AdditionalAppointments)+2)
|
|
if len(item.AdditionalAppointments) > 0 {
|
|
appointmentFailed := false
|
|
for _, rawAppointment := range item.AdditionalAppointments {
|
|
appointmentTenantSlug := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantSlug"]))
|
|
if appointmentTenantSlug == "" {
|
|
continue
|
|
}
|
|
|
|
appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)]
|
|
if !exists {
|
|
appointmentTenant, err = resolveTenantBySlug(appointmentTenantSlug)
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: strings.Replace(err.Error(), "tenantSlug", "additional tenantSlug", 1)})
|
|
appointmentFailed = true
|
|
break
|
|
}
|
|
}
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
|
appointmentFailed = true
|
|
break
|
|
}
|
|
|
|
appointment := make(map[string]any, len(rawAppointment)+3)
|
|
for key, value := range rawAppointment {
|
|
if key == "tenantSlug" || key == "tenantId" || key == "tenantName" {
|
|
continue
|
|
}
|
|
appointment[key] = value
|
|
}
|
|
appointment["tenantId"] = appointmentTenant.ID
|
|
appointment["tenantSlug"] = appointmentTenant.Slug
|
|
if name := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantName"])); name != "" {
|
|
appointment["tenantName"] = name
|
|
} else {
|
|
appointment["tenantName"] = appointmentTenant.Name
|
|
}
|
|
resolvedAppointments = append(resolvedAppointments, appointment)
|
|
}
|
|
if appointmentFailed {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Validation based on schema
|
|
if tItem.Schema != nil {
|
|
if err := h.validateMetadata(item.Metadata, tItem.Schema, true); err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "validation failed: " + err.Error()})
|
|
continue
|
|
}
|
|
}
|
|
|
|
if h.UserRepo != nil && !hanmacScopeLoaded {
|
|
hanmacScopeLoaded = true
|
|
var err error
|
|
hanmacScope, err = h.resolveHanmacEmailScope(c.Context())
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to resolve Hanmac family tenant scope"})
|
|
continue
|
|
}
|
|
if hanmacScope != nil {
|
|
hanmacLocalParts, err = h.loadHanmacLocalParts(c.Context(), hanmacScope)
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to validate Hanmac family email policy"})
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
userEmail := email
|
|
var emailEvaluation hanmacEmailEvaluation
|
|
if h.UserRepo != nil && hanmacScope != nil && hanmacScope.ContainsTenant(tItem.ID, tenantSlug) {
|
|
emailEvaluation = h.evaluateHanmacImportEmail(c.Context(), item, hanmacScope, hanmacLocalParts)
|
|
if emailEvaluation.Blocking {
|
|
results = append(results, bulkUserResult{
|
|
Email: emailEvaluation.Email,
|
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
|
Status: emailEvaluation.Status,
|
|
Warnings: emailEvaluation.Warnings,
|
|
Success: false,
|
|
Message: emailEvaluation.Message,
|
|
})
|
|
continue
|
|
}
|
|
userEmail = emailEvaluation.Email
|
|
if emailEvaluation.LocalPart != "" {
|
|
hanmacLocalParts[emailEvaluation.LocalPart] = true
|
|
}
|
|
} else {
|
|
if _, _, err := domain.SplitEmailDomain(email); err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"})
|
|
continue
|
|
}
|
|
if localPart, err := domain.ExtractNormalizedEmailLocalPart(email); err != nil || localPart == "" {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"})
|
|
continue
|
|
}
|
|
}
|
|
|
|
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, userEmail) {
|
|
domainTenant, ok := resolveTenantByDomain(domainName)
|
|
if !ok || bulkUserAssignmentContainsTenant(resolvedAppointments, tItem.ID, domainTenant.ID) {
|
|
continue
|
|
}
|
|
resolvedAppointments = append(resolvedAppointments, map[string]any{
|
|
"tenantId": domainTenant.ID,
|
|
"tenantSlug": domainTenant.Slug,
|
|
"tenantName": domainTenant.Name,
|
|
"assignmentSource": "email_domain",
|
|
"sourceDomain": strings.ToLower(strings.TrimSpace(domainName)),
|
|
})
|
|
}
|
|
if len(resolvedAppointments) > 0 {
|
|
if item.Metadata == nil {
|
|
item.Metadata = map[string]any{}
|
|
}
|
|
item.Metadata["additionalAppointments"] = resolvedAppointments
|
|
}
|
|
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
|
|
|
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
|
role := item.Role
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
|
|
attributes := map[string]interface{}{
|
|
"department": dept,
|
|
"grade": strings.TrimSpace(item.Grade),
|
|
"position": strings.TrimSpace(item.Position),
|
|
"jobTitle": strings.TrimSpace(item.JobTitle),
|
|
"affiliationType": "internal",
|
|
"tenant_id": tItem.ID,
|
|
"role": role,
|
|
}
|
|
|
|
// Sync all custom login IDs based on tenant schemas
|
|
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, item.Metadata, "")
|
|
|
|
// Merge metadata
|
|
for k, v := range item.Metadata {
|
|
if _, exists := attributes[k]; !exists {
|
|
attributes[k] = v
|
|
}
|
|
}
|
|
|
|
userPhone := normalizePhoneNumber(item.Phone)
|
|
|
|
// Validate all collected LoginIDs
|
|
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
|
|
valid := true
|
|
// Collect all emails
|
|
allEmails := []string{userEmail}
|
|
if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
|
|
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
|
for _, se := range secondaryEmails {
|
|
if seStr, ok := se.(string); ok {
|
|
allEmails = append(allEmails, seStr)
|
|
}
|
|
}
|
|
} else if secondaryEmails, ok := secondaryRaw.([]string); ok {
|
|
allEmails = append(allEmails, secondaryEmails...)
|
|
}
|
|
}
|
|
|
|
for _, lid := range collectedIDs {
|
|
if err := domain.ValidateLoginID(lid, allEmails, userPhone); err != nil {
|
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: "Invalid LoginID (" + lid + "): " + err.Error()})
|
|
valid = false
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
continue
|
|
}
|
|
}
|
|
|
|
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
|
Email: userEmail,
|
|
Name: item.Name,
|
|
PhoneNumber: userPhone,
|
|
Attributes: attributes,
|
|
}, password)
|
|
if err != nil {
|
|
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
|
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
|
|
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
|
|
if err != nil || identityID == "" {
|
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
|
|
continue
|
|
}
|
|
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
|
|
} else {
|
|
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// [CRITICAL FIX] Sync to local DB directly using current data
|
|
// Don't fetch from Kratos here as it might have propagation lag
|
|
if h.UserRepo != nil {
|
|
localUser := &domain.User{
|
|
ID: identityID,
|
|
Email: userEmail,
|
|
Name: name,
|
|
Phone: normalizePhoneNumber(item.Phone),
|
|
Role: role,
|
|
Status: "active",
|
|
Department: dept,
|
|
Grade: strings.TrimSpace(item.Grade),
|
|
AffiliationType: "internal",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if tItem.ID != "" {
|
|
localUser.TenantID = &tItem.ID
|
|
}
|
|
|
|
// Merge metadata
|
|
localUser.Metadata = make(domain.JSONMap)
|
|
for k, v := range item.Metadata {
|
|
localUser.Metadata[k] = v
|
|
}
|
|
|
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
|
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
|
|
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
|
}
|
|
if h.Worksmobile != nil {
|
|
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
|
|
slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Update User Login IDs in local DB
|
|
for i := range loginIDRecords {
|
|
loginIDRecords[i].UserID = localUser.ID
|
|
}
|
|
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
|
|
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
|
|
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
|
|
}
|
|
|
|
if h.KetoOutboxRepo != nil {
|
|
// 1. Sync Role based relationship
|
|
h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
|
|
|
|
// 2. Sync direct membership to the Tenant (for count)
|
|
if localUser.TenantID != nil && *localUser.TenantID != "" {
|
|
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: *localUser.TenantID,
|
|
Relation: "members",
|
|
Subject: "User:" + localUser.ID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
|
|
// 3. Sync membership to UserGroup if department matches
|
|
if dept != "" {
|
|
for _, g := range tItem.Groups {
|
|
if strings.EqualFold(strings.TrimSpace(g.Name), dept) {
|
|
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: g.ID,
|
|
Relation: "members",
|
|
Subject: "User:" + localUser.ID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
results = append(results, bulkUserResult{
|
|
Email: userEmail,
|
|
OriginalEmail: emailEvaluation.OriginalEmail,
|
|
SuggestedEmail: emailEvaluation.SuggestedEmail,
|
|
Status: emailEvaluation.Status,
|
|
Warnings: emailEvaluation.Warnings,
|
|
Success: true,
|
|
UserID: identityID,
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"results": results,
|
|
})
|
|
}
|
|
|
|
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|
search := strings.TrimSpace(c.Query("search"))
|
|
tenantSlug, err := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
var requesterRole string
|
|
var manageableSlugs []string
|
|
var profile *domain.UserProfileResponse
|
|
|
|
// [New] Manual profile resolution to support query-param role mocking
|
|
// This is needed because browsers cannot send custom headers for direct downloads
|
|
mockRole := c.Query("x-test-role")
|
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
|
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
|
|
|
|
if isDev && mockRole != "" {
|
|
slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole)
|
|
requesterRole = mockRole
|
|
// In dev mocking, we might not have a full profile, but we need to know the manageable tenants if it's a tenant_admin
|
|
if requesterRole == domain.RoleTenantAdmin {
|
|
// Try to get actual profile if possible to get manageableTenants
|
|
p, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if p != nil {
|
|
profile = p
|
|
}
|
|
}
|
|
} else {
|
|
// Use real profile from middleware
|
|
p, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if !ok || p == nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "invalid session (trace:export_auth)")
|
|
}
|
|
profile = p
|
|
requesterRole = profile.Role
|
|
}
|
|
|
|
// [New] Access Control: only admin roles can export
|
|
if requesterRole != domain.RoleSuperAdmin && requesterRole != domain.RoleTenantAdmin {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export")
|
|
}
|
|
|
|
if profile != nil && requesterRole == domain.RoleTenantAdmin {
|
|
for _, t := range profile.ManageableTenants {
|
|
manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug))
|
|
manageableSlugs = append(manageableSlugs, strings.ToLower(t.ID))
|
|
}
|
|
if profile.TenantID != nil {
|
|
manageableSlugs = append(manageableSlugs, strings.ToLower(*profile.TenantID))
|
|
}
|
|
}
|
|
|
|
// 1. Fetch Users using Repo for efficiency
|
|
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
|
}
|
|
|
|
// 2. Filter by manageable tenants if tenant_admin
|
|
var filtered []domain.User
|
|
if requesterRole == domain.RoleTenantAdmin {
|
|
slugMap := make(map[string]bool)
|
|
for _, s := range manageableSlugs {
|
|
slugMap[s] = true
|
|
}
|
|
for _, u := range users {
|
|
if slugMap[strings.ToLower(userTenantSlug(u))] || slugMap[strings.ToLower(userTenantID(u))] {
|
|
filtered = append(filtered, u)
|
|
}
|
|
}
|
|
} else {
|
|
filtered = users
|
|
}
|
|
|
|
// 3. Set CSV Headers
|
|
c.Set("Content-Type", "text/csv; charset=utf-8")
|
|
c.Set("Content-Disposition", "attachment; filename=users_export_"+time.Now().Format("20060102")+".csv")
|
|
|
|
// [New] Write UTF-8 BOM for Excel compatibility
|
|
_, _ = c.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
|
|
writer := csv.NewWriter(c)
|
|
defer writer.Flush()
|
|
|
|
// Header row
|
|
includeIDs := includeCSVIds(c)
|
|
header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
|
|
if includeIDs {
|
|
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
|
|
}
|
|
|
|
// Collect all possible metadata keys for dynamic columns
|
|
metaKeysMap := make(map[string]bool)
|
|
for _, u := range filtered {
|
|
for k := range u.Metadata {
|
|
if !includeIDs && csvMetadataKeyIsID(k) {
|
|
continue
|
|
}
|
|
metaKeysMap[k] = true
|
|
}
|
|
}
|
|
var metaKeys []string
|
|
for k := range metaKeysMap {
|
|
metaKeys = append(metaKeys, k)
|
|
header = append(header, "Meta:"+k)
|
|
}
|
|
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Data rows
|
|
for _, u := range filtered {
|
|
tenantID := ""
|
|
if u.TenantID != nil {
|
|
tenantID = *u.TenantID
|
|
}
|
|
row := []string{
|
|
u.Email,
|
|
u.Name,
|
|
u.Phone,
|
|
u.Status,
|
|
userTenantSlug(u),
|
|
u.Grade,
|
|
u.Position,
|
|
u.JobTitle,
|
|
u.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
if includeIDs {
|
|
row = []string{
|
|
u.ID,
|
|
u.Email,
|
|
u.Name,
|
|
u.Phone,
|
|
u.Status,
|
|
tenantID,
|
|
userTenantSlug(u),
|
|
u.Grade,
|
|
u.Position,
|
|
u.JobTitle,
|
|
u.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
}
|
|
// Append metadata values in order
|
|
for _, k := range metaKeys {
|
|
val := ""
|
|
if v, ok := u.Metadata[k]; ok {
|
|
val = fmt.Sprintf("%v", v)
|
|
}
|
|
row = append(row, val)
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func csvMetadataKeyIsID(key string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(key))
|
|
return normalized == "id" || normalized == "user_id" || normalized == "tenant_id" || normalized == "tenantid"
|
|
}
|
|
|
|
func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|
var req struct {
|
|
UserIDs []string `json:"userIds"`
|
|
Status *string `json:"status"`
|
|
Role *string `json:"role"`
|
|
TenantSlug *string `json:"tenantSlug"`
|
|
CompanyCode *string `json:"companyCode"`
|
|
Department *string `json:"department"`
|
|
Grade *string `json:"grade"`
|
|
Position *string `json:"position"`
|
|
JobTitle *string `json:"jobTitle"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
req.CompanyCode = tenantSlug
|
|
|
|
if len(req.UserIDs) == 0 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
|
|
}
|
|
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester == nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
|
}
|
|
if req.Role != nil {
|
|
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
|
}
|
|
role, ok := normalizeAssignableSystemRole(*req.Role)
|
|
if !ok {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
|
}
|
|
*req.Role = role
|
|
}
|
|
|
|
// Pre-fetch tenant cache if tenantSlug is being changed.
|
|
type tenantCacheItem struct {
|
|
ID string
|
|
Schema []interface{}
|
|
}
|
|
tenantCache := make(map[string]tenantCacheItem)
|
|
|
|
manageableSlugs := make(map[string]bool)
|
|
if requester.Role == domain.RoleTenantAdmin {
|
|
manageableSlugs = profileTenantAccessKeys(requester)
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(req.UserIDs))
|
|
|
|
for _, id := range req.UserIDs {
|
|
// [Safety] Cannot delete yourself
|
|
if id == requester.ID {
|
|
results = append(results, map[string]any{
|
|
"id": id,
|
|
"success": false,
|
|
"message": "cannot delete your own account for safety",
|
|
})
|
|
continue
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
|
|
continue
|
|
}
|
|
|
|
// Authorization check
|
|
if requester.Role == domain.RoleTenantAdmin {
|
|
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
|
|
continue
|
|
}
|
|
if req.CompanyCode != nil {
|
|
targetAllowed := manageableSlugs[strings.ToLower(*req.CompanyCode)]
|
|
if !targetAllowed && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
|
targetAllowed = manageableSlugs[strings.ToLower(tenant.ID)]
|
|
}
|
|
}
|
|
if !targetAllowed {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: target tenant not manageable"})
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prepare updates
|
|
traits := identity.Traits
|
|
if req.Role != nil {
|
|
traits["role"] = *req.Role
|
|
}
|
|
if req.CompanyCode != nil {
|
|
delete(traits, "companyCode")
|
|
delete(traits, "companyCodes")
|
|
|
|
// Resolve and update tenant_id in traits if changed
|
|
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
|
traits["tenant_id"] = tItem.ID
|
|
} else if h.TenantService != nil {
|
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
|
if err == nil && tenant != nil {
|
|
tItem.ID = tenant.ID
|
|
tenantCache[*req.CompanyCode] = tItem
|
|
traits["tenant_id"] = tenant.ID
|
|
}
|
|
}
|
|
}
|
|
if req.Department != nil {
|
|
traits["department"] = *req.Department
|
|
}
|
|
if req.Grade != nil {
|
|
traits["grade"] = *req.Grade
|
|
}
|
|
if req.Position != nil {
|
|
traits["position"] = *req.Position
|
|
}
|
|
if req.JobTitle != nil {
|
|
traits["jobTitle"] = *req.JobTitle
|
|
}
|
|
|
|
state := identity.State
|
|
if req.Status != nil {
|
|
state = normalizeKratosState(req.Status)
|
|
if state == "" {
|
|
state = identity.State
|
|
}
|
|
}
|
|
|
|
_, err = h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
|
continue
|
|
}
|
|
|
|
// Sync to local DB
|
|
if h.UserRepo != nil {
|
|
localUser := h.mapToLocalUser(*identity)
|
|
oldRole := roleFromTraits(identity.Traits)
|
|
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
|
|
|
|
if req.Role != nil {
|
|
localUser.Role = *req.Role
|
|
}
|
|
if req.Status != nil {
|
|
localUser.Status = normalizeStatus(*req.Status)
|
|
}
|
|
if req.Department != nil {
|
|
localUser.Department = *req.Department
|
|
}
|
|
if req.Position != nil {
|
|
localUser.Position = *req.Position
|
|
}
|
|
if req.JobTitle != nil {
|
|
localUser.JobTitle = *req.JobTitle
|
|
}
|
|
|
|
// Resolve TenantID if changing tenantSlug.
|
|
if req.CompanyCode != nil && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
|
localUser.TenantID = &tenant.ID
|
|
}
|
|
}
|
|
|
|
_ = h.UserRepo.Update(c.Context(), localUser)
|
|
if h.Worksmobile != nil {
|
|
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
|
|
slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// [Keto Sync]
|
|
if h.KetoOutboxRepo != nil {
|
|
h.syncKetoRole(c.Context(), localUser.ID,
|
|
localUser.Role, oldRole, oldTenantID, localUser.TenantID)
|
|
}
|
|
}
|
|
|
|
results = append(results, map[string]any{"id": id, "success": true})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"results": results})
|
|
}
|
|
|
|
func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
|
var req struct {
|
|
UserIDs []string `json:"userIds"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
if len(req.UserIDs) == 0 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
|
|
}
|
|
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester == nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
|
}
|
|
|
|
manageableSlugs := make(map[string]bool)
|
|
if requester.Role == domain.RoleTenantAdmin {
|
|
manageableSlugs = profileTenantAccessKeys(requester)
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(req.UserIDs))
|
|
|
|
for _, id := range req.UserIDs {
|
|
// [Safety] Cannot delete yourself
|
|
if id == requester.ID {
|
|
results = append(results, map[string]any{
|
|
"id": id,
|
|
"success": false,
|
|
"message": "cannot delete your own account for safety",
|
|
})
|
|
continue
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
|
|
continue
|
|
}
|
|
|
|
// Authorization check
|
|
if requester.Role == domain.RoleTenantAdmin {
|
|
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), id); err != nil {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
|
continue
|
|
}
|
|
|
|
err = h.KratosAdmin.DeleteIdentity(c.Context(), id)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
|
continue
|
|
}
|
|
if h.Worksmobile != nil {
|
|
localUser := h.mapToLocalUser(*identity)
|
|
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
|
|
slog.Warn("Failed to enqueue Worksmobile bulk user delete", "userID", id, "error", err)
|
|
}
|
|
}
|
|
|
|
// Local DB Sync
|
|
if h.UserRepo != nil {
|
|
_ = h.UserRepo.Delete(c.Context(), id)
|
|
}
|
|
|
|
results = append(results, map[string]any{"id": id, "success": true})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"results": results})
|
|
}
|
|
|
|
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
|
}
|
|
|
|
userID := strings.TrimSpace(c.Params("id"))
|
|
if userID == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
if identity == nil {
|
|
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
|
}
|
|
|
|
// Capture current local state for transition comparison
|
|
var oldRole string
|
|
var oldTenantID string
|
|
var oldDepartment string
|
|
if h.UserRepo != nil {
|
|
if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
|
|
oldRole = local.Role
|
|
oldDepartment = local.Department
|
|
if local.TenantID != nil {
|
|
oldTenantID = *local.TenantID
|
|
}
|
|
}
|
|
}
|
|
|
|
// [New] Check access scope
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
|
allowed := map[string]bool{}
|
|
if requester.TenantID != nil {
|
|
allowed[strings.ToLower(*requester.TenantID)] = true
|
|
}
|
|
for _, tenant := range requester.ManageableTenants {
|
|
allowed[strings.ToLower(tenant.ID)] = true
|
|
}
|
|
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
|
|
}
|
|
}
|
|
|
|
var req struct {
|
|
LoginID *string `json:"loginId"`
|
|
Password *string `json:"password"`
|
|
Name *string `json:"name"`
|
|
Phone *string `json:"phone"`
|
|
Role *string `json:"role"`
|
|
Status *string `json:"status"`
|
|
TenantSlug *string `json:"tenantSlug"`
|
|
CompanyCode *string `json:"companyCode"`
|
|
IsAddTenant bool `json:"isAddTenant"`
|
|
IsRemoveTenant bool `json:"isRemoveTenant"`
|
|
Department *string `json:"department"`
|
|
Grade *string `json:"grade"`
|
|
Position *string `json:"position"`
|
|
JobTitle *string `json:"jobTitle"`
|
|
PrimaryTenantID string `json:"primaryTenantId"`
|
|
PrimaryTenantName string `json:"primaryTenantName"`
|
|
PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"`
|
|
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
req.CompanyCode = tenantSlug
|
|
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
|
if req.Role != nil {
|
|
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
|
}
|
|
role, ok := normalizeAssignableSystemRole(*req.Role)
|
|
if !ok {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
|
}
|
|
*req.Role = role
|
|
}
|
|
|
|
// Tenant admins can only move users within tenants they can manage.
|
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
|
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil {
|
|
targetSlug := strings.TrimSpace(*req.CompanyCode)
|
|
targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
|
|
if !targetAllowed && h.TenantService != nil && targetSlug != "" {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), targetSlug); err == nil && tenant != nil {
|
|
targetAllowed = profileCanAccessTenant(requester, tenant.ID, tenant.Slug)
|
|
}
|
|
}
|
|
if !targetAllowed {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
|
}
|
|
}
|
|
}
|
|
|
|
// [Validation] Based on Tenant Schema (Multi-tenant aware)
|
|
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
|
|
|
|
// If metadata is namespaced (key is tenant ID), validate each namespace
|
|
// If it's flat, validate using schemaCompCode
|
|
for key, val := range req.Metadata {
|
|
// Basic check if key looks like a UUID (tenant ID)
|
|
if len(key) >= 32 {
|
|
// Namespaced metadata
|
|
if h.TenantService != nil {
|
|
tenant, err := h.TenantService.GetTenant(c.Context(), key)
|
|
if err == nil && tenant != nil {
|
|
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
if subMeta, ok := val.(map[string]any); ok {
|
|
if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
schemaTenantSlug := ""
|
|
if req.CompanyCode != nil {
|
|
schemaTenantSlug = *req.CompanyCode
|
|
}
|
|
var tenant *domain.Tenant
|
|
if schemaTenantSlug != "" && h.TenantService != nil {
|
|
tenant, _ = h.TenantService.GetTenantBySlug(c.Context(), schemaTenantSlug)
|
|
} else if tenantID := extractTraitString(identity.Traits, "tenant_id"); tenantID != "" && h.TenantService != nil {
|
|
tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID)
|
|
}
|
|
if tenant != nil {
|
|
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
break // Only need to check flat metadata once
|
|
}
|
|
}
|
|
|
|
traits := identity.Traits
|
|
if traits == nil {
|
|
traits = map[string]interface{}{}
|
|
}
|
|
delete(traits, "hanmacFamily")
|
|
delete(traits, "userType")
|
|
|
|
if req.Name != nil {
|
|
traits["name"] = strings.TrimSpace(*req.Name)
|
|
}
|
|
if req.Phone != nil {
|
|
phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
|
if phone == "" {
|
|
delete(traits, "phone_number")
|
|
} else {
|
|
traits["phone_number"] = phone
|
|
}
|
|
}
|
|
if req.CompanyCode != nil {
|
|
code := strings.TrimSpace(*req.CompanyCode)
|
|
|
|
if req.IsRemoveTenant {
|
|
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
|
|
go func(removedSlug string) {
|
|
bgCtx := context.Background()
|
|
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: t.ID,
|
|
Relation: "members",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
})
|
|
}
|
|
}(code)
|
|
}
|
|
if h.TenantService != nil && code != "" {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
|
currentTenantID := extractTraitString(traits, "tenant_id")
|
|
if currentTenantID == tenant.ID {
|
|
traits["tenant_id"] = ""
|
|
}
|
|
}
|
|
}
|
|
} else if !req.IsAddTenant {
|
|
if h.TenantService != nil && code != "" {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
|
traits["tenant_id"] = tenant.ID
|
|
} else {
|
|
traits["tenant_id"] = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
delete(traits, "companyCode")
|
|
delete(traits, "companyCodes")
|
|
|
|
if req.Department != nil {
|
|
traits["department"] = strings.TrimSpace(*req.Department)
|
|
}
|
|
if req.Grade != nil {
|
|
traits["grade"] = strings.TrimSpace(*req.Grade)
|
|
}
|
|
if req.Position != nil {
|
|
traits["position"] = strings.TrimSpace(*req.Position)
|
|
}
|
|
if req.JobTitle != nil {
|
|
traits["jobTitle"] = strings.TrimSpace(*req.JobTitle)
|
|
}
|
|
if req.Role != nil {
|
|
role := domain.NormalizeRole(*req.Role)
|
|
if role == "" {
|
|
role = domain.RoleUser
|
|
}
|
|
traits["role"] = role
|
|
}
|
|
|
|
// [Namespaced Metadata Sync]
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"position": true, "jobTitle": true,
|
|
"affiliationType": true, "role": true, "tenant_id": true,
|
|
"custom_login_ids": true, "id": true,
|
|
}
|
|
|
|
// For namespaced metadata, we don't delete everything, we merge.
|
|
for k, v := range req.Metadata {
|
|
if !coreTraits[k] {
|
|
// Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices
|
|
if incomingMap, ok := v.(map[string]any); ok {
|
|
if existingMap, ok := traits[k].(map[string]interface{}); ok {
|
|
for subK, subV := range incomingMap {
|
|
existingMap[subK] = subV
|
|
}
|
|
traits[k] = existingMap
|
|
} else {
|
|
traits[k] = incomingMap // New namespace
|
|
}
|
|
} else {
|
|
traits[k] = v // Fallback for flat metadata
|
|
}
|
|
}
|
|
}
|
|
|
|
// [LoginID Sync based on Tenant Settings]
|
|
// Perform sync AFTER metadata merge to ensure traits contains current values
|
|
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, userID)
|
|
|
|
// Validate all collected LoginIDs
|
|
userEmail := extractTraitString(traits, "email")
|
|
userPhone := extractTraitString(traits, "phone_number")
|
|
|
|
allEmails := []string{userEmail}
|
|
if secondaryRaw, exists := traits["sub_email"]; exists {
|
|
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
|
|
for _, se := range secondaryEmails {
|
|
if seStr, ok := se.(string); ok {
|
|
allEmails = append(allEmails, seStr)
|
|
}
|
|
}
|
|
} else if secondaryEmails, ok := secondaryRaw.([]string); ok {
|
|
allEmails = append(allEmails, secondaryEmails...)
|
|
}
|
|
}
|
|
|
|
if collectedIDs, ok := traits["custom_login_ids"].([]string); ok {
|
|
for _, lid := range collectedIDs {
|
|
if err := domain.ValidateLoginID(lid, allEmails, userPhone); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
state := normalizeKratosState(req.Status)
|
|
|
|
slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state)
|
|
|
|
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
|
if err != nil {
|
|
// [Exception Handling] Check for 409 Conflict (Duplicate Identifier)
|
|
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "exists already") {
|
|
return errorJSON(c, fiber.StatusConflict, "이미 다른 사용자가 사용 중인 식별자(이메일/전화번호/사번 등)가 포함되어 있습니다.")
|
|
}
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
|
|
if h.UserRepo != nil {
|
|
updatedLocalUser := h.mapToLocalUser(*updated)
|
|
if req.Status != nil {
|
|
updatedLocalUser.Status = normalizeStatus(*req.Status)
|
|
}
|
|
|
|
ctx := context.Background() // Use request context if appropriate, but sync must finish
|
|
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
|
|
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
|
|
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
|
}
|
|
if h.Worksmobile != nil {
|
|
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
|
|
slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Update User Login IDs in local DB
|
|
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
|
|
slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
|
|
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
|
|
}
|
|
|
|
// [Keto Sync] asynchronously as it's less critical for immediate UI count
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
h.syncKetoRole(bgCtx, updatedLocalUser.ID,
|
|
roleFromTraits(updated.Traits), oldRole, oldTenantID, updatedLocalUser.TenantID)
|
|
|
|
// Try to automatically sync UserGroup membership based on Department
|
|
if h.UserGroupRepo != nil && h.KetoOutboxRepo != nil {
|
|
// 1. Remove from old group if department or tenant changed
|
|
if oldTenantID != "" && oldDepartment != "" && (oldTenantID != extractTraitString(updated.Traits, "tenant_id") || oldDepartment != updatedLocalUser.Department) {
|
|
if oldGroups, err := h.UserGroupRepo.ListByTenantID(bgCtx, oldTenantID); err == nil {
|
|
for _, g := range oldGroups {
|
|
if strings.EqualFold(g.Name, oldDepartment) {
|
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: g.ID,
|
|
Relation: "members",
|
|
Subject: "User:" + updatedLocalUser.ID,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Add to new group
|
|
if updatedLocalUser.TenantID != nil && updatedLocalUser.Department != "" {
|
|
if groups, err := h.UserGroupRepo.ListByTenantID(bgCtx, *updatedLocalUser.TenantID); err == nil {
|
|
for _, g := range groups {
|
|
if strings.EqualFold(g.Name, updatedLocalUser.Department) {
|
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: g.ID,
|
|
Relation: "members",
|
|
Subject: "User:" + updatedLocalUser.ID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if h.KetoOutboxRepo != nil && h.TenantService != nil {
|
|
if updatedLocalUser.TenantID != nil {
|
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: *updatedLocalUser.TenantID,
|
|
Relation: "members",
|
|
Subject: "User:" + updatedLocalUser.ID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if req.Password != nil && *req.Password != "" {
|
|
if h.OryProvider == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
|
|
}
|
|
// [New] Resolve a representative LoginID for the password update call
|
|
updateLoginID := resolvePasswordLoginID(updated.Traits)
|
|
if err := h.OryProvider.UpdateUserPassword(updateLoginID, *req.Password, nil); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
}
|
|
|
|
return c.JSON(h.mapIdentitySummary(c.Context(), *updated))
|
|
}
|
|
|
|
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
|
}
|
|
|
|
userID := strings.TrimSpace(c.Params("id"))
|
|
if userID == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
|
}
|
|
|
|
// [New] Check access scope before deletion
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
|
|
// [Safety] Cannot delete yourself
|
|
if requester != nil && userID == requester.ID {
|
|
return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety")
|
|
}
|
|
|
|
var identity *service.KratosIdentity
|
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin || h.Worksmobile != nil {
|
|
found, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err == nil {
|
|
identity = found
|
|
}
|
|
}
|
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
|
if identity != nil {
|
|
allowed := map[string]bool{}
|
|
if requester.TenantID != nil {
|
|
allowed[strings.ToLower(*requester.TenantID)] = true
|
|
}
|
|
for _, tenant := range requester.ManageableTenants {
|
|
allowed[strings.ToLower(tenant.ID)] = true
|
|
}
|
|
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), userID); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
if h.Worksmobile != nil && identity != nil {
|
|
localUser := h.mapToLocalUser(*identity)
|
|
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
|
|
slog.Warn("[UserHandler] Failed to enqueue Worksmobile user delete", "userID", userID, "error", err)
|
|
}
|
|
}
|
|
|
|
// [Keto] Cleanup relations via Outbox
|
|
if h.KetoOutboxRepo != nil {
|
|
ctx := context.Background()
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "System",
|
|
Object: "global",
|
|
Relation: "super_admins",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
})
|
|
// Additional cleanup for tenants could be added here if we keep track of user's current tenants
|
|
}
|
|
|
|
if h.UserRepo != nil {
|
|
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
|
|
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
|
|
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
|
|
}
|
|
}
|
|
|
|
return c.SendStatus(fiber.StatusNoContent)
|
|
}
|
|
|
|
func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, userID string) error {
|
|
if h.KetoService == nil || h.KetoOutboxRepo == nil {
|
|
return nil
|
|
}
|
|
|
|
subject := "User:" + strings.TrimSpace(userID)
|
|
tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list relying party relations for user %s: %w", userID, err)
|
|
}
|
|
|
|
if len(tuples) == 0 {
|
|
slog.Info("[UserHandler] No relying party relations found for deleted user cleanup", "userID", userID)
|
|
return nil
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(tuples))
|
|
for _, tuple := range tuples {
|
|
if strings.TrimSpace(tuple.Object) == "" || strings.TrimSpace(tuple.Relation) == "" {
|
|
continue
|
|
}
|
|
|
|
relSubject := strings.TrimSpace(tuple.SubjectID)
|
|
if relSubject == "" {
|
|
relSubject = subject
|
|
}
|
|
|
|
key := tuple.Namespace + "\x00" + tuple.Object + "\x00" + tuple.Relation + "\x00" + relSubject
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
|
|
namespace := strings.TrimSpace(tuple.Namespace)
|
|
if namespace == "" {
|
|
namespace = "RelyingParty"
|
|
}
|
|
|
|
if err := h.KetoService.DeleteRelation(ctx, namespace, tuple.Object, tuple.Relation, relSubject); err != nil {
|
|
slog.Warn("[UserHandler] Failed to delete RelyingParty relation immediately", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
|
|
}
|
|
|
|
if err := h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: namespace,
|
|
Object: tuple.Object,
|
|
Relation: tuple.Relation,
|
|
Subject: relSubject,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
}); err != nil {
|
|
slog.Warn("[UserHandler] Failed to enqueue RelyingParty relation cleanup", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) {
|
|
var tuples []service.RelationTuple
|
|
var err error
|
|
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tuples) > 0 {
|
|
return tuples, nil
|
|
}
|
|
if attempt == 2 {
|
|
break
|
|
}
|
|
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
|
}
|
|
|
|
fallbackEntries, err := h.KetoOutboxRepo.ListCurrentBySubject(ctx, "RelyingParty", subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(fallbackEntries) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
tuples = make([]service.RelationTuple, 0, len(fallbackEntries))
|
|
for _, entry := range fallbackEntries {
|
|
tuples = append(tuples, service.RelationTuple{
|
|
Namespace: entry.Namespace,
|
|
Object: entry.Object,
|
|
Relation: entry.Relation,
|
|
SubjectID: entry.Subject,
|
|
})
|
|
}
|
|
|
|
slog.Warn("[UserHandler] Falling back to keto_outbox history for deleted user RP cleanup", "subject", subject, "tuples", len(tuples))
|
|
return tuples, nil
|
|
}
|
|
|
|
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
|
traits := identity.Traits
|
|
role := roleFromTraits(traits)
|
|
|
|
tenantID := extractTraitString(traits, "tenant_id")
|
|
tenantSlug := ""
|
|
var tenantSummary *domain.Tenant
|
|
if tenantID != "" && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenant(ctx, tenantID); err == nil && tenant != nil {
|
|
tenantSlug = tenant.Slug
|
|
tenantSummary = tenant
|
|
}
|
|
}
|
|
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "tenantID", tenantID, "tenantSlug", tenantSlug)
|
|
|
|
var customLoginIDs []string
|
|
if raw, ok := traits["custom_login_ids"]; ok {
|
|
if ids, ok := raw.([]interface{}); ok {
|
|
for _, id := range ids {
|
|
if s, ok := id.(string); ok {
|
|
customLoginIDs = append(customLoginIDs, s)
|
|
}
|
|
}
|
|
} else if ids, ok := raw.([]string); ok {
|
|
customLoginIDs = ids
|
|
}
|
|
}
|
|
|
|
summary := userSummary{
|
|
ID: identity.ID,
|
|
Email: extractTraitString(traits, "email"),
|
|
LoginID: resolvePasswordLoginID(traits),
|
|
CustomLoginIDs: customLoginIDs,
|
|
Name: extractTraitString(traits, "name"),
|
|
Phone: extractTraitString(traits, "phone_number"),
|
|
Role: role,
|
|
Status: normalizeStatus(identity.State),
|
|
TenantSlug: tenantSlug,
|
|
CompanyCode: tenantSlug,
|
|
Department: extractTraitString(traits, "department"),
|
|
Grade: gradeFromTraits(traits),
|
|
Position: extractTraitString(traits, "position"),
|
|
JobTitle: extractTraitString(traits, "jobTitle"),
|
|
Metadata: make(domain.JSONMap),
|
|
CreatedAt: formatTime(identity.CreatedAt),
|
|
UpdatedAt: formatTime(identity.UpdatedAt),
|
|
}
|
|
|
|
// [New] Fetch all joined tenants (for Multi-tenancy support)
|
|
if h.TenantService != nil {
|
|
if joined, err := h.TenantService.ListJoinedTenants(ctx, identity.ID); err == nil {
|
|
summary.JoinedTenants = joined
|
|
}
|
|
}
|
|
|
|
// [Namespaced Metadata] Handling
|
|
// We assume core traits are at the top level.
|
|
// For other keys, if they are UUIDs (tenant IDs), we treat them as namespaced metadata.
|
|
// Otherwise, we put them in a "legacy" or "flat" bucket if needed, but for now let's keep them in summary.Metadata
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
|
|
"position": true, "jobTitle": true,
|
|
"affiliationType": true, "role": true, "tenant_id": true,
|
|
"custom_login_ids": true, "id": true,
|
|
}
|
|
|
|
for k, v := range traits {
|
|
if coreTraits[k] {
|
|
continue
|
|
}
|
|
// If the key is a tenant ID (uuid-like), it's namespaced metadata
|
|
// If not, it's flat metadata (for backward compatibility)
|
|
summary.Metadata[k] = v
|
|
}
|
|
|
|
summary.Tenant = tenantSummary
|
|
|
|
return summary
|
|
}
|
|
|
|
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
|
return normalizePhoneNumber(phone)
|
|
}
|
|
|
|
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
|
traits := identity.Traits
|
|
role := roleFromTraits(traits)
|
|
|
|
user := &domain.User{
|
|
ID: identity.ID,
|
|
Email: extractTraitString(traits, "email"),
|
|
Name: extractTraitString(traits, "name"),
|
|
Phone: extractTraitString(traits, "phone_number"),
|
|
Role: role,
|
|
Status: normalizeStatus(identity.State),
|
|
Department: extractTraitString(traits, "department"),
|
|
Grade: gradeFromTraits(traits),
|
|
Position: extractTraitString(traits, "position"),
|
|
JobTitle: extractTraitString(traits, "jobTitle"),
|
|
AffiliationType: extractTraitString(traits, "affiliationType"),
|
|
CreatedAt: identity.CreatedAt,
|
|
UpdatedAt: identity.UpdatedAt,
|
|
}
|
|
|
|
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
|
|
tID := extractTraitString(traits, "tenant_id")
|
|
if tID != "" {
|
|
user.TenantID = &tID
|
|
}
|
|
|
|
// Metadata handling (exclude core fields)
|
|
user.Metadata = make(domain.JSONMap)
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "companyCodes": true, "department": true,
|
|
"position": true, "jobTitle": true,
|
|
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
|
|
"custom_login_ids": true, "id": true,
|
|
}
|
|
for k, v := range traits {
|
|
if !coreTraits[k] {
|
|
user.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
|
|
newRole = domain.NormalizeRole(newRole)
|
|
oldRole = domain.NormalizeRole(oldRole)
|
|
|
|
newTID := ""
|
|
if newTenantID != nil {
|
|
newTID = *newTenantID
|
|
}
|
|
|
|
if h.KetoOutboxRepo == nil {
|
|
return
|
|
}
|
|
|
|
if oldRole == newRole && oldTenantID == newTID {
|
|
return // Nothing changed
|
|
}
|
|
|
|
// 1. Handle Role Changes
|
|
if oldRole == domain.RoleSuperAdmin {
|
|
// Only remove super_admin if the role actually changed (tenant change doesn't matter for global roles)
|
|
if oldRole != newRole {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "System",
|
|
Object: "global",
|
|
Relation: "super_admins",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
})
|
|
}
|
|
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: oldTenantID,
|
|
Relation: "admins",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
})
|
|
}
|
|
|
|
// Add new roles
|
|
if newRole == domain.RoleSuperAdmin {
|
|
if oldRole != newRole {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "System",
|
|
Object: "global",
|
|
Relation: "super_admins",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
} else if newRole == domain.RoleTenantAdmin && newTID != "" {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: newTID,
|
|
Relation: "admins",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
|
|
// 2. Handle Tenant Membership (for count)
|
|
if oldTenantID != newTID {
|
|
// Remove from old tenant
|
|
if oldTenantID != "" {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: oldTenantID,
|
|
Relation: "members",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionDelete,
|
|
})
|
|
}
|
|
// Add to new tenant
|
|
if newTID != "" {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: newTID,
|
|
Relation: "members",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractTraitString(traits map[string]interface{}, key string) string {
|
|
if traits == nil {
|
|
return ""
|
|
}
|
|
if raw, ok := traits[key]; ok {
|
|
if value, ok := raw.(string); ok {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractTraitStringArray(traits map[string]interface{}, key string) []string {
|
|
if traits == nil {
|
|
return nil
|
|
}
|
|
if raw, ok := traits[key]; ok {
|
|
if slice, ok := raw.([]interface{}); ok {
|
|
var result []string
|
|
for _, v := range slice {
|
|
if s, ok := v.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
if slice, ok := raw.([]string); ok {
|
|
return slice
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
|
// First check custom_login_ids (array)
|
|
if raw, ok := traits["custom_login_ids"]; ok {
|
|
if ids, ok := raw.([]interface{}); ok && len(ids) > 0 {
|
|
if first, ok := ids[0].(string); ok {
|
|
return first
|
|
}
|
|
} else if ids, ok := raw.([]string); ok && len(ids) > 0 {
|
|
return ids[0]
|
|
}
|
|
}
|
|
// Fallback to legacy id (if still exists in some old identities)
|
|
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
|
|
return loginID
|
|
}
|
|
if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
|
|
return email
|
|
}
|
|
return strings.TrimSpace(extractTraitString(traits, "phone_number"))
|
|
}
|
|
|
|
// syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas
|
|
// and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB.
|
|
func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]interface{}, metadata map[string]any, userID string) []domain.UserLoginID {
|
|
if tenantService == nil {
|
|
return nil
|
|
}
|
|
|
|
var loginIDRecords []domain.UserLoginID
|
|
var allCustomIDs []string
|
|
idSet := make(map[string]bool)
|
|
|
|
normalizeCustomLoginIDsTrait(traits)
|
|
|
|
// Collect tenant IDs to check schemas for
|
|
tenantIDsToCheck := make(map[string]bool)
|
|
primaryTenantID := extractTraitString(traits, "tenant_id")
|
|
for k, v := range metadata {
|
|
if isTenantMetadataNamespace(k, v, primaryTenantID) {
|
|
tenantIDsToCheck[k] = true
|
|
}
|
|
}
|
|
// Also check primary tenant if available
|
|
if tid := primaryTenantID; tid != "" && (len(metadata) > 0 || isMetadataMap(traits[tid])) {
|
|
tenantIDsToCheck[tid] = true
|
|
}
|
|
if len(tenantIDsToCheck) == 0 {
|
|
return nil
|
|
}
|
|
|
|
for tid := range tenantIDsToCheck {
|
|
tenant, err := tenantService.GetTenant(ctx, tid)
|
|
if err != nil || tenant == nil {
|
|
continue
|
|
}
|
|
|
|
schema, ok := tenant.Config["userSchema"].([]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for _, fieldRaw := range schema {
|
|
field, ok := fieldRaw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
isLoginId, _ := field["isLoginId"].(bool)
|
|
if !isLoginId {
|
|
continue
|
|
}
|
|
|
|
fieldKey, ok := field["key"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Try to find value in namespaced metadata first, then flat metadata, then existing traits
|
|
var val string
|
|
if namespaced, ok := metadata[tid].(map[string]any); ok {
|
|
val, _ = namespaced[fieldKey].(string)
|
|
} else if namespaced, ok := metadata[tid].(map[string]interface{}); ok {
|
|
val, _ = namespaced[fieldKey].(string)
|
|
}
|
|
|
|
if val == "" {
|
|
val, _ = metadata[fieldKey].(string)
|
|
}
|
|
|
|
if val == "" {
|
|
// Check existing trait (namespaced)
|
|
if namespaced, ok := traits[tid].(map[string]interface{}); ok {
|
|
val, _ = namespaced[fieldKey].(string)
|
|
} else if namespaced, ok := traits[tid].(map[string]any); ok {
|
|
val, _ = namespaced[fieldKey].(string)
|
|
}
|
|
}
|
|
|
|
if val == "" {
|
|
// Fallback: Check flat traits
|
|
val = extractTraitString(traits, fieldKey)
|
|
}
|
|
|
|
if val != "" {
|
|
if !idSet[val] {
|
|
idSet[val] = true
|
|
allCustomIDs = append(allCustomIDs, val)
|
|
}
|
|
loginIDRecords = append(loginIDRecords, domain.UserLoginID{
|
|
UserID: userID,
|
|
TenantID: tid,
|
|
FieldKey: fieldKey,
|
|
LoginID: val,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(allCustomIDs) > 0 {
|
|
traits["custom_login_ids"] = allCustomIDs
|
|
} else {
|
|
delete(traits, "custom_login_ids")
|
|
}
|
|
|
|
// Always remove legacy "id" trait to avoid confusion
|
|
delete(traits, "id")
|
|
|
|
return loginIDRecords
|
|
}
|
|
|
|
func isTenantMetadataNamespace(key string, value any, primaryTenantID string) bool {
|
|
return isTenantMetadataNamespaceKey(key, primaryTenantID) && isMetadataMap(value)
|
|
}
|
|
|
|
func isTenantMetadataNamespaceKey(key string, primaryTenantID string) bool {
|
|
if key == "" {
|
|
return false
|
|
}
|
|
if primaryTenantID != "" && key == primaryTenantID {
|
|
return true
|
|
}
|
|
if len(key) != 36 {
|
|
return false
|
|
}
|
|
for index, char := range key {
|
|
switch index {
|
|
case 8, 13, 18, 23:
|
|
if char != '-' {
|
|
return false
|
|
}
|
|
default:
|
|
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isMetadataMap(value any) bool {
|
|
if _, ok := value.(map[string]any); ok {
|
|
return true
|
|
}
|
|
if _, ok := value.(map[string]interface{}); ok {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
|
raw, exists := traits["custom_login_ids"]
|
|
if !exists {
|
|
return
|
|
}
|
|
switch values := raw.(type) {
|
|
case []string:
|
|
return
|
|
case []interface{}:
|
|
normalized := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
|
normalized = append(normalized, text)
|
|
}
|
|
}
|
|
if len(normalized) > 0 {
|
|
traits["custom_login_ids"] = normalized
|
|
}
|
|
}
|
|
}
|
|
|
|
func formatTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.Format(time.RFC3339)
|
|
}
|
|
|
|
func normalizeStatus(state string) string {
|
|
return domain.NormalizeUserStatus(state)
|
|
}
|
|
|
|
func normalizeKratosState(status *string) string {
|
|
if status == nil {
|
|
return ""
|
|
}
|
|
value := strings.ToLower(strings.TrimSpace(*status))
|
|
if value == "blocked" {
|
|
return "inactive"
|
|
}
|
|
if value == domain.UserStatusActive {
|
|
return domain.UserStatusActive
|
|
}
|
|
normalized := domain.NormalizeUserStatus(value)
|
|
if normalized == domain.UserStatusPreboarding ||
|
|
normalized == domain.UserStatusSuspended ||
|
|
normalized == domain.UserStatusTemporaryLeave ||
|
|
normalized == domain.UserStatusBaronGuest ||
|
|
normalized == domain.UserStatusExtendedLeave ||
|
|
normalized == domain.UserStatusArchived {
|
|
return "inactive"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizePhoneNumber(phone string) string {
|
|
normalized := strings.ReplaceAll(phone, "-", "")
|
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
|
if normalized == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(normalized, "010") {
|
|
return "+82" + normalized[1:]
|
|
}
|
|
if strings.HasPrefix(normalized, "82") {
|
|
return "+" + normalized
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []interface{}, checkRequired bool) error {
|
|
return h.validateMetadataWithAuth(metadata, schema, true, checkRequired)
|
|
}
|
|
|
|
func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []interface{}, isAdmin bool, checkRequired bool) error {
|
|
schemaMap := make(map[string]map[string]interface{})
|
|
for _, s := range schema {
|
|
if m, ok := s.(map[string]interface{}); ok {
|
|
if key, ok := m["key"].(string); ok {
|
|
schemaMap[key] = m
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Check required fields
|
|
if checkRequired {
|
|
for key, config := range schemaMap {
|
|
required, _ := config["required"].(bool)
|
|
val, exists := metadata[key]
|
|
if required && (!exists || val == nil || val == "") {
|
|
return errors.New("field " + key + " is required")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Check each field in metadata
|
|
for key, val := range metadata {
|
|
config, exists := schemaMap[key]
|
|
if !exists {
|
|
continue // Ignore fields not in schema or allow? Let's allow for now
|
|
}
|
|
|
|
// Admin Only check
|
|
adminOnly, _ := config["adminOnly"].(bool)
|
|
if adminOnly && !isAdmin {
|
|
return errors.New("field " + key + " is admin only")
|
|
}
|
|
|
|
// Type validation
|
|
if expectedType, ok := config["type"].(string); ok && expectedType != "" && val != nil && val != "" {
|
|
switch expectedType {
|
|
case "number":
|
|
var numVal float64
|
|
switch v := val.(type) {
|
|
case float64:
|
|
numVal = v
|
|
case int:
|
|
numVal = float64(v)
|
|
case string:
|
|
parsed, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return errors.New("field " + key + " must be a number")
|
|
}
|
|
numVal = parsed
|
|
default:
|
|
return errors.New("field " + key + " must be a number")
|
|
}
|
|
if float64(int(numVal)) != numVal {
|
|
return errors.New("field " + key + " must be an integer")
|
|
}
|
|
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
|
|
return errors.New("field " + key + " must be an unsigned integer")
|
|
}
|
|
case "float":
|
|
var numVal float64
|
|
switch v := val.(type) {
|
|
case float64:
|
|
numVal = v
|
|
case int:
|
|
numVal = float64(v)
|
|
case string:
|
|
parsed, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return errors.New("field " + key + " must be a float")
|
|
}
|
|
numVal = parsed
|
|
default:
|
|
return errors.New("field " + key + " must be a float")
|
|
}
|
|
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
|
|
return errors.New("field " + key + " must be an unsigned float")
|
|
}
|
|
case "boolean":
|
|
switch v := val.(type) {
|
|
case bool:
|
|
// ok
|
|
case string:
|
|
if v != "true" && v != "false" {
|
|
return errors.New("field " + key + " must be a boolean")
|
|
}
|
|
default:
|
|
return errors.New("field " + key + " must be a boolean")
|
|
}
|
|
case "date":
|
|
if strVal, ok := val.(string); ok {
|
|
if _, err := time.Parse("2006-01-02", strVal); err != nil {
|
|
return errors.New("field " + key + " must be a valid date (YYYY-MM-DD)")
|
|
}
|
|
} else {
|
|
return errors.New("field " + key + " must be a date string")
|
|
}
|
|
case "datetime":
|
|
if strVal, ok := val.(string); ok {
|
|
_, err1 := time.Parse(time.RFC3339, strVal)
|
|
_, err2 := time.Parse("2006-01-02T15:04", strVal)
|
|
_, err3 := time.Parse("2006-01-02T15:04:05", strVal)
|
|
if err1 != nil && err2 != nil && err3 != nil {
|
|
return errors.New("field " + key + " must be a valid datetime")
|
|
}
|
|
} else {
|
|
return errors.New("field " + key + " must be a datetime string")
|
|
}
|
|
case "text":
|
|
if _, ok := val.(string); !ok {
|
|
return errors.New("field " + key + " must be a string")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Regex validation
|
|
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
|
|
strVal := ""
|
|
switch v := val.(type) {
|
|
case string:
|
|
strVal = v
|
|
case float64:
|
|
strVal = fmt.Sprintf("%v", v)
|
|
case int:
|
|
strVal = fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
if strVal != "" {
|
|
matched, err := regexp.MatchString(regexStr, strVal)
|
|
if err != nil {
|
|
return errors.New("invalid regex pattern for field " + key)
|
|
}
|
|
if !matched {
|
|
return errors.New("field " + key + " does not match validation pattern")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *UserHandler) GetUserRpHistory(c *fiber.Ctx) error {
|
|
userId := c.Params("id")
|
|
if userId == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
|
}
|
|
|
|
if h.AuditRepo == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
|
}
|
|
|
|
logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), userId, []string{"consent.granted", "consent.revoked"}, 100)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history")
|
|
}
|
|
|
|
type rpHistoryItem struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientName string `json:"client_name"`
|
|
LastLoginAt string `json:"lastLoginAt"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
historyMap := make(map[string]*rpHistoryItem)
|
|
|
|
// Logs are DESC (newest first).
|
|
for _, log := range logs {
|
|
details, _ := utils.ParseAuditDetails(log.Details)
|
|
cid, _ := details["client_id"].(string)
|
|
if cid == "" {
|
|
continue
|
|
}
|
|
|
|
if _, exists := historyMap[cid]; !exists {
|
|
cname, _ := details["client_name"].(string)
|
|
if cname == "" {
|
|
cname = cid
|
|
}
|
|
|
|
historyMap[cid] = &rpHistoryItem{
|
|
ClientID: cid,
|
|
ClientName: cname,
|
|
LastLoginAt: log.Timestamp.Format(time.RFC3339),
|
|
Status: "active", // Default based on latest grant
|
|
}
|
|
if log.EventType == "consent.revoked" {
|
|
historyMap[cid].Status = "revoked"
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]*rpHistoryItem, 0, len(historyMap))
|
|
for _, item := range historyMap {
|
|
result = append(result, item)
|
|
}
|
|
|
|
return c.JSON(result)
|
|
}
|