diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index c499eb39..cf86510a 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -156,11 +156,26 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([] } func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil } func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { - return nil, 0, nil + results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents)) + for _, consent := range m.consents { + if consent.ClientID == clientID { + results = append(results, domain.ClientConsentWithTenantInfo{ClientConsent: consent}) + } + } + return results, int64(len(results)), nil } func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { - return nil, 0, nil + results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents)) + for _, consent := range m.consents { + if consent.ClientID == clientID { + results = append(results, domain.ClientConsentWithTenantInfo{ + ClientConsent: consent, + TenantID: tenantID, + }) + } + } + return results, int64(len(results)), nil } // --- Mock Secret Repository --- diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2507e6a9..08f3faea 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -205,6 +205,7 @@ var allowedRelyingPartyOperatorRelations = map[string]struct{}{ "consent_viewer": {}, "consent_revoker": {}, "relationship_viewer": {}, + "audit_viewer": {}, "status_operator": {}, } @@ -221,6 +222,15 @@ func isDevConsoleRoleAllowed(role string) bool { } } +func isDevConsoleViewerRole(role string) bool { + switch normalizeUserRole(role) { + case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser: + return true + default: + return false + } +} + func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse { if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { return profile @@ -388,6 +398,51 @@ func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.User return canAccessClientByLegacyScope(profile, summary) } +func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} { + ids := make(map[string]struct{}) + if profile == nil || h.Hydra == nil { + return ids + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return ids + } + + clientFilter = strings.TrimSpace(clientFilter) + if clientFilter != "" { + summary, err := h.loadClientSummary(c.Context(), clientFilter) + if err == nil && h.canOperateClientByPermit(c, profile, summary, "view_audit_logs") { + ids[summary.ID] = struct{}{} + } + return ids + } + + clients, err := h.Hydra.ListClients(c.Context(), 500, 0) + if err != nil { + slog.Warn("Failed to list clients for audit permission filtering", "error", err) + return ids + } + for _, client := range clients { + if isHiddenSystemClient(client) { + continue + } + summary := h.mapClientSummary(client) + if h.canOperateClientByPermit(c, profile, summary, "view_audit_logs") { + ids[summary.ID] = struct{}{} + } + } + return ids +} + +func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[string]struct{} { + if dst == nil { + dst = make(map[string]struct{}, len(src)) + } + for key := range src { + dst[key] = struct{}{} + } + return dst +} + func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false @@ -938,7 +993,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -972,14 +1027,16 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { summary := h.mapClientSummary(client) // 1. [Security] Filter out 'private' clients if user is not an AppManager - if summary.Type == "private" && !isAppManager { + canViewByPermit := h.canViewClientByPermit(c, profile, summary) + + if summary.Type == "private" && !isAppManager && !canViewByPermit { continue } // 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant if !isSuperAdmin { clientTenantID, _ := summary.Metadata["tenant_id"].(string) - if clientTenantID != userTenantID && !h.canViewClientByPermit(c, profile, summary) { + if clientTenantID != userTenantID && !canViewByPermit { continue } } @@ -987,13 +1044,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { // 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 { if _, ok := allowedClientIDs[summary.ID]; !ok { - if !h.canViewClientByPermit(c, profile, summary) { + if !canViewByPermit { continue } } } - if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { + if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit { continue } @@ -1163,7 +1220,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -1172,7 +1229,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { } // Check permission for private clients - if summary.Type == "private" { + if summary.Type == "private" && !h.canViewClientByPermit(c, profile, summary) { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") @@ -1730,7 +1787,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } client, err := h.Hydra.GetClient(c.Context(), clientID) @@ -1741,7 +1798,8 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*client) - if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "view_consents") { + canViewConsentsByPermit := h.canOperateClientByPermit(c, profile, summary, "view_consents") + if !canAccessClientByLegacyScope(profile, summary) && !canViewConsentsByPermit { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1755,7 +1813,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { // [Isolation] Get admin tenant ID from locals or header adminTenantID := "" if profile != nil { - if role != domain.RoleSuperAdmin && profile.TenantID != nil { + if role != domain.RoleSuperAdmin && !canViewConsentsByPermit && profile.TenantID != nil { adminTenantID = *profile.TenantID } } @@ -1813,12 +1871,14 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { } userName := "" - identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) - if err == nil && identity != nil { - if name, ok := identity.Traits["name"].(string); ok { - userName = name - } else if email, ok := identity.Traits["email"].(string); ok { - userName = email + if h.KratosAdmin != nil { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) + if err == nil && identity != nil { + if name, ok := identity.Traits["name"].(string); ok { + userName = name + } else if email, ok := identity.Traits["email"].(string); ok { + userName = email + } } } @@ -1854,7 +1914,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if clientID != "" { @@ -2123,13 +2183,9 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - allowedClientIDs := managedClientIDsFromProfile(profile) - if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients") - } limit := c.QueryInt("limit", 50) if limit <= 0 { @@ -2142,11 +2198,21 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action"))) clientFilter := strings.TrimSpace(c.Query("client_id")) statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status"))) + allowedClientIDs := managedClientIDsFromProfile(profile) + allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter)) + if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == domain.RoleRPAdmin || role == domain.RoleUser) { + return c.JSON(devAuditListResponse{ + Items: []domain.AuditLog{}, + Limit: limit, + Cursor: c.Query("cursor"), + }) + } + tenantFilter := strings.TrimSpace(c.Query("tenant_id")) if tenantFilter == "" { tenantFilter = h.resolveDevTenantScope(c) } - if role != domain.RoleSuperAdmin && tenantFilter == "" { + if role != domain.RoleSuperAdmin && tenantFilter == "" && len(allowedClientIDs) == 0 { tenantFilter = tenantIDFromProfile(profile) } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 0ef00aea..ce3315bb 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -229,6 +229,54 @@ func TestListClients_Success(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}}, + {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + tenantID := "tenant-a" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/clients", h.ListClients) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result clientListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "client-allowed", result.Items[0].ID) + } + mockKeto.AssertExpectations(t) +} + func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { t.Fatalf("hydra should not be called when reserved system name is rejected") @@ -1310,6 +1358,133 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) { assert.Equal(t, "evt-1", result.Items[0].EventID) } +func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) { + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + EventID: "evt-allowed", + EventType: "POST /api/v1/dev/clients/client-allowed/secret/rotate", + Status: "success", + Timestamp: time.Now().UTC(), + Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"ROTATE_SECRET"}`, + }, + { + EventID: "evt-denied", + EventType: "POST /api/v1/dev/clients/client-denied/secret/rotate", + Status: "success", + Timestamp: time.Now().UTC().Add(-time.Minute), + Details: `{"target_id":"client-denied","tenant_id":"tenant-b","action":"ROTATE_SECRET"}`, + }, + }, + } + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, + {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view_audit_logs").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view_audit_logs").Return(false, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + AuditRepo: auditRepo, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result devAuditListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "evt-allowed", result.Items[0].EventID) + } + mockKeto.AssertExpectations(t) +} + +func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]any{ + "tenant_id": "tenant-1", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_consents").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + ConsentRepo: &mockConsentRepo{ + consents: []domain.ClientConsent{ + { + ClientID: "client-1", + Subject: "subject-1", + GrantedScopes: []string{"openid", "profile"}, + CreatedAt: time.Now().UTC(), + }, + }, + }, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-1" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/consents", h.ListConsents) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result consentListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "client-1", result.Items[0].ClientID) + assert.Equal(t, "subject-1", result.Items[0].Subject) + } + mockKeto.AssertExpectations(t) +} + func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -1330,7 +1505,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{ {Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"}, }, nil) - for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} { + for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} { mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) } mockKratos := new(devMockKratosAdmin) diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 26b0ef02..152e321b 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -35,6 +35,7 @@ var defaultRelyingPartyOperatorRelations = []string{ "consent_viewer", "consent_revoker", "relationship_viewer", + "audit_viewer", "status_operator", } diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 4e0eb33b..9dcaa72c 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -270,11 +270,6 @@ function AppLayout() { ); const displayRoleKey = profile?.role || currentRole; - const isDevConsoleAllowed = [ - "super_admin", - "tenant_admin", - "rp_admin", - ].includes(currentRole); const expiresAtSec = auth.user?.expires_at; const remainingMs = typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null; @@ -360,24 +355,23 @@ function AppLayout() {
- {isDevConsoleAllowed && - navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( - - [ - "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", - isActive - ? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]" - : "text-muted-foreground hover:bg-muted/10 hover:text-foreground", - ].join(" ") - } - > - - {t(labelKey, labelFallback)} - - ))} + {navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( + + [ + "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", + isActive + ? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]" + : "text-muted-foreground hover:bg-muted/10 hover:text-foreground", + ].join(" ") + } + > + + {t(labelKey, labelFallback)} + + ))}
diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx index 0ab7c9fd..26069583 100644 --- a/devfront/src/features/auth/AuthGuard.tsx +++ b/devfront/src/features/auth/AuthGuard.tsx @@ -1,7 +1,5 @@ import { useAuth } from "react-oidc-context"; import { Navigate, Outlet } from "react-router-dom"; -import { t } from "../../lib/i18n"; -import { resolveProfileRole } from "../../lib/role"; export default function AuthGuard() { const auth = useAuth(); @@ -18,39 +16,5 @@ export default function AuthGuard() { return ; } - const normalizedRole = resolveProfileRole( - auth.user?.profile as Record | undefined, - ); - const isTenantMember = - normalizedRole === "user" || normalizedRole === "tenant_member"; - - if (isTenantMember) { - return ( -
-
-

- {t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")} -

-

- {t( - "msg.dev.auth.access_denied_description", - "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요.", - )} -

- -
-
- ); - } - return ; } diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index a6813fd0..d47cd449 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -38,12 +38,17 @@ import { } from "../../components/ui/table"; import { fetchClients, fetchDevStats } from "../../lib/devApi"; import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); const hasAccessToken = Boolean(auth.user?.access_token); + const role = resolveProfileRole( + auth.user?.profile as Record | undefined, + ); + const canCreateClient = role !== "user" && role !== "tenant_member"; const { data, @@ -168,16 +173,18 @@ function ClientsPage() { )} -
- -
+ {canCreateClient && ( +
+ +
+ )}
@@ -217,7 +224,7 @@ function ClientsPage() { )} - {t("ui.dev.clients.badge.admin_session", "관리자 세션")} + {t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
@@ -319,12 +326,14 @@ function ClientsPage() { {t("ui.dev.clients.list.title", "클라이언트 목록")} -
- -
+ {canCreateClient && ( +
+ +
+ )} @@ -350,6 +359,29 @@ function ClientsPage() { + {filteredClients.length === 0 && ( + + +
+

+ {t( + "msg.dev.clients.empty", + "조회 가능한 RP가 없습니다.", + )} +

+

+ {t( + "msg.dev.clients.empty_detail", + "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", + )} +

+
+
+
+ )} {filteredClients.map((client) => ( diff --git a/devfront/tests/devfront-role-switch-report.spec.ts b/devfront/tests/devfront-role-switch-report.spec.ts index 633e6b89..7b0066a3 100644 --- a/devfront/tests/devfront-role-switch-report.spec.ts +++ b/devfront/tests/devfront-role-switch-report.spec.ts @@ -17,7 +17,7 @@ test.describe("DevFront role report", () => { }); }); - test("user(tenant_member) is blocked with 안내 문구", async ({ + test("user(tenant_member) can enter and sees empty RP list", async ({ page, }, testInfo) => { await seedAuth(page, "user"); @@ -29,9 +29,12 @@ test.describe("DevFront role report", () => { await page.goto("/clients"); await expect( - page.getByText(/관리자 전용 화면|administrator only/i), + page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), ).toBeVisible(); - await captureEvidence(page, testInfo, "role-user-blocked"); + await expect( + page.getByText(/연동 앱|Connected Application/i), + ).toBeVisible(); + await captureEvidence(page, testInfo, "role-user-empty-rps"); }); test("rp_admin sees only assigned Gitea app and its logs", async ({ diff --git a/devfront/tests/devfront-security.spec.ts b/devfront/tests/devfront-security.spec.ts index 0e451a59..b993e557 100644 --- a/devfront/tests/devfront-security.spec.ts +++ b/devfront/tests/devfront-security.spec.ts @@ -59,14 +59,25 @@ test.describe("DevFront security and isolation", () => { await expect(page.getByText("Server side App")).not.toBeVisible(); }); - test("tenant_member user is blocked at AuthGuard", async ({ page }) => { + test("tenant_member user can enter DevFront and sees empty RP list", async ({ + page, + }) => { await seedAuth(page, "tenant_member"); + const state = { + clients: [] as ReturnType[], + consents: [] as Consent[], + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); await page.goto("/clients"); - await expect( - page.getByText(/DevFront는 관리자 전용 화면입니다|administrator access/i), - ).toBeVisible(); await expect(page).toHaveURL(/\/clients$/); + await expect( + page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i }), + ).not.toBeVisible(); }); test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({ diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index ce7970c6..2e142757 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -69,6 +69,7 @@ class RelyingParty implements Namespace { consent_viewer: (User | SubjectSet)[] consent_revoker: (User | SubjectSet)[] relationship_viewer: (User | SubjectSet)[] + audit_viewer: (User | SubjectSet)[] status_operator: (User | SubjectSet)[] } @@ -82,6 +83,7 @@ class RelyingParty implements Namespace { this.related.consent_viewer.includes(ctx.subject) || this.related.consent_revoker.includes(ctx.subject) || this.related.relationship_viewer.includes(ctx.subject) || + this.related.audit_viewer.includes(ctx.subject) || this.related.status_operator.includes(ctx.subject) || this.related.parents.traverse((t) => t.permits.view(ctx)) || this.related.parents.traverse((t) => t.permits.view_dev_console(ctx)), @@ -126,6 +128,10 @@ class RelyingParty implements Namespace { this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) || this.permits.manage(ctx), + view_audit_logs: (ctx: Context): boolean => + this.related.audit_viewer.includes(ctx.subject) || + this.permits.manage(ctx), + change_status: (ctx: Context): boolean => this.related.status_operator.includes(ctx.subject) || this.permits.manage(ctx),