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 {