forked from baron/baron-sso
1256 lines
37 KiB
Go
1256 lines
37 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/repository"
|
|
"baron-sso-backend/internal/service"
|
|
"baron-sso-backend/internal/utils"
|
|
"context"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"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
|
|
}
|
|
|
|
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler {
|
|
return &UserHandler{
|
|
KratosAdmin: kratosAdmin,
|
|
OryProvider: oryProvider,
|
|
TenantService: tenantService,
|
|
KetoService: ketoService,
|
|
KetoOutboxRepo: ketoOutboxRepo,
|
|
UserRepo: userRepo,
|
|
}
|
|
}
|
|
|
|
type userSummary struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
|
Department string `json:"department"`
|
|
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"`
|
|
}
|
|
|
|
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 = profile.Role
|
|
}
|
|
|
|
limit := c.QueryInt("limit", 50)
|
|
offset := c.QueryInt("offset", 0)
|
|
search := strings.TrimSpace(c.Query("search"))
|
|
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
|
|
|
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 {
|
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if profile != nil {
|
|
for _, t := range profile.ManageableTenants {
|
|
manageableSlugs[strings.ToLower(t.Slug)] = true
|
|
}
|
|
// Include primary tenant slug if not already there
|
|
if profile.CompanyCode != "" {
|
|
manageableSlugs[strings.ToLower(profile.CompanyCode)] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Try Kratos First
|
|
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"))
|
|
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
|
|
|
// Tenant Admin filtering
|
|
if requesterRole == domain.RoleTenantAdmin {
|
|
if !manageableSlugs[compCode] {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Dedicated companyCode filter
|
|
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
|
|
continue
|
|
}
|
|
|
|
// Search filtering (Keyword search in email, name, or companyCode)
|
|
if search != "" {
|
|
if !strings.Contains(email, searchLower) &&
|
|
!strings.Contains(name, searchLower) &&
|
|
!strings.Contains(strings.ToLower(compCode), searchLower) {
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, identity)
|
|
}
|
|
|
|
total := int64(len(filtered))
|
|
if offset > len(filtered) {
|
|
offset = len(filtered)
|
|
}
|
|
end := offset + limit
|
|
if end > len(filtered) {
|
|
end = len(filtered)
|
|
}
|
|
|
|
items := make([]userSummary, 0, end-offset)
|
|
for _, identity := range filtered[offset:end] {
|
|
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})
|
|
}
|
|
|
|
// 2. Fallback to Local DB if Kratos is down
|
|
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
|
|
|
// Fetch from UserRepo
|
|
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
|
|
}
|
|
|
|
items := make([]userSummary, 0, len(users))
|
|
for _, u := range users {
|
|
items = append(items, userSummary{
|
|
ID: u.ID,
|
|
Email: u.Email,
|
|
Name: u.Name,
|
|
Phone: u.Phone,
|
|
Role: u.Role,
|
|
Status: u.Status,
|
|
CompanyCode: u.CompanyCode,
|
|
Department: u.Department,
|
|
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
return c.JSON(userListResponse{
|
|
Items: items,
|
|
Total: total,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
}
|
|
|
|
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 {
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
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"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Department string `json:"department"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
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 := strings.TrimSpace(req.Role)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
|
|
attributes := map[string]interface{}{
|
|
"department": req.Department,
|
|
"affiliationType": "internal",
|
|
"companyCode": req.CompanyCode,
|
|
"grade": role,
|
|
}
|
|
|
|
// [Resolve TenantID before Kratos creation]
|
|
var tenantID string
|
|
if req.CompanyCode != "" && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
|
tenantID = tenant.ID
|
|
}
|
|
}
|
|
attributes["role"] = role
|
|
if tenantID != "" {
|
|
attributes["tenant_id"] = tenantID
|
|
}
|
|
|
|
// Merge custom metadata into attributes
|
|
for k, v := range req.Metadata {
|
|
// Don't overwrite core fields
|
|
if _, exists := attributes[k]; !exists {
|
|
attributes[k] = v
|
|
}
|
|
}
|
|
|
|
brokerUser := &domain.BrokerUser{
|
|
Email: email,
|
|
Name: name,
|
|
PhoneNumber: normalizePhoneNumber(req.Phone),
|
|
Attributes: attributes,
|
|
}
|
|
|
|
// [Validation] Based on Tenant Schema
|
|
if req.CompanyCode != "" && h.TenantService != nil {
|
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
|
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(), "already exists") {
|
|
return errorJSON(c, fiber.StatusConflict, "email already exists")
|
|
}
|
|
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
|
|
go func(u *domain.User, role string, tID *string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
// Use Update (upsert) instead of Create for robustness
|
|
if err := h.UserRepo.Update(ctx, u); err != nil {
|
|
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
|
|
return
|
|
}
|
|
|
|
// [Keto] Sync relations via Outbox
|
|
if h.KetoOutboxRepo != nil {
|
|
h.syncKetoRole(ctx, u.ID, role, "", "", tID)
|
|
}
|
|
}(localUser, role, localUser.TenantID)
|
|
}
|
|
|
|
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"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Department string `json:"department"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
|
|
type bulkUserResult struct {
|
|
Email string `json:"email"`
|
|
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))
|
|
|
|
// Pre-fetch tenant data to avoid redundant DB calls
|
|
type tenantCacheItem struct {
|
|
ID string
|
|
Schema []interface{}
|
|
}
|
|
tenantCache := make(map[string]tenantCacheItem)
|
|
|
|
for _, item := range req.Users {
|
|
email := strings.TrimSpace(item.Email)
|
|
name := strings.TrimSpace(item.Name)
|
|
compCode := strings.TrimSpace(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 compCode == "" {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "companyCode (tenant) is required"})
|
|
continue
|
|
}
|
|
|
|
// Role-based access check
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
if compCode != requester.CompanyCode {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Verify Tenant Existence and Resolve ID (with Cache)
|
|
var tItem tenantCacheItem
|
|
var exists bool
|
|
if tItem, exists = tenantCache[compCode]; !exists {
|
|
if h.TenantService != nil {
|
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode)
|
|
if err != nil || tenant == nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid companyCode: tenant not found"})
|
|
continue
|
|
}
|
|
tItem.ID = tenant.ID
|
|
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
tItem.Schema = s
|
|
}
|
|
tenantCache[compCode] = tItem
|
|
} else {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
|
|
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
|
|
}
|
|
}
|
|
|
|
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
|
role := item.Role
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
|
|
attributes := map[string]interface{}{
|
|
"department": dept,
|
|
"affiliationType": "internal",
|
|
"companyCode": compCode,
|
|
"tenant_id": tItem.ID,
|
|
"grade": role,
|
|
"role": role,
|
|
}
|
|
|
|
// Merge metadata
|
|
for k, v := range item.Metadata {
|
|
if _, exists := attributes[k]; !exists {
|
|
attributes[k] = v
|
|
}
|
|
}
|
|
|
|
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
|
Email: email,
|
|
Name: item.Name,
|
|
PhoneNumber: normalizePhoneNumber(item.Phone),
|
|
Attributes: attributes,
|
|
}, password)
|
|
|
|
if err != nil {
|
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
|
continue
|
|
}
|
|
|
|
// Sync to local DB
|
|
if h.UserRepo != nil {
|
|
identity, _ := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
|
if identity != nil {
|
|
localUser := h.mapToLocalUser(*identity)
|
|
_ = h.UserRepo.Update(context.Background(), localUser)
|
|
if h.KetoOutboxRepo != nil {
|
|
h.syncKetoRole(context.Background(), localUser.ID, role, "", "", localUser.TenantID)
|
|
}
|
|
}
|
|
}
|
|
|
|
results = append(results, bulkUserResult{Email: email, 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"))
|
|
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
|
|
|
var requesterRole string
|
|
var manageableSlugs []string
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
|
requesterRole = profile.Role
|
|
if requesterRole == domain.RoleTenantAdmin {
|
|
for _, t := range profile.ManageableTenants {
|
|
manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug))
|
|
}
|
|
if profile.CompanyCode != "" {
|
|
manageableSlugs = append(manageableSlugs, strings.ToLower(profile.CompanyCode))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Fetch Users using Repo for efficiency
|
|
users, _, err := h.UserRepo.List(c.Context(), 10000, 0, search, companyCode)
|
|
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(u.CompanyCode)] {
|
|
filtered = append(filtered, u)
|
|
}
|
|
}
|
|
} else {
|
|
filtered = users
|
|
}
|
|
|
|
// 3. Set CSV Headers
|
|
c.Set("Content-Type", "text/csv")
|
|
c.Set("Content-Disposition", "attachment; filename=users_export_"+time.Now().Format("20060102")+".csv")
|
|
|
|
writer := csv.NewWriter(c)
|
|
defer writer.Flush()
|
|
|
|
// Header row
|
|
header := []string{"ID", "Email", "Name", "Role", "Status", "Tenant", "Department", "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 {
|
|
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 {
|
|
row := []string{
|
|
u.ID,
|
|
u.Email,
|
|
u.Name,
|
|
u.Role,
|
|
u.Status,
|
|
u.CompanyCode,
|
|
u.Department,
|
|
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 (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|
var req struct {
|
|
UserIDs []string `json:"userIds"`
|
|
Status *string `json:"status"`
|
|
Role *string `json:"role"`
|
|
}
|
|
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")
|
|
}
|
|
|
|
// Build manageable slugs map if tenant_admin
|
|
manageableSlugs := make(map[string]bool)
|
|
if requester.Role == domain.RoleTenantAdmin {
|
|
for _, t := range requester.ManageableTenants {
|
|
manageableSlugs[strings.ToLower(t.Slug)] = true
|
|
}
|
|
if requester.CompanyCode != "" {
|
|
manageableSlugs[strings.ToLower(requester.CompanyCode)] = true
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(req.UserIDs))
|
|
|
|
for _, id := range req.UserIDs {
|
|
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 {
|
|
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
|
if !manageableSlugs[userComp] {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Prepare updates
|
|
traits := identity.Traits
|
|
if req.Role != nil {
|
|
traits["role"] = *req.Role
|
|
}
|
|
|
|
state := identity.State
|
|
if req.Status != nil {
|
|
if *req.Status == "active" {
|
|
state = "active"
|
|
} else {
|
|
state = "inactive"
|
|
}
|
|
}
|
|
|
|
_, 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)
|
|
if req.Role != nil {
|
|
localUser.Role = *req.Role
|
|
}
|
|
if req.Status != nil {
|
|
localUser.Status = *req.Status
|
|
}
|
|
_ = h.UserRepo.Update(c.Context(), localUser)
|
|
}
|
|
|
|
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 {
|
|
for _, t := range requester.ManageableTenants {
|
|
manageableSlugs[strings.ToLower(t.Slug)] = true
|
|
}
|
|
if requester.CompanyCode != "" {
|
|
manageableSlugs[strings.ToLower(requester.CompanyCode)] = true
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(req.UserIDs))
|
|
|
|
for _, id := range req.UserIDs {
|
|
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 {
|
|
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
|
if !manageableSlugs[userComp] {
|
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
|
|
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
|
|
}
|
|
|
|
// 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
|
|
if h.UserRepo != nil {
|
|
if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
|
|
oldRole = local.Role
|
|
if local.TenantID != nil {
|
|
oldTenantID = *local.TenantID
|
|
}
|
|
}
|
|
}
|
|
|
|
// [New] Check access scope
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
|
|
}
|
|
}
|
|
|
|
var req struct {
|
|
Password *string `json:"password"`
|
|
Name *string `json:"name"`
|
|
Phone *string `json:"phone"`
|
|
Role *string `json:"role"`
|
|
Status *string `json:"status"`
|
|
CompanyCode *string `json:"companyCode"`
|
|
Department *string `json:"department"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
// [New] Tenant Admin restriction: Cannot change companyCode
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
|
}
|
|
}
|
|
|
|
// [Validation] Based on Tenant Schema
|
|
schemaCompCode := extractTraitString(identity.Traits, "companyCode")
|
|
if req.CompanyCode != nil {
|
|
schemaCompCode = *req.CompanyCode
|
|
}
|
|
|
|
if schemaCompCode != "" && h.TenantService != nil {
|
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode)
|
|
if err == nil && tenant != nil {
|
|
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
|
|
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
traits := identity.Traits
|
|
if traits == nil {
|
|
traits = map[string]interface{}{}
|
|
}
|
|
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)
|
|
traits["companyCode"] = code
|
|
// Resolve TenantID for Kratos Trait
|
|
if h.TenantService != nil && code != "" {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
|
traits["tenant_id"] = tenant.ID
|
|
}
|
|
}
|
|
}
|
|
if req.Department != nil {
|
|
traits["department"] = strings.TrimSpace(*req.Department)
|
|
}
|
|
if req.Role != nil {
|
|
role := strings.TrimSpace(*req.Role)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
traits["grade"] = role
|
|
traits["role"] = role
|
|
}
|
|
|
|
// [Refined] Metadata synchronization: replace non-core traits with new Metadata
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"affiliationType": true, "role": true, "tenant_id": true,
|
|
}
|
|
|
|
// 1. Remove existing non-core traits to handle deletions
|
|
for k := range traits {
|
|
if !coreTraits[k] {
|
|
delete(traits, k)
|
|
}
|
|
}
|
|
|
|
// 2. Add new metadata fields
|
|
for k, v := range req.Metadata {
|
|
if !coreTraits[k] {
|
|
traits[k] = v
|
|
}
|
|
}
|
|
|
|
state := normalizeKratosState(req.Status)
|
|
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
|
if err != nil {
|
|
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)
|
|
|
|
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)
|
|
}
|
|
|
|
// [Keto Sync] asynchronously as it's less critical for immediate UI count
|
|
go h.syncKetoRole(context.Background(), updatedLocalUser.ID,
|
|
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
|
|
}
|
|
|
|
if req.Password != nil && *req.Password != "" {
|
|
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); 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)
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err == nil && identity != nil {
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
// [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
|
|
}
|
|
|
|
return c.SendStatus(fiber.StatusNoContent)
|
|
}
|
|
|
|
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
|
traits := identity.Traits
|
|
role := extractTraitString(traits, "grade")
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
|
|
compCode := extractTraitString(traits, "companyCode")
|
|
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
|
|
summary := userSummary{
|
|
ID: identity.ID,
|
|
Email: extractTraitString(traits, "email"),
|
|
Name: extractTraitString(traits, "name"),
|
|
Phone: extractTraitString(traits, "phone_number"),
|
|
Role: role,
|
|
Status: normalizeStatus(identity.State),
|
|
CompanyCode: compCode,
|
|
Department: extractTraitString(traits, "department"),
|
|
Metadata: make(domain.JSONMap),
|
|
CreatedAt: formatTime(identity.CreatedAt),
|
|
UpdatedAt: formatTime(identity.UpdatedAt),
|
|
}
|
|
|
|
// Filter out core traits and put everything else in Metadata
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"affiliationType": true,
|
|
}
|
|
for k, v := range traits {
|
|
if !coreTraits[k] {
|
|
summary.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
if compCode != "" && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
|
summary.Tenant = tenant
|
|
}
|
|
}
|
|
|
|
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 := extractTraitString(traits, "grade")
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
compCode := extractTraitString(traits, "companyCode")
|
|
|
|
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),
|
|
CompanyCode: compCode,
|
|
Department: extractTraitString(traits, "department"),
|
|
AffiliationType: extractTraitString(traits, "affiliationType"),
|
|
CreatedAt: identity.CreatedAt,
|
|
UpdatedAt: identity.UpdatedAt,
|
|
}
|
|
|
|
if compCode != "" && h.TenantService != nil {
|
|
// Use a background context or a timeout-limited context for tenant lookup
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
|
user.TenantID = &tenant.ID
|
|
}
|
|
}
|
|
|
|
// Metadata
|
|
user.Metadata = make(domain.JSONMap)
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"affiliationType": true, "role": true, "tenant_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) {
|
|
// Remove old roles
|
|
if oldRole == domain.RoleSuperAdmin {
|
|
_ = 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 {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "System",
|
|
Object: "global",
|
|
Relation: "super_admins",
|
|
Subject: "User:" + userID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: *newTenantID,
|
|
Relation: "admins",
|
|
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 formatTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.Format(time.RFC3339)
|
|
}
|
|
|
|
func normalizeStatus(state string) string {
|
|
state = strings.ToLower(strings.TrimSpace(state))
|
|
if state == "inactive" || state == "blocked" || state == "active" {
|
|
return state
|
|
}
|
|
if state == "" {
|
|
return "active"
|
|
}
|
|
return state
|
|
}
|
|
|
|
func normalizeKratosState(status *string) string {
|
|
if status == nil {
|
|
return ""
|
|
}
|
|
value := strings.ToLower(strings.TrimSpace(*status))
|
|
if value == "blocked" {
|
|
return "inactive"
|
|
}
|
|
if value == "active" || value == "inactive" {
|
|
return value
|
|
}
|
|
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")
|
|
}
|
|
|
|
// 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
|
|
}
|