1
0
forked from baron/baron-sso

feat: 테넌트 그룹 기반 권한 상속 고도화 및 개발자 포털 보안 강화 #239

This commit is contained in:
2026-02-11 12:41:03 +09:00
parent dc0d1a8e63
commit afaac1781c
11 changed files with 282 additions and 34 deletions

View File

@@ -259,7 +259,7 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo) auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler() adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService) tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
@@ -619,14 +619,24 @@ func main() {
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정) // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
dev := api.Group("/dev") dev := api.Group("/dev", requireAdmin)
dev.Get("/clients", devHandler.ListClients) dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient) dev.Post("/clients", devHandler.CreateClient)
dev.Get("/clients/:id", devHandler.GetClient) dev.Get("/clients/:id",
dev.Put("/clients/:id", devHandler.UpdateClient) middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret) devHandler.GetClient)
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) dev.Put("/clients/:id",
dev.Delete("/clients/:id", devHandler.DeleteClient) 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.Get("/consents", devHandler.ListConsents)
dev.Delete("/consents", devHandler.RevokeConsents) dev.Delete("/consents", devHandler.RevokeConsents)

View File

@@ -80,6 +80,7 @@ type UserProfileResponse struct {
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {

View File

@@ -36,6 +36,28 @@ func (t *Tenant) IsActive() bool {
return t.Status == TenantStatusActive 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. // BeforeCreate hook to generate UUID if not present.
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) { func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" { if t.ID == "" {

View File

@@ -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) // 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" { if h.RedisService != nil && cacheKey != "" {
if data, err := json.Marshal(profile); err == nil { if data, err := json.Marshal(profile); err == nil {

View File

@@ -22,15 +22,17 @@ type DevHandler struct {
SecretRepo domain.ClientSecretRepository SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService KratosAdmin *service.KratosAdminService
ConsentRepo repository.ClientConsentRepository 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{ return &DevHandler{
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
Redis: redis, Redis: redis,
SecretRepo: secretRepo, SecretRepo: secretRepo,
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
RPService: rpService,
} }
} }
@@ -95,38 +97,58 @@ type clientUpsertRequest struct {
} }
func (h *DevHandler) ListClients(c *fiber.Ctx) error { func (h *DevHandler) ListClients(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50) profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
offset := c.QueryInt("offset", 0) if !ok {
if limit <= 0 { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"})
limit = 50
}
if offset < 0 {
offset = 0
} }
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 err != nil {
if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
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(clients)) items := make([]clientSummary, 0, len(rps))
for _, client := range clients { for _, rp := range rps {
items = append(items, h.mapClientSummary(client)) // 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{ return c.JSON(clientListResponse{
Items: items, Items: items,
Limit: limit, Limit: len(items),
Offset: offset, Offset: 0,
}) })
} }
@@ -197,11 +219,46 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
} }
func (h *DevHandler) CreateClient(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 var req clientUpsertRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) 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, "")) clientID := strings.TrimSpace(valueOr(req.ID, ""))
if clientID == "" { if clientID == "" {
clientID = uuid.NewString() clientID = uuid.NewString()
@@ -257,11 +314,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
Metadata: metadata, 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 { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) 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 // Store secret in metadata for later retrieval
if created.ClientSecret != "" { if created.ClientSecret != "" {
// 1. Store in PostgreSQL (Source of Truth) // 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 { 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")) clientID := strings.TrimSpace(c.Query("client_id"))
if clientID == "" { if clientID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"}) 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")) subject := strings.TrimSpace(c.Query("subject"))
limit := c.QueryInt("limit", 50) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) 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 { 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")) subject := strings.TrimSpace(c.Query("subject"))
if subject == "" { if subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
} }
clientID := strings.TrimSpace(c.Query("client_id")) 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 subject is not a UUID, try to resolve it as an identifier (email/username)
if _, err := uuid.Parse(subject); err != nil { if _, err := uuid.Parse(subject); err != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)

View File

@@ -102,7 +102,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
} }
var tenants []domain.Tenant 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()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -341,7 +341,7 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
Status: t.Status, Status: t.Status,
TenantGroupID: t.TenantGroupID, TenantGroupID: t.TenantGroupID,
Domains: domains, Domains: domains,
Config: t.Config, Config: t.GetMergedConfig(),
CreatedAt: t.CreatedAt.Format(time.RFC3339), CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339), UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
} }

View File

@@ -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"}) 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 // Check with Keto
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed { if err != nil || !allowed {

View File

@@ -14,6 +14,7 @@ type TenantRepository interface {
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
FindByName(ctx context.Context, name string) (*domain.Tenant, error) FindByName(ctx context.Context, name string) (*domain.Tenant, error)
FindByDomain(ctx context.Context, domainName 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 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 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) { func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
var tenant domain.Tenant var tenant domain.Tenant
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil { if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {

View File

@@ -18,6 +18,7 @@ type KetoService interface {
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
DeleteRelation(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) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
} }
type ketoService struct { 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) slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
return nil 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
}

View File

@@ -15,6 +15,7 @@ type RelyingPartyService interface {
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
Delete(ctx context.Context, clientID string) error Delete(ctx context.Context, clientID string) error
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
} }
type relyingPartyService struct { type relyingPartyService struct {
@@ -158,6 +159,10 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
return nil 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 { func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
if client == nil { if client == nil {
return nil return nil

View File

@@ -18,6 +18,7 @@ type TenantService interface {
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
GetTenant(ctx context.Context, id 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 ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가 SetKetoService(keto KetoService) // 추가
} }
@@ -39,6 +40,60 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
return s.repo.FindByID(ctx, id) 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) { func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
// Validate Slug // Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok { if ok, msg := utils.ValidateSlug(slug); !ok {