forked from baron/baron-sso
fix: ensure all manageable tenants are visible for tenant admin and refine overview UI
This commit is contained in:
@@ -20,41 +20,6 @@ import { t } from "../../lib/i18n";
|
|||||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||||
import PermissionChecker from "./components/PermissionChecker";
|
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() {
|
function GlobalOverviewPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -73,42 +38,79 @@ function GlobalOverviewPage() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<RoleGuard roles={["super_admin"]}>
|
||||||
<Badge variant="muted">
|
<div className="flex items-center gap-2">
|
||||||
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
|
<Badge variant="muted">
|
||||||
</Badge>
|
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
|
||||||
<Badge variant="muted">
|
</Badge>
|
||||||
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
|
<Badge variant="muted">
|
||||||
</Badge>
|
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
|
||||||
</div>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</RoleGuard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{summaryCards.map(
|
<RoleGuard roles={["super_admin"]}>
|
||||||
({
|
<Card className="bg-[var(--color-panel)]">
|
||||||
labelKey,
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
labelFallback,
|
<CardDescription>{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}</CardDescription>
|
||||||
value,
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
hintKey,
|
<Users size={16} />
|
||||||
hintFallback,
|
</div>
|
||||||
icon: Icon,
|
</CardHeader>
|
||||||
}) => (
|
<CardContent>
|
||||||
<Card key={labelKey} className="bg-[var(--color-panel)]">
|
<div className="text-2xl font-semibold">-</div>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
<CardDescription>{t(labelKey, labelFallback)}</CardDescription>
|
{t("msg.admin.overview.summary.total_tenants", "Tenant-aware core")}
|
||||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
</p>
|
||||||
<Icon size={16} />
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardHeader>
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardContent>
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<div className="text-2xl font-semibold">{value}</div>
|
<CardDescription>{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}</CardDescription>
|
||||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
{t(hintKey, hintFallback)}
|
<ShieldCheck size={16} />
|
||||||
</p>
|
</div>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
<CardContent>
|
||||||
),
|
<div className="text-2xl font-semibold">-</div>
|
||||||
)}
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
|
{t("msg.admin.overview.summary.oidc_clients", "Hydra registry")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</RoleGuard>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardDescription>{t("ui.admin.overview.summary.audit_events_24h", "Audit Events (24h)")}</CardDescription>
|
||||||
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
|
<Activity size={16} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">-</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
|
{t("msg.admin.overview.summary.audit_events_24h", "ClickHouse stream")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardDescription>{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}</CardDescription>
|
||||||
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
|
<Database size={16} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">Planned</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
|
{t("msg.admin.overview.summary.policy_gate", "Keto + Admin checks")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
||||||
|
|||||||
@@ -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)
|
// 4. Save to Redis Cache (Short TTL)
|
||||||
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key
|
// 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.
|
// or we should include the mock role in the cache key.
|
||||||
|
|||||||
@@ -53,36 +53,25 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
|||||||
return nil, errors.New("keto service not initialized")
|
return nil, errors.New("keto service not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
|
// [Keto] 'Tenant' 네임스페이스에서 'manage' 권한을 가진 모든 테넌트 ID 조회
|
||||||
directAdminIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
// OPL(parents 상속 포함) 결과가 반영된 리스트를 가져옵니다.
|
||||||
|
allIDs, err := s.keto.ListObjects(ctx, "Tenant", "manage", "User:"+userID)
|
||||||
if err != nil {
|
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)
|
if len(allIDs) == 0 {
|
||||||
directOwnerIDs, err := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
// Fallback: Check direct membership if list objects didn't catch everything
|
||||||
if err != nil {
|
directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||||
slog.Error("Failed to list owned tenants", "userID", userID, "error", err)
|
directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||||
}
|
|
||||||
|
|
||||||
// 합산 및 중복 제거
|
idMap := make(map[string]bool)
|
||||||
allIDsMap := make(map[string]bool)
|
for _, id := range directAdminIDs { idMap[id] = true }
|
||||||
for _, id := range directAdminIDs {
|
for _, id := range directOwnerIDs { idMap[id] = true }
|
||||||
allIDsMap[id] = true
|
|
||||||
}
|
|
||||||
for _, id := range directOwnerIDs {
|
|
||||||
allIDsMap[id] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: 상속된 권한(부모의 어드민이 자식의 어드민)은 Keto의 OPL에서 처리되므로,
|
allIDs = make([]string, 0, len(idMap))
|
||||||
// 특정 유저가 'view' 또는 'manage' 권한을 가진 테넌트를 모두 찾으려면
|
for id := range idMap { allIDs = append(allIDs, id) }
|
||||||
// Keto의 'expand' 또는 'list objects' 기능을 더 고도화하거나,
|
|
||||||
// 여기서는 직접 할당된 부모 테넌트를 기준으로 하위 테넌트 정보를 추가 조회하는 로직이 필요할 수 있습니다.
|
|
||||||
// 우선 직접 할당된 테넌트들만 반환합니다.
|
|
||||||
|
|
||||||
allIDs := make([]string, 0, len(allIDsMap))
|
|
||||||
for id := range allIDsMap {
|
|
||||||
allIDs = append(allIDs, id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(allIDs) == 0 {
|
if len(allIDs) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user