diff --git a/.env.sample b/.env.sample index 5233fbac..505020cb 100644 --- a/.env.sample +++ b/.env.sample @@ -35,6 +35,9 @@ CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기 +# Redis Cache Configuration +PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간 + # Descope Project ID (Required for Auth) DESCOPE_PROJECT_ID=P2t...your_descope_project_id DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a38acc4b..43ea6901 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3748,10 +3748,30 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe var err error token := h.getBearerToken(c) + cookie := c.Get("Cookie") + cacheKey := "" + + // 1. Try Redis Cache + if h.RedisService != nil { + if token != "" { + cacheKey = "cache:profile:token:" + token + } + // Cookie based caching skipped for simplicity/safety + + if cacheKey != "" { + cached, _ := h.RedisService.Get(cacheKey) + if cached != "" { + if json.Unmarshal([]byte(cached), &profile) == nil { + return profile, nil + } + } + } + } + + // 2. Fetch from Kratos (SoT) if token != "" { profile, err = h.getKratosProfile(token) } else { - cookie := c.Get("Cookie") if cookie != "" { profile, err = h.getKratosProfileWithCookie(cookie) } @@ -3761,26 +3781,40 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe return nil, errors.New("invalid session (trace:resolve_profile)") } - // [New] Enrich with Local DB (Roles, TenantID, etc.) - if h.UserRepo != nil { - localUser, err := h.UserRepo.FindByID(c.Context(), profile.ID) - if err == nil && localUser != nil { - profile.Role = localUser.Role - profile.TenantID = localUser.TenantID - profile.RelyingPartyID = localUser.RelyingPartyID - if profile.Tenant == nil && localUser.Tenant != nil { - profile.Tenant = localUser.Tenant - } - } else { - // 로컬 DB에 없으면 기본 권한 부여 - profile.Role = domain.RoleUser - } + // 3. Post-Process (Defaults & Metadata Enrichment) + // Default Role if missing (migration safety) + if profile.Role == "" { + profile.Role = domain.RoleUser } - // 로컬 DB에 Tenant 정보가 없더라도 companyCode(slug)가 있으면 조회 시도 + // Fetch Tenant Metadata if missing + // Case A: Have TenantID from Kratos -> Fetch by ID + if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { + if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil { + profile.Tenant = tenant + } + } + // Case B: Have CompanyCode but no TenantID -> Fetch by Slug if profile.Tenant == nil && profile.CompanyCode != "" { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil { profile.Tenant = tenant + if profile.TenantID == nil || *profile.TenantID == "" { + profile.TenantID = &tenant.ID + } + } + } + + // 4. Save to Redis Cache (Short TTL) + if h.RedisService != nil && cacheKey != "" { + if data, err := json.Marshal(profile); err == nil { + ttlStr := os.Getenv("PROFILE_CACHE_TTL") + ttl := 30 * time.Minute // Default TTL + if ttlStr != "" { + if parsed, err := time.ParseDuration(ttlStr); err == nil { + ttl = parsed + } + } + _ = h.RedisService.Set(cacheKey, string(data), ttl) } } @@ -4809,6 +4843,8 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile dept, _ := traits["department"].(string) affType, _ := traits["affiliationType"].(string) compCode, _ := traits["companyCode"].(string) + role, _ := traits["role"].(string) + tenantID, _ := traits["tenant_id"].(string) profile := &domain.UserProfileResponse{ ID: identityID, @@ -4818,13 +4854,18 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile Department: dept, AffiliationType: affType, CompanyCode: compCode, + Role: role, Metadata: make(map[string]any), } + if tenantID != "" { + profile.TenantID = &tenantID + } + coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, - "affiliationType": true, + "affiliationType": true, "role": true, "tenant_id": true, } for k, v := range traits { if !coreTraits[k] { @@ -4847,6 +4888,8 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro dept, _ := traits["department"].(string) affType, _ := traits["affiliationType"].(string) compCode, _ := traits["companyCode"].(string) + role, _ := traits["role"].(string) + tenantID, _ := traits["tenant_id"].(string) profile := &domain.UserProfileResponse{ ID: identityID, @@ -4856,13 +4899,18 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro Department: dept, AffiliationType: affType, CompanyCode: compCode, + Role: role, Metadata: make(map[string]any), } + if tenantID != "" { + profile.TenantID = &tenantID + } + coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, - "affiliationType": true, + "affiliationType": true, "role": true, "tenant_id": true, } for k, v := range traits { if !coreTraits[k] { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index b67b7358..c316ad5e 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -252,6 +252,18 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { "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 @@ -288,11 +300,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { Status: "active", Metadata: req.Metadata, } - - if req.CompanyCode != "" && h.TenantService != nil { - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil { - localUser.TenantID = &tenant.ID - } + if tenantID != "" { + localUser.TenantID = &tenantID } if h.UserRepo != nil { @@ -392,7 +401,14 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone)) } if req.CompanyCode != nil { - traits["companyCode"] = strings.TrimSpace(*req.CompanyCode) + 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) @@ -403,13 +419,14 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { 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, + "affiliationType": true, "role": true, "tenant_id": true, } // 1. Remove existing non-core traits to handle deletions diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index a5615a8f..c7cee875 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -17,6 +17,7 @@ type TenantService interface { RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) + GetTenant(ctx context.Context, id string) (*domain.Tenant, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 } @@ -34,6 +35,10 @@ func (s *tenantService) SetKetoService(keto KetoService) { s.keto = keto } +func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { + return s.repo.FindByID(ctx, id) +} + func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { diff --git a/docker/ory/kratos/identity.schema.json b/docker/ory/kratos/identity.schema.json index 91ed0358..c872f2f7 100644 --- a/docker/ory/kratos/identity.schema.json +++ b/docker/ory/kratos/identity.schema.json @@ -62,6 +62,14 @@ "type": "string", "title": "Company Code" }, + "role": { + "type": "string", + "title": "Role" + }, + "tenant_id": { + "type": "string", + "title": "Tenant ID" + }, "displayname": { "type": "string", "title": "Display Name"