diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b5dd9fc9..301cab51 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -245,9 +245,7 @@ func main() { // 2. Initialize Handlers tenantRepo := repository.NewTenantRepository(db) - tenantGroupRepo := repository.NewTenantGroupRepository(db) tenantService := service.NewTenantService(tenantRepo) - tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService) tenantService.SetKetoService(ketoService) // Keto 주입 userRepo := repository.NewUserRepository(db) // relyingPartyRepo removed as SSOT is now Hydra+Keto @@ -256,16 +254,14 @@ func main() { secretRepo := repository.NewClientSecretRepository(db) consentRepo := repository.NewClientConsentRepository(db) - kratosAdminService := service.NewKratosAdminService() - oryAdminProvider := service.NewOryProvider() - auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) - adminHandler := handler.NewAdminHandler(ketoService) - devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) - tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) - tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService) - relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) + adminHandler := handler.NewAdminHandler() + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo) + tenantHandler := handler.NewTenantHandler(db, tenantService) + relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService) + kratosAdminService := service.NewKratosAdminService() + oryAdminProvider := service.NewOryProvider() userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) apiKeyHandler := handler.NewApiKeyHandler(db) @@ -489,7 +485,6 @@ func main() { // Auth Proxy Routes auth := api.Group("/auth") - auth.All("/oidc/*", authHandler.ProxyOidc) auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) @@ -554,14 +549,13 @@ func main() { KetoService: ketoService, }) requireAdmin := middleware.RequireRole(middleware.RBACConfig{ - AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin}, + AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, AuthHandler: authHandler, KetoService: ketoService, }) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) - admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission) // Tenant Management (Super Admin Only) admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants) @@ -570,27 +564,9 @@ func main() { admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant) admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant) admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant) - admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins) - admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin) - admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin) - - // Tenant Group Management (Super Admin Only) - admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups) - admin.Post("/tenant-groups", requireSuperAdmin, tenantGroupHandler.CreateGroup) - admin.Get("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.GetGroup) - admin.Put("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.UpdateGroup) - admin.Delete("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.DeleteGroup) - admin.Post("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.AddTenantToGroup) - admin.Delete("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.RemoveTenantFromGroup) - admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins) - admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin) - admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin) // Relying Party Management (Global List) admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll) - admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners) - admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner) - admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner) // Relying Party Management (Tenant Context) admin.Post("/tenants/:tenantId/relying-parties", @@ -631,24 +607,14 @@ func main() { admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정) - dev := api.Group("/dev", requireAdmin) + dev := api.Group("/dev") dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) - dev.Get("/clients/:id", - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"), - devHandler.GetClient) - dev.Put("/clients/:id", - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), - devHandler.UpdateClient) - dev.Post("/clients/:id/secret/rotate", - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), - devHandler.RotateClientSecret) - dev.Patch("/clients/:id/status", - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), - devHandler.UpdateClientStatus) - dev.Delete("/clients/:id", - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), - devHandler.DeleteClient) + dev.Get("/clients/:id", devHandler.GetClient) + dev.Put("/clients/:id", devHandler.UpdateClient) + dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret) + dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) + dev.Delete("/clients/:id", devHandler.DeleteClient) dev.Get("/consents", devHandler.ListConsents) dev.Delete("/consents", devHandler.RevokeConsents) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index f36b841b..fcd15586 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -31,7 +31,6 @@ func migrateSchemas(db *gorm.DB) error { slog.Info("[Bootstrap] Migrating database schemas...") // Add all domain models here return db.AutoMigrate( - &domain.TenantGroup{}, &domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, diff --git a/backend/internal/bootstrap/keto_sync.go b/backend/internal/bootstrap/keto_sync.go index 80e7badf..676451ce 100644 --- a/backend/internal/bootstrap/keto_sync.go +++ b/backend/internal/bootstrap/keto_sync.go @@ -25,18 +25,6 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error { if t.ParentID != nil { _ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID) } - if t.TenantGroupID != nil { - _ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent_group", *t.TenantGroupID) - } - } - - // 1.1 Sync Tenant Groups (Group Admins) - var groups []domain.TenantGroup - if err := db.Find(&groups).Error; err == nil { - slog.Info("Syncing tenant groups to Keto", "count", len(groups)) - for range groups { - // 그룹 관리자 개념 확정 후 관계 생성 로직 추가 예정 - } } // 2. Sync All Users diff --git a/backend/internal/bootstrap/kratos_seed.go b/backend/internal/bootstrap/kratos_seed.go index b1205cb5..7ddb4a4c 100644 --- a/backend/internal/bootstrap/kratos_seed.go +++ b/backend/internal/bootstrap/kratos_seed.go @@ -34,7 +34,6 @@ func SeedAdminIdentity(idp domain.IdentityProvider) error { "affiliationType": "internal", "companyCode": "", "grade": "admin", - "role": domain.RoleSuperAdmin, }, } diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index b81181fe..3cfd6d98 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -68,19 +68,18 @@ type SignupRequest struct { // User Profile Models type UserProfileResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` // 추가 - Department string `json:"department"` - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode,omitempty"` - TenantID *string `json:"tenantId,omitempty"` // 추가 - RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 - Metadata map[string]any `json:"metadata,omitempty"` - Tenant *Tenant `json:"tenant,omitempty"` - ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` // 추가 + Department string `json:"department"` + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode,omitempty"` + TenantID *string `json:"tenantId,omitempty"` // 추가 + RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 + Metadata map[string]any `json:"metadata,omitempty"` + Tenant *Tenant `json:"tenant,omitempty"` } type UpdateUserRequest struct { diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index 1cf9d532..2383c840 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -17,47 +17,23 @@ const ( // Tenant represents a tenant model stored in PostgreSQL. type Tenant struct { - ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` - ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID - TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"` - TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"` - Name string `gorm:"not null" json:"name"` - Slug string `gorm:"uniqueIndex;not null" json:"slug"` - Description string `json:"description"` - Status string `gorm:"default:'pending'" json:"status"` - Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"` - Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID + Name string `gorm:"not null" json:"name"` + Slug string `gorm:"uniqueIndex;not null" json:"slug"` + Description string `json:"description"` + Status string `gorm:"default:'pending'" json:"status"` + Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"` + Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } func (t *Tenant) IsActive() bool { return t.Status == TenantStatusActive } -// GetMergedConfig merges the group-level config with tenant-level config. -// Tenant config takes precedence. -func (t *Tenant) GetMergedConfig() JSONMap { - merged := make(JSONMap) - - // 1. Apply Group Config (Base) - if t.TenantGroup != nil && t.TenantGroup.Config != nil { - for k, v := range t.TenantGroup.Config { - merged[k] = v - } - } - - // 2. Apply Tenant Config (Overrides) - if t.Config != nil { - for k, v := range t.Config { - merged[k] = v - } - } - - return merged -} - // BeforeCreate hook to generate UUID if not present. func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) { if t.ID == "" { diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 45b63c75..04c2805e 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,51 +1,22 @@ package handler import ( - "baron-sso-backend/internal/service" "runtime" "time" "github.com/gofiber/fiber/v2" ) -type AdminHandler struct { - Keto service.KetoService -} +type AdminHandler struct{} -func NewAdminHandler(keto service.KetoService) *AdminHandler { - return &AdminHandler{Keto: keto} +func NewAdminHandler() *AdminHandler { + return &AdminHandler{} } func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } -func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error { - namespace := c.Query("namespace") - object := c.Query("object") - relation := c.Query("relation") - subject := c.Query("subject") - - if namespace == "" || object == "" || relation == "" || subject == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"}) - } - - allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.JSON(fiber.Map{ - "allowed": allowed, - "query": fiber.Map{ - "namespace": namespace, - "object": object, - "relation": relation, - "subject": subject, - }, - }) -} - // GetSystemStats returns runtime statistics for monitoring func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { var m runtime.MemStats diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 66ea4ebb..e5127d03 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -125,11 +125,10 @@ func GenerateSecureAlnumToken(length int) string { func GenerateUserCode() string { const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" - // [Fixed] 요청하신 포맷 (영문 2자리 + 숫자 6자리, 하이픈 없음)으로 변경 - return fmt.Sprintf("%c%c%06d", + return fmt.Sprintf("%c%c-%03d", letters[rand.Intn(len(letters))], letters[rand.Intn(len(letters))], - rand.Intn(1000000), + rand.Intn(1000), ) } @@ -455,7 +454,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID) - // [New] Local DB Sync + // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. + // 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다. localUser := &domain.User{ ID: providerID, // Match IDP Subject Email: req.Email, @@ -471,9 +471,17 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { } if h.UserRepo != nil { - if err := h.UserRepo.Create(c.Context(), localUser); err != nil { - slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err) - } + go func(u *domain.User) { + // 요청 Context가 취소될 수 있으므로 Background Context 사용 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := h.UserRepo.Create(ctx, u); err != nil { + slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err) + } else { + slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email) + } + }(localUser) } // [Keto] Sync user-tenant relationship @@ -959,20 +967,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } + // [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정 userCode := GenerateUserCode() token := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3) slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef) - // [Added] 사용자가 입력할 간편 코드를 Redis에 저장합니다. (이게 없으면 인증이 안 됩니다) - shortCodePayload, _ := json.Marshal(shortLoginCodePayload{ - LoginID: lookupLoginID, - Code: token, - PendingRef: pendingRef, - }) - h.RedisService.Set(prefixLoginCodeShort+userCode, string(shortCodePayload), defaultExpiration) - // Store in Redis sessionData, _ := json.Marshal(map[string]string{ "status": statusPending, @@ -1026,13 +1027,12 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } } else { // Send SMS - phone := sanitizePhoneForSms(loginID) - content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode) + content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode) if drySend { - slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", phone, "content", content) + slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content) } else { - slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone) - if err := h.SmsService.SendSms(phone, content); err != nil { + slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) + if err := h.SmsService.SendSms(loginID, content); err != nil { slog.Error("[Enchanted] SMS Failed", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } @@ -1526,7 +1526,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID - // ale.NewPassword = req.Password // For test only, logging password (sensitive) + ale.NewPassword = req.Password // For test only, logging password (sensitive) ale.Log(slog.LevelInfo, "Attempting to login") @@ -1568,25 +1568,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { // --- OIDC 로그인 흐름 처리 --- if req.LoginChallenge != "" { - slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge, "subject", subject) + slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge) // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) - if err == nil && loginReq != nil { - slog.Info("OIDC Client Info", "client_id", loginReq.Client.ClientID, "name", loginReq.Client.ClientName) - if loginReq.Client.Metadata != nil { - if status, ok := loginReq.Client.Metadata["status"].(string); ok { - if strings.ToLower(status) == "inactive" { - slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) - return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") - } + if err == nil && loginReq != nil && loginReq.Client.Metadata != nil { + if status, ok := loginReq.Client.Metadata["status"].(string); ok { + if strings.ToLower(status) == "inactive" { + slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) + return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") } } } acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) if err != nil { - slog.Error("failed to accept hydra login request", "error", err, "challenge", req.LoginChallenge) + slog.Error("failed to accept hydra login request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo) @@ -1597,13 +1594,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { // --- OIDC 로그인 흐름 처리 끝 --- resp := fiber.Map{ - "sessionToken": authInfo.SessionToken.JWT, - "sessionJwt": authInfo.SessionToken.JWT, // Frontend compatibility - "status": "ok", - "provider": h.IdpProvider.Name(), + "sessionJwt": authInfo.SessionToken.JWT, + "status": "ok", + "provider": h.IdpProvider.Name(), } if authInfo.RefreshToken != nil { - resp["refreshToken"] = authInfo.RefreshToken.JWT + resp["refreshJwt"] = authInfo.RefreshToken.JWT } if authInfo.Subject != "" { resp["subject"] = authInfo.Subject @@ -2079,16 +2075,6 @@ type kratosCourierRequest struct { Body string `json:"body"` } -// sanitizePhoneForSms - 네이버 SMS 등 국내 발송기를 위해 +82 형식을 010 형식으로 변환합니다. -func sanitizePhoneForSms(phone string) string { - p := strings.ReplaceAll(phone, "-", "") - p = strings.ReplaceAll(p, " ", "") - if strings.HasPrefix(p, "+82") { - return "0" + p[3:] - } - return p -} - // HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다. func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { var req kratosCourierRequest @@ -2467,6 +2453,16 @@ func extractFirstString(data map[string]interface{}, keys ...string) string { return "" } +func sanitizePhoneForSms(phone string) string { + sanitized := strings.TrimSpace(phone) + if strings.HasPrefix(sanitized, "+82") { + sanitized = "0" + sanitized[3:] + } + sanitized = strings.ReplaceAll(sanitized, "-", "") + sanitized = strings.ReplaceAll(sanitized, " ", "") + return sanitized +} + // --- User Profile Handlers --- func (h *AuthHandler) formatPhoneForDisplay(phone string) string { @@ -2484,56 +2480,7 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { return phone } -// ProxyOidc - 프론트엔드의 OIDC 요청을 내부 Hydra 서비스로 프록시합니다. -func (h *AuthHandler) ProxyOidc(c *fiber.Ctx) error { - path := c.Params("*") - // [Strict] Always use internal Docker network address for proxying to avoid external loops - targetURL := "http://hydra:4444" - - // 프록시 URL 구성 - u, err := url.Parse(targetURL) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "invalid hydra public url") - } - u.Path = strings.TrimRight(u.Path, "/") + "/" + path - u.RawQuery = string(c.Request().URI().QueryString()) - - slog.Debug("Proxying OIDC request", "from", c.Path(), "to", u.String()) - - // 요청 준비 - req, err := http.NewRequestWithContext(c.Context(), c.Method(), u.String(), bytes.NewReader(c.Body())) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "failed to create proxy request") - } - - // 헤더 복사 - c.Request().Header.VisitAll(func(key, value []byte) { - k := string(key) - if k != "Host" && k != "Connection" { - req.Header.Add(k, string(value)) - } - }) - - // 요청 실행 (Hydra 내부 HttpClient 사용) - resp, err := h.Hydra.HttpClient().Do(req) - if err != nil { - return fiber.NewError(fiber.StatusServiceUnavailable, "hydra public api unavailable") - } - defer resp.Body.Close() - - // 응답 헤더 복사 - for k, values := range resp.Header { - for _, v := range values { - c.Set(k, v) - } - } - - // 상태 코드 및 바디 설정 - c.Status(resp.StatusCode) - _, err = io.Copy(c.Response().BodyWriter(), resp.Body) - return err -} - +// GetMe - Returns current user's profile with enriched data from local DB func (h *AuthHandler) GetMe(c *fiber.Ctx) error { profile, err := h.resolveCurrentProfile(c) if err != nil { @@ -4006,13 +3953,6 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe } } - // Fetch Manageable Tenants for Admins - if profile.Role == domain.RoleSuperAdmin || profile.Role == domain.RoleTenantAdmin || profile.Role == domain.RoleRPAdmin { - if tenants, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID); err == nil { - profile.ManageableTenants = tenants - } - } - // 4. Save to Redis Cache (Short TTL) if h.RedisService != nil && cacheKey != "" { if data, err := json.Marshal(profile); err == nil { @@ -4842,7 +4782,10 @@ func extractLoginIDFromClaims(claims map[string]any) string { } func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { - kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/") + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { return "", nil, err @@ -4850,44 +4793,33 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string req.Header.Set("X-Session-Token", sessionToken) resp, err := http.DefaultClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - var result struct { - Identity struct { - ID string `json:"id"` - Traits map[string]interface{} `json:"traits"` - } `json:"identity"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { - return result.Identity.ID, result.Identity.Traits, nil - } - } + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } - // 2. Kratos 실패 시 Hydra Introspection 시도 (OIDC Access Token 대응) - if h.Hydra != nil { - slog.Debug("[Auth] Kratos whoami failed, trying Hydra introspection", "token_prefix", sessionToken[:min(len(sessionToken), 10)]) - introspection, err := h.Hydra.IntrospectToken(context.Background(), sessionToken) - if err == nil && introspection["active"] == true { - subject, _ := introspection["sub"].(string) - if subject != "" { - // Hydra는 Traits를 직접 주지 않으므로, Kratos Admin API로 상세 정보를 가져옴 - identity, err := h.KratosAdmin.GetIdentity(context.Background(), subject) - if err == nil && identity != nil { - return identity.ID, identity.Traits, nil - } - // Identity 정보가 없더라도 최소한 Subject는 반환 - return subject, map[string]interface{}{}, nil - } - } + var result struct { + Identity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + } `json:"identity"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err } - return "", nil, fmt.Errorf("invalid session or token") + return result.Identity.ID, result.Identity.Traits, nil } func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { - kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/") + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { return "", err @@ -4910,7 +4842,6 @@ func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } - return result.ID, nil } @@ -4919,7 +4850,10 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) return "", fmt.Errorf("kratos identity id is empty") } - kratosAdminURL := strings.TrimRight(utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), "/") + kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") + if kratosAdminURL == "" { + kratosAdminURL = "http://kratos:4434" + } payload := map[string]interface{}{ "identity_id": identityID, diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go new file mode 100644 index 00000000..48db4a75 --- /dev/null +++ b/backend/internal/handler/auth_handler_async_test.go @@ -0,0 +1,251 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// --- Async Test Mocks --- + +type AsyncMockIdpProvider struct { + mock.Mock +} + +func (m *AsyncMockIdpProvider) Name() string { return "mock-idp" } +func (m *AsyncMockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) { + return &domain.IDPMetadata{}, nil +} +func (m *AsyncMockIdpProvider) UserExists(loginID string) (bool, error) { + args := m.Called(loginID) + return args.Bool(0), args.Error(1) +} +func (m *AsyncMockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + args := m.Called(user, password) + return args.String(0), args.Error(1) +} +func (m *AsyncMockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *AsyncMockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil } +func (m *AsyncMockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, nil +} +func (m *AsyncMockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *AsyncMockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return &domain.PasswordPolicy{MinLength: 12}, nil +} +func (m *AsyncMockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return nil } +func (m *AsyncMockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *AsyncMockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + return nil +} + +type AsyncMockUserRepo struct { + mock.Mock + createCalled chan bool +} + +func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error { + // Simulate DB latency + time.Sleep(50 * time.Millisecond) + args := m.Called(ctx, user) + if m.createCalled != nil { + m.createCalled <- true + } + return args.Error(0) +} +func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + return nil, nil +} +func (m *AsyncMockUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) { + return nil, nil +} +func (m *AsyncMockUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { + return nil, nil +} +func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + return nil, nil +} +func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { + return nil, 0, nil +} + +type AsyncMockRedisRepo struct { + mock.Mock +} + +func (m *AsyncMockRedisRepo) Set(key string, value string, expiration time.Duration) error { + args := m.Called(key, value, expiration) + return args.Error(0) +} +func (m *AsyncMockRedisRepo) Get(key string) (string, error) { + args := m.Called(key) + return args.String(0), args.Error(1) +} +func (m *AsyncMockRedisRepo) Delete(key string) error { + args := m.Called(key) + return args.Error(0) +} +func (m *AsyncMockRedisRepo) StoreVerificationCode(phone, code string) error { return nil } +func (m *AsyncMockRedisRepo) GetVerificationCode(phone string) (string, error) { return "", nil } +func (m *AsyncMockRedisRepo) DeleteVerificationCode(phone string) error { return nil } + +type AsyncMockTenantService struct { + mock.Mock +} + +func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { + args := m.Called(ctx, emailDomain) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *AsyncMockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil } +func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} +func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { + return nil +} +func (m *AsyncMockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error { + return nil +} +func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) { + return nil, nil +} + +type AsyncMockKetoService struct { + mock.Mock +} + +func (m *AsyncMockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} +func (m *AsyncMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return nil +} +func (m *AsyncMockKetoService) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) { + return false, nil +} +func (m *AsyncMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + return nil, nil +} +func (m *AsyncMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { + return nil, nil +} + +// --- Tests --- + +func TestSignup_AsyncDB_Isolation(t *testing.T) { + mockIdp := new(AsyncMockIdpProvider) + mockUserRepo := new(AsyncMockUserRepo) + mockRedis := new(AsyncMockRedisRepo) + mockTenant := new(AsyncMockTenantService) + mockKeto := new(AsyncMockKetoService) + + h := &AuthHandler{ + IdpProvider: mockIdp, + UserRepo: mockUserRepo, + RedisService: mockRedis, + TenantService: mockTenant, + KetoService: mockKeto, + } + + app := fiber.New() + app.Post("/signup", h.Signup) + + t.Run("SoT_DB_Failure_Ignored_And_Async", func(t *testing.T) { + email := "test@example.com" + phone := "010-1234-5678" + emailKey := "signup:email:" + email + phoneKey := "signup:phone:" + "01012345678" + + // Redis Mocks + mockRedis.On("Get", emailKey).Return(`{"verified": true, "expires_at": 9999999999}`, nil) + mockRedis.On("Get", phoneKey).Return(`{"verified": true, "expires_at": 9999999999}`, nil) + mockRedis.On("Delete", emailKey).Return(nil) + mockRedis.On("Delete", phoneKey).Return(nil) + + // Tenant Mocks + mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, errors.New("not found")) + + // Kratos Mocks (Success) + mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil) + + // UserRepo Mocks (Async & Failure) + mockUserRepo.createCalled = make(chan bool, 1) + mockUserRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *domain.User) bool { + return u.Email == email + })).Return(errors.New("db connection error")) + + // Keto Mocks (Optional, since it's also async) + // We won't block on this either + + body, _ := json.Marshal(domain.SignupRequest{ + Email: email, + Password: "Password123!", + Name: "Test User", + Phone: phone, + TermsAccepted: true, + }) + req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + start := time.Now() + resp, err := app.Test(req, 5000) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("Request failed: %v", err) + } + + assert.Equal(t, 200, resp.StatusCode) + + // Ensure API responded faster than DB latency (50ms) + assert.Less(t, int64(elapsed), int64(60*time.Millisecond), "API should return before DB timeout") + + // Wait for async execution + select { + case <-mockUserRepo.createCalled: + // Pass + case <-time.After(2 * time.Second): + t.Fatal("UserRepo.Create was not called asynchronously") + } + + mockRedis.AssertExpectations(t) + mockIdp.AssertExpectations(t) + mockUserRepo.AssertExpectations(t) + }) +} diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 7fb89bdc..dd261e17 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -288,8 +288,8 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) { } var got map[string]string json.NewDecoder(resp.Body).Decode(&got) - if got["sessionToken"] != "valid-jwt" { - t.Errorf("expected jwt valid-jwt, got %s", got["sessionToken"]) + if got["sessionJwt"] != "valid-jwt" { + t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"]) } // No redirectTo if _, ok := got["redirectTo"]; ok { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index ac232a11..6514d741 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -22,17 +22,15 @@ type DevHandler struct { SecretRepo domain.ClientSecretRepository KratosAdmin *service.KratosAdminService ConsentRepo repository.ClientConsentRepository - RPService service.RelyingPartyService } -func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpService service.RelyingPartyService) *DevHandler { +func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler { return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, SecretRepo: secretRepo, KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, - RPService: rpService, } } @@ -97,58 +95,38 @@ type clientUpsertRequest struct { } func (h *DevHandler) ListClients(c *fiber.Ctx) error { - profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) - if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"}) + limit := c.QueryInt("limit", 50) + offset := c.QueryInt("offset", 0) + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 } - // Super Admin sees all (best effort via Hydra list for now, or we can use RPService if it's improved) - if profile.Role == domain.RoleSuperAdmin { - limit := c.QueryInt("limit", 50) - offset := c.QueryInt("offset", 0) - clients, err := h.Hydra.ListClients(c.Context(), limit, offset) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - items := make([]clientSummary, 0, len(clients)) - for _, client := range clients { - items = append(items, h.mapClientSummary(client)) - } - return c.JSON(clientListResponse{Items: items, Limit: limit, Offset: offset}) - } - - // For others, only show manageable tenants' clients - var tenantIDs []string - for _, t := range profile.ManageableTenants { - tenantIDs = append(tenantIDs, t.ID) - } - - if len(tenantIDs) == 0 && profile.TenantID != nil { - tenantIDs = append(tenantIDs, *profile.TenantID) - } - - if len(tenantIDs) == 0 { - return c.JSON(clientListResponse{Items: []clientSummary{}, Limit: 50, Offset: 0}) - } - - rps, err := h.RPService.ListByTenantIDs(c.Context(), tenantIDs) + clients, err := h.Hydra.ListClients(c.Context(), limit, offset) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + if errors.Is(err, service.ErrHydraNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"}) + } + errMsg := err.Error() + if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "Hydra service is unavailable. Please check if Ory Hydra is running.", + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg}) } - items := make([]clientSummary, 0, len(rps)) - for _, rp := range rps { - // We need HydraClient details for the summary - client, err := h.Hydra.GetClient(c.Context(), rp.ClientID) - if err == nil { - items = append(items, h.mapClientSummary(*client)) - } + items := make([]clientSummary, 0, len(clients)) + for _, client := range clients { + items = append(items, h.mapClientSummary(client)) } return c.JSON(clientListResponse{ Items: items, - Limit: len(items), - Offset: 0, + Limit: limit, + Offset: offset, }) } @@ -166,11 +144,6 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // Set for audit logging - if tid, ok := client.Metadata["tenant_id"].(string); ok { - c.Locals("tenant_id", tid) - } - summary := h.mapClientSummary(*client) return c.JSON(clientDetailResponse{ Client: summary, @@ -224,49 +197,11 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { } func (h *DevHandler) CreateClient(c *fiber.Ctx) error { - profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) - if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) - } - var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } - // Determine Tenant ID - targetTenantID := c.Get("X-Tenant-ID") - if targetTenantID == "" && profile.TenantID != nil { - targetTenantID = *profile.TenantID - } - - if targetTenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"}) - } - - // Set for audit logging - c.Locals("tenant_id", targetTenantID) - - // Validate Permission - isAllowed := false - if profile.Role == domain.RoleSuperAdmin { - isAllowed = true - } else { - for _, t := range profile.ManageableTenants { - if t.ID == targetTenantID { - isAllowed = true - break - } - } - if !isAllowed && profile.TenantID != nil && *profile.TenantID == targetTenantID { - isAllowed = true - } - } - - if !isAllowed { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "you do not have permission to create clients for this tenant"}) - } - clientID := strings.TrimSpace(valueOr(req.ID, "")) if clientID == "" { clientID = uuid.NewString() @@ -322,18 +257,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { Metadata: metadata, } - // Use RPService to ensure Keto relations are created - rp, err := h.RPService.Create(c.Context(), targetTenantID, clientReq) + created, err := h.Hydra.CreateClient(c.Context(), clientReq) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // Fetch back the Hydra client to get the secret (RPService.Create returns domain.RelyingParty which has limited fields) - created, err := h.Hydra.GetClient(c.Context(), rp.ClientID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "client created but failed to retrieve details"}) - } - // Store secret in metadata for later retrieval if created.ClientSecret != "" { // 1. Store in PostgreSQL (Source of Truth) @@ -379,11 +307,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // Set for audit logging - if tid, ok := current.Metadata["tenant_id"].(string); ok { - c.Locals("tenant_id", tid) - } - clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) @@ -459,14 +382,6 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) } - // Fetch first for audit log tenant_id - client, err := h.Hydra.GetClient(c.Context(), clientID) - if err == nil { - if tid, ok := client.Metadata["tenant_id"].(string); ok { - c.Locals("tenant_id", tid) - } - } - if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) @@ -488,24 +403,11 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { } func (h *DevHandler) ListConsents(c *fiber.Ctx) error { - profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) - if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) - } - clientID := strings.TrimSpace(c.Query("client_id")) if clientID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"}) } - // Permission Check - if profile.Role != domain.RoleSuperAdmin { - allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "view") - if err != nil || !allowed { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to view consents for this client"}) - } - } - subject := strings.TrimSpace(c.Query("subject")) limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) @@ -582,28 +484,12 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { } func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { - profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) - if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) - } - subject := strings.TrimSpace(c.Query("subject")) if subject == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"}) } clientID := strings.TrimSpace(c.Query("client_id")) - // Permission Check (if clientID is provided) - if clientID != "" && profile.Role != domain.RoleSuperAdmin { - allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "manage") - if err != nil || !allowed { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to revoke consents for this client"}) - } - } else if clientID == "" && profile.Role != domain.RoleSuperAdmin { - // If clientID is not provided, we might need a more global check or just disallow it for non-superadmins - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required for non-superadmins"}) - } - // If subject is not a UUID, try to resolve it as an identifier (email/username) if _, err := uuid.Parse(subject); err != nil { resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) @@ -646,11 +532,6 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // Set for audit logging - if tid, ok := current.Metadata["tenant_id"].(string); ok { - c.Locals("tenant_id", tid) - } - // 3. Update Hydra current.ClientSecret = newSecret updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 7e6e1a9a..4c491c73 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1,10 +1,8 @@ package handler import ( - "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" @@ -12,75 +10,8 @@ import ( "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) -type MockRPService struct { - mock.Mock -} - -func (m *MockRPService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) { - args := m.Called(ctx, tenantID, client) - return args.Get(0).(*domain.RelyingParty), args.Error(1) -} - -func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) { - args := m.Called(ctx, clientID) - return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2) -} - -func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { - args := m.Called(ctx, tenantID) - return args.Get(0).([]domain.RelyingParty), args.Error(1) -} - -func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { - args := m.Called(ctx) - return args.Get(0).([]domain.RelyingParty), args.Error(1) -} - -func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) { - args := m.Called(ctx, tenantIDs) - return args.Get(0).([]domain.RelyingParty), args.Error(1) -} - -func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) { - args := m.Called(ctx, clientID, client) - return args.Get(0).(*domain.RelyingParty), args.Error(1) -} - -func (m *MockRPService) Delete(ctx context.Context, clientID string) error { - args := m.Called(ctx, clientID) - return args.Error(0) -} - -func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) { - args := m.Called(ctx, userID, clientID, relation) - return args.Bool(0), args.Error(1) -} - -func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error { - args := m.Called(ctx, clientID, subject) - return args.Error(0) -} - -func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error { - args := m.Called(ctx, clientID, subject) - return args.Error(0) -} - -func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) { - args := m.Called(ctx, clientID) - return args.Get(0).([]string), args.Error(1) -} - -func withMockProfile(profile *domain.UserProfileResponse) fiber.Handler { - return func(c *fiber.Ctx) error { - c.Locals("user_profile", profile) - return c.Next() - } -} - func TestListClients_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -99,11 +30,7 @@ func TestListClients_Success(t *testing.T) { }, } app := fiber.New() - adminProfile := &domain.UserProfileResponse{ - ID: "admin-1", - Role: domain.RoleSuperAdmin, - } - app.Get("/api/v1/dev/clients", withMockProfile(adminProfile), h.ListClients) + app.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) @@ -139,11 +66,7 @@ func TestGetClient_Success(t *testing.T) { }, } app := fiber.New() - adminProfile := &domain.UserProfileResponse{ - ID: "admin-1", - Role: domain.RoleSuperAdmin, - } - app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient) + app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) resp, _ := app.Test(req, -1) @@ -169,11 +92,7 @@ func TestGetClient_NotFound(t *testing.T) { }, } app := fiber.New() - adminProfile := &domain.UserProfileResponse{ - ID: "admin-1", - Role: domain.RoleSuperAdmin, - } - app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient) + app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil) resp, _ := app.Test(req, -1) @@ -190,49 +109,30 @@ func TestCreateClient_Success(t *testing.T) { "client_secret": "secret-123", }), nil } - if r.Method == http.MethodGet && r.URL.Path == "/clients/new-client-123" { - return httpJSONAny(r, http.StatusOK, map[string]interface{}{ - "client_id": "new-client-123", - "client_name": "New App", - "client_secret": "secret-123", - "metadata": map[string]interface{}{"status": "active"}, - }), nil - } - return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error path: " + r.URL.Path}), nil + return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil }) secretRepo := &mockSecretRepo{secrets: make(map[string]string)} redisRepo := &mockRedisRepo{data: make(map[string]string)} - mockRP := new(MockRPService) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", - PublicURL: "http://hydra-public.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: secretRepo, Redis: redisRepo, - RPService: mockRP, } app := fiber.New() - adminProfile := &domain.UserProfileResponse{ - ID: "admin-1", - Role: domain.RoleSuperAdmin, - } - app.Post("/api/v1/dev/clients", withMockProfile(adminProfile), h.CreateClient) + app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]interface{}{ "client_name": "New App", "type": "confidential", "redirectUris": []string{"http://localhost/cb"}, }) - - mockRP.On("Create", mock.Anything, "t1", mock.Anything).Return(&domain.RelyingParty{ClientID: "new-client-123"}, nil) - req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Tenant-ID", "t1") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) diff --git a/backend/internal/handler/relying_party_handler.go b/backend/internal/handler/relying_party_handler.go index 8a9b448a..29611b23 100644 --- a/backend/internal/handler/relying_party_handler.go +++ b/backend/internal/handler/relying_party_handler.go @@ -10,11 +10,10 @@ import ( type RelyingPartyHandler struct { Service service.RelyingPartyService - UserSvc *service.KratosAdminService } -func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler { - return &RelyingPartyHandler{Service: s, UserSvc: userSvc} +func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler { + return &RelyingPartyHandler{Service: s} } func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { @@ -111,58 +110,3 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } - -func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error { - clientID := c.Params("id") - subjects, err := h.Service.ListOwners(c.Context(), clientID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - type ownerInfo struct { - Subject string `json:"subject"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Type string `json:"type"` // "user" or "group" - } - - owners := make([]ownerInfo, 0, len(subjects)) - for _, s := range subjects { - info := ownerInfo{Subject: s, Type: "unknown"} - if len(s) > 5 && s[:5] == "User:" { - info.Type = "user" - userID := s[5:] - identity, err := h.UserSvc.GetIdentity(c.Context(), userID) - if err == nil && identity != nil { - info.Name, _ = identity.Traits["name"].(string) - info.Email, _ = identity.Traits["email"].(string) - } - } else if len(s) > 10 && s[:10] == "UserGroup:" { - info.Type = "group" - // Group name enrichment could be added if we have a GroupService here - } - owners = append(owners, info) - } - - return c.JSON(owners) -} - -func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error { - clientID := c.Params("id") - subject := c.Params("subject") // e.g. "User:uuid" - - if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - return c.JSON(fiber.Map{"message": "owner added"}) -} - -func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error { - clientID := c.Params("id") - subject := c.Params("subject") - - if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - return c.JSON(fiber.Map{"message": "owner removed"}) -} diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 89d43663..53d858bb 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -14,25 +14,22 @@ import ( type TenantHandler struct { DB *gorm.DB Service service.TenantService - Keto service.KetoService - UserSvc *service.KratosAdminService } -func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler { - return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc} +func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler { + return &TenantHandler{DB: db, Service: svc} } type tenantSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - Status string `json:"status"` - TenantGroupID *string `json:"tenantGroupId,omitempty"` - Domains []string `json:"domains,omitempty"` - Config domain.JSONMap `json:"config,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + Domains []string `json:"domains,omitempty"` + Config domain.JSONMap `json:"config,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type tenantListResponse struct { @@ -103,7 +100,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { } var tenants []domain.Tenant - if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil { + if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -126,7 +123,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { } var tenant domain.Tenant - if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil { + if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) } @@ -207,13 +204,12 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } var req struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Description *string `json:"description"` - Status *string `json:"status"` - TenantGroupID *string `json:"tenantGroupId"` - Domains []string `json:"domains"` - Config map[string]any `json:"config"` + Name *string `json:"name"` + Slug *string `json:"slug"` + Description *string `json:"description"` + Status *string `json:"status"` + Domains []string `json:"domains"` + Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) @@ -255,29 +251,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { tenant.Config = req.Config } - // Handle Group Change - if req.TenantGroupID != nil { - oldGroupID := tenant.TenantGroupID - newGroupID := req.TenantGroupID - if *newGroupID == "" { - newGroupID = nil - } - - // Update Keto if group changed - if h.Keto != nil { - // Remove old group relation if existed - if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) { - _ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID) - } - // Add new group relation - if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) { - _ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID) - } - } - - tenant.TenantGroupID = newGroupID - } - if err := h.DB.Save(&tenant).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -328,58 +301,6 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } -func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { - tenantID := c.Params("id") - userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - type adminInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - } - - admins := make([]adminInfo, 0, len(userIDs)) - for _, uid := range userIDs { - identity, err := h.UserSvc.GetIdentity(c.Context(), uid) - if err == nil && identity != nil { - name, _ := identity.Traits["name"].(string) - email, _ := identity.Traits["email"].(string) - admins = append(admins, adminInfo{ - ID: uid, - Name: name, - Email: email, - }) - } else { - admins = append(admins, adminInfo{ID: uid}) - } - } - - return c.JSON(admins) -} - -func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { - tenantID := c.Params("id") - userID := c.Params("userId") - - if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - return c.JSON(fiber.Map{"message": "admin added to tenant"}) -} - -func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { - tenantID := c.Params("id") - userID := c.Params("userId") - - if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - return c.JSON(fiber.Map{"message": "admin removed from tenant"}) -} - func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { @@ -387,16 +308,15 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { } return tenantSummary{ - ID: t.ID, - Name: t.Name, - Slug: t.Slug, - Description: t.Description, - Status: t.Status, - TenantGroupID: t.TenantGroupID, - Domains: domains, - Config: t.GetMergedConfig(), - CreatedAt: t.CreatedAt.Format(time.RFC3339), - UpdatedAt: t.UpdatedAt.Format(time.RFC3339), + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Description: t.Description, + Status: t.Status, + Domains: domains, + Config: t.Config, + CreatedAt: t.CreatedAt.Format(time.RFC3339), + UpdatedAt: t.UpdatedAt.Format(time.RFC3339), } } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index e1ae7261..28bb903c 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -70,26 +70,6 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } -func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { - args := m.Called(ctx, userID) - return args.Get(0).([]domain.Tenant), args.Error(1) -} - -func (m *MockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { - args := m.Called(ctx, tenantID, userID) - return args.Error(0) -} - -func (m *MockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error { - args := m.Called(ctx, tenantID, userID) - return args.Error(0) -} - -func (m *MockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) { - args := m.Called(ctx, tenantID) - return args.Get(0).([]string), args.Error(1) -} - func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 966e5b82..00a64ba7 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -304,10 +304,15 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { localUser.TenantID = &tenantID } + // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. if h.UserRepo != nil { - if err := h.UserRepo.Create(c.Context(), localUser); err != nil { - slog.Error("[UserHandler] Failed to sync user to local DB", "email", email, "error", err) - } + go func(u *domain.User) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := h.UserRepo.Create(ctx, u); err != nil { + slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err) + } + }(localUser) } // [Keto] Sync relations @@ -483,27 +488,32 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { localUser.Metadata = req.Metadata } - if err := h.UserRepo.Update(c.Context(), localUser); err == nil { - // [Keto Sync on Role Change] - if h.KetoService != nil && req.Role != nil && *req.Role != oldRole { - go func(uID, oldR, newR, tID string) { - ctx := context.Background() - if oldR == domain.RoleSuperAdmin { + // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. + go func(u *domain.User, rRole *string, oRole string, oTenantID string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := h.UserRepo.Update(ctx, u); err == nil { + // [Keto Sync on Role Change] + if h.KetoService != nil && rRole != nil && *rRole != oRole { + uID := u.ID + newR := *rRole + if oRole == domain.RoleSuperAdmin { _ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID) - } else if oldR == domain.RoleTenantAdmin && tID != "" { - _ = h.KetoService.DeleteRelation(ctx, "Tenant", tID, "admins", uID) + } else if oRole == domain.RoleTenantAdmin && oTenantID != "" { + _ = h.KetoService.DeleteRelation(ctx, "Tenant", oTenantID, "admins", uID) } if newR == domain.RoleSuperAdmin { _ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID) - } else if newR == domain.RoleTenantAdmin && tID != "" { - _ = h.KetoService.CreateRelation(ctx, "Tenant", tID, "admins", uID) + } else if newR == domain.RoleTenantAdmin && u.TenantID != nil { + _ = h.KetoService.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", uID) } - }(userID, oldRole, *req.Role, oldTenantID) + } + } else { + slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err) } - } else { - slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err) - } + }(localUser, req.Role, oldRole, oldTenantID) } } diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index a7f08f5a..920a87d4 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -3,7 +3,6 @@ package middleware import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" - "fmt" "log/slog" "github.com/gofiber/fiber/v2" @@ -26,7 +25,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. return func(c *fiber.Ctx) error { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_keto)"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"}) } // Store profile in locals for further use in handlers @@ -44,21 +43,14 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. } if objectID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "권한 검증을 위한 대상 ID가 누락되었습니다."}) - } - - // Set tenant_id for audit logging if namespace is Tenant - if namespace == "Tenant" { - c.Locals("tenant_id", objectID) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"}) } // Check with Keto allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) if err != nil || !allowed { slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 요청하신 리소스에 대한 상세 권한(Keto)이 부족합니다. 관리자에게 문의하세요.", profile.Role), - }) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"}) } return c.Next() @@ -76,7 +68,7 @@ func RequireRole(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "error": "인증 정보 조회에 실패했습니다: " + err.Error(), + "error": "unauthorized (trace:rbac_role): " + err.Error(), }) } @@ -105,7 +97,7 @@ func RequireRole(config RBACConfig) fiber.Handler { "path", c.Path(), ) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 이 기능을 사용할 수 없습니다. 관리자에게 문의하여 'rp_admin' 이상의 권한을 확보하세요.", profile.Role), + "error": "forbidden: insufficient permissions", }) } @@ -126,7 +118,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_match)"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"}) } // Store profile in locals for further use in handlers @@ -146,12 +138,12 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { if profile.TenantID == nil || *profile.TenantID != targetTenantID { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": fmt.Sprintf("해당 테넌트에 대한 접근 권한이 없습니다. 사용자님의 '%s' 권한은 소속된 테넌트의 리소스만 관리할 수 있습니다.", profile.Role), + "error": "forbidden: you do not have access to this tenant", }) } return c.Next() } - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "요청하신 리소스에 접근할 수 없습니다."}) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) } } diff --git a/backend/internal/middleware/rbac_test.go b/backend/internal/middleware/rbac_test.go index db9ff925..b4bd837f 100644 --- a/backend/internal/middleware/rbac_test.go +++ b/backend/internal/middleware/rbac_test.go @@ -54,14 +54,6 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, return args.Get(0).([]service.RelationTuple), args.Error(1) } -func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { - args := m.Called(ctx, namespace, relation, subject) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]string), args.Error(1) -} - // Fixed MockKetoService to match service.KetoService exactly if possible. // Wait, middleware/rbac.go imports baron-sso-backend/internal/service. // So I should use service.RelationTuple. diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 644ffc1a..5990e48b 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -14,7 +14,6 @@ type TenantRepository interface { FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) FindByName(ctx context.Context, name string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) - FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) AddDomain(ctx context.Context, tenantID string, domainName string) error } @@ -42,17 +41,6 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten return &tenant, nil } -func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { - var tenants []domain.Tenant - if len(ids) == 0 { - return tenants, nil - } - if err := r.db.WithContext(ctx).Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil { - return nil, err - } - return tenants, nil -} - func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { var tenant domain.Tenant if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil { diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index ce9c97bc..b86ebaba 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -2,7 +2,6 @@ package service import ( "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/utils" "bytes" "context" "encoding/json" @@ -28,8 +27,8 @@ type HydraAdminService struct { func NewHydraAdminService() *HydraAdminService { return &HydraAdminService{ - AdminURL: utils.GetEnv("HYDRA_ADMIN_URL", "http://hydra:4445"), - PublicURL: utils.GetEnv("HYDRA_PUBLIC_URL", "http://hydra:4444"), + AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"), + PublicURL: getenv("HYDRA_PUBLIC_URL", "http://hydra:4444"), } } @@ -47,7 +46,7 @@ func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) return nil, err } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, err } @@ -75,7 +74,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do return nil, err } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, err } @@ -114,7 +113,7 @@ func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, sta } req.Header.Set("Content-Type", "application/json-patch+json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, err } @@ -145,7 +144,7 @@ func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.Hydr } req.Header.Set("Content-Type", "application/json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, err } @@ -174,7 +173,7 @@ func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, c } req.Header.Set("Content-Type", "application/json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, err } @@ -202,7 +201,7 @@ func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) e return err } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return err } @@ -235,7 +234,7 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl return nil, err } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, err } @@ -276,7 +275,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, return err } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return err } @@ -289,7 +288,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, return nil } -func (s *HydraAdminService) HttpClient() *http.Client { +func (s *HydraAdminService) httpClient() *http.Client { if s.HTTPClient != nil { return s.HTTPClient } @@ -367,7 +366,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str return nil, fmt.Errorf("hydra admin: create request for get consent failed: %w", err) } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err) } @@ -407,7 +406,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge } req.Header.Set("Content-Type", "application/json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: reject consent request failed: %w", err) } @@ -449,7 +448,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e } req.Header.Set("Content-Type", "application/json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: reject login request failed: %w", err) } @@ -484,7 +483,7 @@ func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge strin return nil, fmt.Errorf("hydra admin: create request for get login failed: %w", err) } - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: get login request failed: %w", err) } @@ -532,7 +531,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge } req.Header.Set("Content-Type", "application/json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err) } @@ -576,7 +575,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st } req.Header.Set("Content-Type", "application/json") - resp, err := s.HttpClient().Do(req) + resp, err := s.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err) } @@ -597,34 +596,3 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } - -func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (map[string]interface{}, error) { - endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) - - data := url.Values{} - data.Set("token", token) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := s.HttpClient().Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return nil, fmt.Errorf("hydra admin: introspect failed status=%d body=%s", resp.StatusCode, string(body)) - } - - var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - return result, nil -} diff --git a/backend/internal/service/keto_service.go b/backend/internal/service/keto_service.go index 2eeab758..9303485b 100644 --- a/backend/internal/service/keto_service.go +++ b/backend/internal/service/keto_service.go @@ -1,7 +1,6 @@ package service import ( - "baron-sso-backend/internal/utils" "bytes" "context" "encoding/json" @@ -10,6 +9,7 @@ import ( "log/slog" "net/http" "net/url" + "os" "time" ) @@ -18,7 +18,6 @@ type KetoService interface { CreateRelation(ctx context.Context, namespace, object, relation, subject string) error DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) - ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) } type ketoService struct { @@ -28,8 +27,14 @@ type ketoService struct { } func NewKetoService() KetoService { - readURL := utils.GetEnv("KETO_READ_URL", "http://keto:4466") - writeURL := utils.GetEnv("KETO_WRITE_URL", "http://keto:4467") + readURL := os.Getenv("KETO_READ_URL") + if readURL == "" { + readURL = "http://keto:4466" + } + writeURL := os.Getenv("KETO_WRITE_URL") + if writeURL == "" { + writeURL = "http://keto:4467" + } return &ketoService{ readURL: readURL, @@ -187,40 +192,3 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) return nil } - -func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { - u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL)) - q := u.Query() - q.Set("namespace", namespace) - q.Set("relation", relation) - q.Set("subject_id", subject) - u.RawQuery = q.Encode() - - req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body)) - } - - var res relationTuplesResponse - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - objects := make([]string, 0, len(res.RelationTuples)) - seen := make(map[string]bool) - for _, rt := range res.RelationTuples { - if !seen[rt.Object] { - objects = append(objects, rt.Object) - seen[rt.Object] = true - } - } - - return objects, nil -} diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index 03e9669e..800407d4 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -1,7 +1,6 @@ package service import ( - "baron-sso-backend/internal/utils" "bytes" "context" "encoding/json" @@ -29,7 +28,7 @@ type KratosAdminService struct { func NewKratosAdminService() *KratosAdminService { return &KratosAdminService{ - AdminURL: utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), + AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"), } } @@ -228,9 +227,8 @@ func (s *KratosAdminService) httpClient() *http.Client { } func getenvKratos(key, fallback string) string { - v := os.Getenv(key) - if v == "" { - return fallback + if v := os.Getenv(key); v != "" { + return v } - return strings.Trim(v, "\"") + return fallback } diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 024d9f6b..bab1e8c0 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -2,7 +2,6 @@ package service import ( "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/utils" "bytes" "context" "encoding/json" @@ -28,9 +27,9 @@ type OryProvider struct { func NewOryProvider() *OryProvider { return &OryProvider{ - KratosAdminURL: utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), - KratosPublicURL: utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), - HydraAdminURL: utils.GetEnv("HYDRA_ADMIN_URL", "http://hydra:4445"), + KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"), + KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"), + HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"), } } @@ -320,7 +319,6 @@ func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.Lin respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { - slog.Warn("Ory link login init failed", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode, "body", string(respBody)) init, ok := parseKratosLinkLoginResponse(flowID, respBody) if ok { slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode) @@ -729,12 +727,10 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re } func getenv(key, fallback string) string { - v := os.Getenv(key) - if v == "" { - return fallback + if v := os.Getenv(key); v != "" { + return v } - // Strip surrounding double quotes if present - return strings.Trim(v, "\"") + return fallback } // findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환 diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 94ec851b..24b693ef 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -15,10 +15,6 @@ type RelyingPartyService interface { ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) Delete(ctx context.Context, clientID string) error - CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) - AddOwner(ctx context.Context, clientID, subject string) error - RemoveOwner(ctx context.Context, clientID, subject string) error - ListOwners(ctx context.Context, clientID string) ([]string, error) } type relyingPartyService struct { @@ -162,31 +158,6 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error return nil } -func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) { - return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation) -} - -func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error { - return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject) -} - -func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error { - return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject) -} - -func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) { - tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "") - if err != nil { - return nil, err - } - - subjects := make([]string, 0, len(tuples)) - for _, t := range tuples { - subjects = append(subjects, t.SubjectID) - } - return subjects, nil -} - func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty { if client == nil { return nil diff --git a/backend/internal/service/relying_party_service_test.go b/backend/internal/service/relying_party_service_test.go index 08e25466..f3464229 100644 --- a/backend/internal/service/relying_party_service_test.go +++ b/backend/internal/service/relying_party_service_test.go @@ -54,14 +54,6 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, return args.Get(0).([]RelationTuple), args.Error(1) } -func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { - args := m.Called(ctx, namespace, relation, subject) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]string), args.Error(1) -} - // --- Test Helpers --- type hydraRoundTripperFunc func(*http.Request) (*http.Response, error) diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index e4ea83dd..417d0b97 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -18,12 +18,8 @@ type TenantService interface { 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) - ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 - AddTenantAdmin(ctx context.Context, tenantID, userID string) error - RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error - ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) } type tenantService struct { @@ -43,60 +39,6 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan return s.repo.FindByID(ctx, id) } -func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { - if s.keto == nil { - return nil, errors.New("keto service not initialized") - } - - // 1. Get directly managed tenants - directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID) - if err != nil { - slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err) - } - - // 2. Get managed tenant groups - groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID) - if err != nil { - slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err) - } - - // 3. Get tenants belonging to those groups - var groupInheritedTenantIDs []string - for _, groupID := range groupIDs { - // In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_ - // To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_ - // Wait, my ListObjects lists objects given a subject. - // So subject="TenantGroup:"+groupID+"#_" - // Object is Tenant ID. - ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID) - if err == nil { - for _, t := range ts { - groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object) - } - } - } - - // Combine and deduplicate IDs - allIDsMap := make(map[string]bool) - for _, id := range directTenantIDs { - allIDsMap[id] = true - } - for _, id := range groupInheritedTenantIDs { - allIDsMap[id] = true - } - - allIDs := make([]string, 0, len(allIDsMap)) - for id := range allIDsMap { - allIDs = append(allIDs, id) - } - - if len(allIDs) == 0 { - return []domain.Tenant{}, nil - } - - return s.repo.FindByIDs(ctx, allIDs) -} - 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 { @@ -211,35 +153,3 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return s.repo.FindBySlug(ctx, slug) } - -func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { - if s.keto == nil { - return errors.New("keto service not initialized") - } - return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID) -} - -func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error { - if s.keto == nil { - return errors.New("keto service not initialized") - } - return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID) -} - -func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) { - if s.keto == nil { - return nil, errors.New("keto service not initialized") - } - tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "") - if err != nil { - return nil, err - } - - userIDs := make([]string, 0, len(tuples)) - for _, t := range tuples { - if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" { - userIDs = append(userIDs, t.SubjectID[5:]) - } - } - return userIDs, nil -}