diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx
index 6b7d036e..3e8220c2 100644
--- a/adminfront/src/features/overview/GlobalOverviewPage.tsx
+++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx
@@ -20,41 +20,6 @@ import { t } from "../../lib/i18n";
import { RoleGuard } from "../../components/auth/RoleGuard";
import PermissionChecker from "./components/PermissionChecker";
-const summaryCards = [
- {
- labelKey: "ui.admin.overview.summary.total_tenants",
- labelFallback: "Total Tenants",
- value: "-",
- hintKey: "msg.admin.overview.summary.total_tenants",
- hintFallback: "Tenant-aware core",
- icon: Users,
- },
- {
- labelKey: "ui.admin.overview.summary.oidc_clients",
- labelFallback: "OIDC Clients",
- value: "-",
- hintKey: "msg.admin.overview.summary.oidc_clients",
- hintFallback: "Hydra registry",
- icon: ShieldCheck,
- },
- {
- labelKey: "ui.admin.overview.summary.audit_events_24h",
- labelFallback: "Audit Events (24h)",
- value: "-",
- hintKey: "msg.admin.overview.summary.audit_events_24h",
- hintFallback: "ClickHouse stream",
- icon: Activity,
- },
- {
- labelKey: "ui.admin.overview.summary.policy_gate",
- labelFallback: "Policy Gate",
- value: "Planned",
- hintKey: "msg.admin.overview.summary.policy_gate",
- hintFallback: "Keto + Admin checks",
- icon: Database,
- },
-];
-
function GlobalOverviewPage() {
return (
@@ -73,42 +38,79 @@ function GlobalOverviewPage() {
)}
-
-
- {t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
-
-
- {t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
-
-
+
+
+
+ {t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
+
+
+ {t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
+
+
+
- {summaryCards.map(
- ({
- labelKey,
- labelFallback,
- value,
- hintKey,
- hintFallback,
- icon: Icon,
- }) => (
-
-
- {t(labelKey, labelFallback)}
-
-
-
-
-
- {value}
-
- {t(hintKey, hintFallback)}
-
-
-
- ),
- )}
+
+
+
+ {t("ui.admin.overview.summary.total_tenants", "Total Tenants")}
+
+
+
+
+
+ -
+
+ {t("msg.admin.overview.summary.total_tenants", "Tenant-aware core")}
+
+
+
+
+
+ {t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}
+
+
+
+
+
+ -
+
+ {t("msg.admin.overview.summary.oidc_clients", "Hydra registry")}
+
+
+
+
+
+
+
+ {t("ui.admin.overview.summary.audit_events_24h", "Audit Events (24h)")}
+
+
+
+ -
+
+ {t("msg.admin.overview.summary.audit_events_24h", "ClickHouse stream")}
+
+
+
+
+
+
+ {t("ui.admin.overview.summary.policy_gate", "Policy Gate")}
+
+
+
+
+
+ Planned
+
+ {t("msg.admin.overview.summary.policy_gate", "Keto + Admin checks")}
+
+
+
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index d0d63baa..47354d0f 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -4046,6 +4046,14 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
}
}
+ // [New] Fetch manageable tenants for Tenant Admin
+ if profile.Role == domain.RoleTenantAdmin && h.TenantService != nil {
+ manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
+ if err == nil {
+ profile.ManageableTenants = manageable
+ }
+ }
+
// 4. Save to Redis Cache (Short TTL)
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key
// or we should include the mock role in the cache key.
diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go
index ff104c91..3c7e06c5 100644
--- a/backend/internal/service/tenant_service.go
+++ b/backend/internal/service/tenant_service.go
@@ -53,36 +53,25 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return nil, errors.New("keto service not initialized")
}
- // 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
- directAdminIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
+ // [Keto] 'Tenant' 네임스페이스에서 'manage' 권한을 가진 모든 테넌트 ID 조회
+ // OPL(parents 상속 포함) 결과가 반영된 리스트를 가져옵니다.
+ allIDs, err := s.keto.ListObjects(ctx, "Tenant", "manage", "User:"+userID)
if err != nil {
- slog.Error("Failed to list direct admin tenants", "userID", userID, "error", err)
+ slog.Error("Failed to list manageable tenants from Keto", "userID", userID, "error", err)
+ return []domain.Tenant{}, nil
}
- // 2. 직접 소유자(조직장)인 테넌트 ID 목록 (Tenant:ID#owners@User:ID)
- directOwnerIDs, err := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
- if err != nil {
- slog.Error("Failed to list owned tenants", "userID", userID, "error", err)
- }
-
- // 합산 및 중복 제거
- allIDsMap := make(map[string]bool)
- for _, id := range directAdminIDs {
- allIDsMap[id] = true
- }
- for _, id := range directOwnerIDs {
- allIDsMap[id] = true
- }
-
- // Note: 상속된 권한(부모의 어드민이 자식의 어드민)은 Keto의 OPL에서 처리되므로,
- // 특정 유저가 'view' 또는 'manage' 권한을 가진 테넌트를 모두 찾으려면
- // Keto의 'expand' 또는 'list objects' 기능을 더 고도화하거나,
- // 여기서는 직접 할당된 부모 테넌트를 기준으로 하위 테넌트 정보를 추가 조회하는 로직이 필요할 수 있습니다.
- // 우선 직접 할당된 테넌트들만 반환합니다.
-
- allIDs := make([]string, 0, len(allIDsMap))
- for id := range allIDsMap {
- allIDs = append(allIDs, id)
+ if len(allIDs) == 0 {
+ // Fallback: Check direct membership if list objects didn't catch everything
+ directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
+ directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
+
+ idMap := make(map[string]bool)
+ for _, id := range directAdminIDs { idMap[id] = true }
+ for _, id := range directOwnerIDs { idMap[id] = true }
+
+ allIDs = make([]string, 0, len(idMap))
+ for id := range idMap { allIDs = append(allIDs, id) }
}
if len(allIDs) == 0 {