From afaac1781c965cd4752084749956bdd1ab4325d8 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 11 Feb 2026 12:41:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EA=B8=B0=EB=B0=98=20=EA=B6=8C=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=EC=86=8D=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=20=ED=8F=AC=ED=84=B8=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20#239?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 24 ++- backend/internal/domain/auth_models.go | 1 + backend/internal/domain/tenant.go | 22 +++ backend/internal/handler/auth_handler.go | 7 + backend/internal/handler/dev_handler.go | 143 +++++++++++++++--- backend/internal/handler/tenant_handler.go | 4 +- backend/internal/middleware/rbac.go | 5 + .../internal/repository/tenant_repository.go | 12 ++ backend/internal/service/keto_service.go | 38 +++++ .../internal/service/relying_party_service.go | 5 + backend/internal/service/tenant_service.go | 55 +++++++ 11 files changed, 282 insertions(+), 34 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6f4f1ecf..526cdc0b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -259,7 +259,7 @@ func main() { auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) adminHandler := handler.NewAdminHandler() - devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo) + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService) tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService) @@ -619,14 +619,24 @@ func main() { admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정) - dev := api.Group("/dev") + dev := api.Group("/dev", requireAdmin) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) - 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("/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("/consents", devHandler.ListConsents) dev.Delete("/consents", devHandler.RevokeConsents) diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 3cfd6d98..fd6eccee 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -80,6 +80,7 @@ type UserProfileResponse struct { RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 Metadata map[string]any `json:"metadata,omitempty"` Tenant *Tenant `json:"tenant,omitempty"` + ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 } type UpdateUserRequest struct { diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index 2f2ac485..954aa962 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -36,6 +36,28 @@ func (t *Tenant) IsActive() bool { return t.Status == TenantStatusActive } +// GetMergedConfig merges the group-level config with tenant-level config. +// Tenant config takes precedence. +func (t *Tenant) GetMergedConfig() JSONMap { + merged := make(JSONMap) + + // 1. Apply Group Config (Base) + if t.TenantGroup != nil && t.TenantGroup.Config != nil { + for k, v := range t.TenantGroup.Config { + merged[k] = v + } + } + + // 2. Apply Tenant Config (Overrides) + if t.Config != nil { + for k, v := range t.Config { + merged[k] = v + } + } + + return merged +} + // BeforeCreate hook to generate UUID if not present. func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) { if t.ID == "" { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3e2916e6..a960d7df 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3953,6 +3953,13 @@ 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 { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 6514d741..87d9b16f 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -22,15 +22,17 @@ 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) *DevHandler { +func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpService service.RelyingPartyService) *DevHandler { return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, SecretRepo: secretRepo, KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, + RPService: rpService, } } @@ -95,38 +97,58 @@ type clientUpsertRequest struct { } func (h *DevHandler) ListClients(c *fiber.Ctx) error { - limit := c.QueryInt("limit", 50) - offset := c.QueryInt("offset", 0) - if limit <= 0 { - limit = 50 - } - if offset < 0 { - offset = 0 + profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"}) } - clients, err := h.Hydra.ListClients(c.Context(), limit, offset) + // 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) if err != nil { - 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}) + 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)) + 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)) + } } return c.JSON(clientListResponse{ Items: items, - Limit: limit, - Offset: offset, + Limit: len(items), + Offset: 0, }) } @@ -197,11 +219,46 @@ 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"}) + } + + // 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() @@ -257,11 +314,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { Metadata: metadata, } - created, err := h.Hydra.CreateClient(c.Context(), clientReq) + // Use RPService to ensure Keto relations are created + rp, err := h.RPService.Create(c.Context(), targetTenantID, 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) @@ -403,11 +467,24 @@ 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) @@ -484,12 +561,28 @@ 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) diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index db7164be..01cede71 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -102,7 +102,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").Find(&tenants).Error; err != nil { + if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -341,7 +341,7 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { Status: t.Status, TenantGroupID: t.TenantGroupID, Domains: domains, - Config: t.Config, + Config: t.GetMergedConfig(), CreatedAt: t.CreatedAt.Format(time.RFC3339), UpdatedAt: t.UpdatedAt.Format(time.RFC3339), } diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 920a87d4..67afe9da 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -46,6 +46,11 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"}) } + // Set tenant_id for audit logging if namespace is Tenant + if namespace == "Tenant" { + c.Locals("tenant_id", objectID) + } + // Check with Keto allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) if err != nil || !allowed { diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 5990e48b..644ffc1a 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -14,6 +14,7 @@ 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 } @@ -41,6 +42,17 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten return &tenant, nil } +func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { + var tenants []domain.Tenant + if len(ids) == 0 { + return tenants, nil + } + if err := r.db.WithContext(ctx).Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil { + return nil, err + } + return tenants, nil +} + func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { var tenant domain.Tenant if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil { diff --git a/backend/internal/service/keto_service.go b/backend/internal/service/keto_service.go index 9303485b..4b329eae 100644 --- a/backend/internal/service/keto_service.go +++ b/backend/internal/service/keto_service.go @@ -18,6 +18,7 @@ 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 { @@ -192,3 +193,40 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) return nil } + +func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL)) + q := u.Query() + q.Set("namespace", namespace) + q.Set("relation", relation) + q.Set("subject_id", subject) + u.RawQuery = q.Encode() + + req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body)) + } + + var res relationTuplesResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + objects := make([]string, 0, len(res.RelationTuples)) + seen := make(map[string]bool) + for _, rt := range res.RelationTuples { + if !seen[rt.Object] { + objects = append(objects, rt.Object) + seen[rt.Object] = true + } + } + + return objects, nil +} diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 24b693ef..636918b7 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -15,6 +15,7 @@ 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) } type relyingPartyService struct { @@ -158,6 +159,10 @@ 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) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty { if client == nil { return nil diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 417d0b97..c6a5d954 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -18,6 +18,7 @@ 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) // 추가 } @@ -39,6 +40,60 @@ 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 {