forked from baron/baron-sso
refactor: backend tenant_group 제거 및 리팩터 반영
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,6 @@ func SeedAdminIdentity(idp domain.IdentityProvider) error {
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "admin",
|
||||
"role": domain.RoleSuperAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
251
backend/internal/handler/auth_handler_async_test.go
Normal file
251
backend/internal/handler/auth_handler_async_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 반환
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user