1
0
forked from baron/baron-sso

feat(user): support fixed UUID registration and enhance bulk import results

- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
2026-06-01 15:34:08 +09:00
parent 4a1e89e421
commit 31d107ff2e
85 changed files with 2104 additions and 1149 deletions

View File

@@ -16,11 +16,13 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"slices"
"sort"
"strconv"
"strings"
@@ -601,7 +603,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
email := c.Query("email")
if email == "" {
// No email provided, return empty list (Security policy)
return c.JSON([]interface{}{})
return c.JSON([]any{})
}
// 1. Verify Verification Status in Redis
@@ -615,7 +617,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
// 2. Extract domain from verified email
parts := strings.Split(email, "@")
if len(parts) != 2 {
return c.JSON([]interface{}{})
return c.JSON([]any{})
}
domainName := parts[1]
@@ -623,7 +625,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
if !isInternal {
// If not an affiliate email, do not show any tenants
return c.JSON([]interface{}{})
return c.JSON([]any{})
}
// 3. List and Filter Tenants
@@ -785,7 +787,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
// IDP에 전달할 BrokerUser 스키마 구성
attributes := map[string]interface{}{
attributes := map[string]any{
"department": req.Department,
"affiliationType": req.AffiliationType,
"grade": "",
@@ -854,9 +856,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
// Merge metadata
localUser.Metadata = make(domain.JSONMap)
for k, v := range req.Metadata {
localUser.Metadata[k] = v
}
maps.Copy(localUser.Metadata, req.Metadata)
if h.UserRepo != nil {
go func(u *domain.User, ids []domain.UserLoginID) {
@@ -915,7 +915,7 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
}
func firstForwardedValue(raw string) string {
for _, part := range strings.Split(raw, ",") {
for part := range strings.SplitSeq(raw, ",") {
value := strings.TrimSpace(part)
if value != "" {
return value
@@ -925,8 +925,8 @@ func firstForwardedValue(raw string) string {
}
func forwardedDirective(raw, key string) string {
for _, group := range strings.Split(raw, ",") {
for _, directive := range strings.Split(group, ";") {
for group := range strings.SplitSeq(raw, ",") {
for directive := range strings.SplitSeq(group, ";") {
pair := strings.SplitN(strings.TrimSpace(directive), "=", 2)
if len(pair) != 2 {
continue
@@ -1075,9 +1075,9 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
res["loginIdField"] = loginIdField
// Find label in userSchema
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
if schema, ok := tenant.Config["userSchema"].([]any); ok {
for _, field := range schema {
if f, ok := field.(map[string]interface{}); ok {
if f, ok := field.(map[string]any); ok {
if f["key"] == loginIdField {
res["loginIdLabel"] = f["label"]
break
@@ -1215,9 +1215,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
if includeTenantDetails {
// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
if namespaced, ok := traits[tenantID].(map[string]any); ok {
for k, v := range namespaced {
claims[k] = v
}
maps.Copy(claims, namespaced)
}
}
}
@@ -1570,7 +1568,7 @@ func tenantClaimAncestorSummaries(ancestors []*domain.Tenant) []map[string]any {
return items
}
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any {
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]any) map[string]any {
if baseClaims == nil {
baseClaims = map[string]any{}
}
@@ -1670,7 +1668,7 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
claims["rp_profiles"] = append(existing, profile)
return claims
}
if existing, ok := claims["rp_profiles"].([]interface{}); ok {
if existing, ok := claims["rp_profiles"].([]any); ok {
claims["rp_profiles"] = append(existing, profile)
return claims
}
@@ -1678,7 +1676,7 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
return claims
}
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []string {
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]any) []string {
if metadata == nil {
return nil
}
@@ -1687,12 +1685,12 @@ func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []
return nil
}
var items []interface{}
var items []any
switch schema := rawSchema.(type) {
case []interface{}:
case []any:
items = schema
case []map[string]interface{}:
items = make([]interface{}, 0, len(schema))
case []map[string]any:
items = make([]any, 0, len(schema))
for _, item := range schema {
items = append(items, item)
}
@@ -1703,7 +1701,7 @@ func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []
keys := make([]string, 0, len(items))
seen := make(map[string]struct{})
for _, item := range items {
field, ok := item.(map[string]interface{})
field, ok := item.(map[string]any)
if !ok {
if typed, typedOK := item.(map[string]any); typedOK {
field = typed
@@ -4275,11 +4273,11 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
}
type kratosCourierRequest struct {
Recipient string `json:"recipient"`
TemplateType string `json:"template_type"`
TemplateData map[string]interface{} `json:"template_data"`
Subject string `json:"subject"`
Body string `json:"body"`
Recipient string `json:"recipient"`
TemplateType string `json:"template_type"`
TemplateData map[string]any `json:"template_data"`
Subject string `json:"subject"`
Body string `json:"body"`
}
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
@@ -4604,7 +4602,7 @@ func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
func (h *AuthHandler) generateShortCode(code string) string {
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for i := 0; i < 10; i++ {
for range 10 {
b := make([]byte, 2)
if _, err := crand.Read(b); err != nil {
break
@@ -4646,7 +4644,7 @@ func firstNonEmpty(values ...string) string {
return ""
}
func extractFirstString(data map[string]interface{}, keys ...string) string {
func extractFirstString(data map[string]any, keys ...string) string {
if data == nil {
return ""
}
@@ -5229,10 +5227,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
}
candidates := buildLoginCandidates(profile)
fetchLimit := limit * 10
if fetchLimit < limit {
fetchLimit = limit
}
fetchLimit := max(limit*10, limit)
if fetchLimit > 500 {
fetchLimit = 500
}
@@ -5805,9 +5800,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
}
for _, log := range auditLogs {
var details struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
Scopes interface{} `json:"scopes"`
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
Scopes any `json:"scopes"`
}
// 로그 Details 파싱
if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
@@ -5824,7 +5819,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
// 스코프 추출 (consent.granted인 경우)
scopes := []string{}
if sList, ok := details.Scopes.([]interface{}); ok {
if sList, ok := details.Scopes.([]any); ok {
for _, s := range sList {
if str, ok := s.(string); ok {
scopes = append(scopes, str)
@@ -5927,7 +5922,7 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
}
if h.AuditRepo != nil {
detailsMap := map[string]interface{}{
detailsMap := map[string]any{
"client_id": clientID,
}
detailsBytes, _ := json.Marshal(detailsMap)
@@ -6085,7 +6080,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
}
if h.AuditRepo != nil {
detailsMap := map[string]interface{}{
detailsMap := map[string]any{
"client_id": consentRequest.Client.ClientID,
"scopes": consentRequest.RequestedScope,
"client_name": consentRequest.Client.ClientName,
@@ -6135,12 +6130,12 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
// structured_scopes 파싱 및 scope_details 생성
if metadata := consentRequest.Client.Metadata; metadata != nil {
if rawScopes, ok := metadata["structured_scopes"]; ok {
scopeDetails := make(map[string]map[string]interface{})
scopeDetails := make(map[string]map[string]any)
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
// 안전하게 처리
rawBytes, _ := json.Marshal(rawScopes)
var scopesList []map[string]interface{}
var scopesList []map[string]any
if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
for _, item := range scopesList {
name, _ := item["name"].(string)
@@ -6150,7 +6145,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
desc, _ := item["description"].(string)
mandatory, _ := item["mandatory"].(bool)
scopeDetails[name] = map[string]interface{}{
scopeDetails[name] = map[string]any{
"description": desc,
"mandatory": mandatory,
}
@@ -6280,7 +6275,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
}
if h.AuditRepo != nil {
detailsMap := map[string]interface{}{
detailsMap := map[string]any{
"client_id": consentRequest.Client.ClientID,
"scopes": consentRequest.RequestedScope,
"client_name": consentRequest.Client.ClientName,
@@ -6618,7 +6613,7 @@ func appendLoginIDsFromValues(subjects []string, email string, phone string) []s
return subjects
}
func appendLoginIDsFromTraits(subjects []string, traits map[string]interface{}) []string {
func appendLoginIDsFromTraits(subjects []string, traits map[string]any) []string {
if traits == nil {
return subjects
}
@@ -7235,7 +7230,7 @@ func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
return loginID, nil
}
func pickLoginIDFromTraits(traits map[string]interface{}) string {
func pickLoginIDFromTraits(traits map[string]any) string {
if traits == nil {
return ""
}
@@ -7409,12 +7404,12 @@ func extractLoginIDFromClaims(claims map[string]any) string {
return ""
}
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, string, error) {
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]any, string, error) {
identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
return identityID, traits, usedID, err
}
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, string, error) {
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]any, string, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
@@ -7442,8 +7437,8 @@ func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string,
Identifier string `json:"identifier"`
} `json:"authentication_methods"`
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
ID string `json:"id"`
Traits map[string]any `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -7501,7 +7496,7 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
kratosAdminURL = "http://kratos:4434"
}
payload := map[string]interface{}{
payload := map[string]any{
"identity_id": identityID,
}
body, _ := json.Marshal(payload)
@@ -7534,12 +7529,12 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
return parsed.SessionToken, nil
}
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, string, error) {
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]any, string, error) {
identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
return identityID, traits, usedID, err
}
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, string, error) {
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]any, string, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
@@ -7567,8 +7562,8 @@ func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (stri
Identifier string `json:"identifier"`
} `json:"authentication_methods"`
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
ID string `json:"id"`
Traits map[string]any `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -7616,13 +7611,13 @@ func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error
return result.ID, nil
}
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]any) error {
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
if kratosAdminURL == "" {
kratosAdminURL = "http://kratos:4434"
}
payload := map[string]interface{}{
payload := map[string]any{
"schema_id": "default",
"traits": traits,
}
@@ -7681,7 +7676,7 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
}
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse {
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]any) *domain.UserProfileResponse {
email, _ := traits["email"].(string)
name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string)
@@ -7723,7 +7718,7 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile
}
func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]interface{}, existing *domain.User) *domain.User {
func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]any, existing *domain.User) *domain.User {
now := time.Now()
localUser := &domain.User{
ID: identityID,
@@ -7810,7 +7805,7 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
return localUser
}
func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]interface{}) error {
func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]any) error {
if h == nil || h.UserRepo == nil {
return nil
}
@@ -7880,7 +7875,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
var (
identityID string
traits map[string]interface{}
traits map[string]any
err error
)
if token != "" {
@@ -7929,10 +7924,8 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
// [Fix] Support merging namespaced metadata maps
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
}
if existingMap, ok := traits[k].(map[string]any); ok {
maps.Copy(existingMap, incomingMap)
traits[k] = existingMap
} else {
traits[k] = incomingMap
@@ -8129,7 +8122,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"success": true})
}
func hydraClientStatus(metadata map[string]interface{}) string {
func hydraClientStatus(metadata map[string]any) string {
if metadata == nil {
return "active"
}
@@ -8142,7 +8135,7 @@ func hydraClientStatus(metadata map[string]interface{}) string {
return "active"
}
func extractHydraClientLogo(metadata map[string]interface{}) string {
func extractHydraClientLogo(metadata map[string]any) string {
if metadata == nil {
return ""
}
@@ -8198,7 +8191,7 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
return ""
}
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]interface{}) bool {
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]any) bool {
if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
return true
}
@@ -8210,7 +8203,7 @@ func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]inte
}
}
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string {
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]any) string {
clientID = strings.TrimSpace(clientID)
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
if clientID == "orgfront" {
@@ -8253,7 +8246,7 @@ func ensureOrgfrontAutoLoginURL(rawURL string) string {
return parsed.String()
}
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string {
func resolveLinkedRPInitURL(clientID string, metadata map[string]any) string {
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
return ""
}
@@ -8548,7 +8541,7 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
ts := log.Timestamp
item.LastApprovedAt = &ts
if scopesRaw, ok := details["scopes"].([]interface{}); ok {
if scopesRaw, ok := details["scopes"].([]any); ok {
scopes := make([]string, 0, len(scopesRaw))
for _, s := range scopesRaw {
if str, ok := s.(string); ok {
@@ -8811,7 +8804,7 @@ func extractStringLikeValue(raw any) string {
}
}
func extractHydraSessionID(ext map[string]interface{}) string {
func extractHydraSessionID(ext map[string]any) string {
if len(ext) == 0 {
return ""
}
@@ -8886,13 +8879,7 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri
}
existing := bindings[sessionID]
seen := false
for _, candidate := range existing {
if candidate == clientID {
seen = true
break
}
}
seen := slices.Contains(existing, clientID)
if !seen {
bindings[sessionID] = append(existing, clientID)
}