diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 57d5645a..fabc6639 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -513,8 +513,10 @@ func main() { api.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, ExcludePaths: map[string]struct{}{ - "/api/v1/audit": {}, - "/api/v1/client-log": {}, + "/api/v1/audit": {}, + "/api/v1/audit/auth/timeline": {}, + "/api/v1/client-log": {}, + "/api/v1/dev/audit-logs": {}, }, BodyDump: true, WorkerCount: workerCount, @@ -604,14 +606,14 @@ func main() { KetoService: ketoService, }) requireAdmin := middleware.RequireRole(middleware.RBACConfig{ - AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, - AuthHandler: authHandler, - KetoService: ketoService, + AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, + AuthHandler: authHandler, + KetoService: ketoService, }) requireAnyUser := middleware.RequireRole(middleware.RBACConfig{ - AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser}, - AuthHandler: authHandler, - KetoService: ketoService, + AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser}, + AuthHandler: authHandler, + KetoService: ketoService, }) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) @@ -640,7 +642,7 @@ func main() { // Organization & Org-Chart Management (Tenant Admin/Super Admin) org := admin.Group("/tenants/:tenantId/organization") - org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API + org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List) diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index a0c5c6fe..f7baafad 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -17,6 +17,7 @@ import ( type AuditConfig struct { Repo domain.AuditRepository ExcludePaths map[string]struct{} + ReadPaths map[string]struct{} BodyDump bool WorkerCount int QueueSize int @@ -30,9 +31,8 @@ func isNil(i any) bool { return v.Kind() == reflect.Ptr && v.IsNil() } -// AuditMiddleware provides comprehensive audit logging for all requests. -// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH) -// and best-effort logging for queries (GET, HEAD, OPTIONS). +// AuditMiddleware provides comprehensive audit logging for write requests by default. +// Read requests are skipped unless they are explicitly allowlisted in ReadPaths. func AuditMiddleware(config AuditConfig) fiber.Handler { // 0. Initialize Worker Pool for Async Logging if config.WorkerCount <= 0 { @@ -77,6 +77,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { if config.ExcludePaths == nil { config.ExcludePaths = map[string]struct{}{} } + if config.ReadPaths == nil { + config.ReadPaths = map[string]struct{}{} + } return func(c *fiber.Ctx) error { // 1. Check exclusions @@ -186,6 +189,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { // 9. Store Log (Policy Enforcement) _, isWrite := writeMethods[c.Method()] + _, allowRead := config.ReadPaths[c.Path()] if isNil(config.Repo) { if isWrite { @@ -200,7 +204,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { slog.Error("Failed to write audit log (sync)", "error", createErr, "req_id", reqID) return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed") } - } else { + } else if allowRead { // Best Effort: Load Shedding via Buffered Channel select { case auditQueue <- auditLog: diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index d553ad40..59031fe3 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -7,6 +7,7 @@ import ( "errors" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -48,6 +49,44 @@ func (m *MockAuditRepository) Ping(ctx context.Context) error { return args.Error(0) } +type recordingAuditRepository struct { + mu sync.Mutex + count int +} + +func (r *recordingAuditRepository) Create(log *domain.AuditLog) error { + r.mu.Lock() + defer r.mu.Unlock() + r.count++ + return nil +} + +func (r *recordingAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) { + return nil, nil +} + +func (r *recordingAuditRepository) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) { + return nil, nil +} + +func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { + return 0, nil +} + +func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { + return 0, nil +} + +func (r *recordingAuditRepository) Ping(ctx context.Context) error { + return nil +} + +func (r *recordingAuditRepository) Calls() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.count +} + func TestAuditMiddleware(t *testing.T) { t.Run("POST request - Sync Success", func(t *testing.T) { app := fiber.New() @@ -191,4 +230,24 @@ func TestAuditMiddleware(t *testing.T) { resp2, _ := app.Test(req2) assert.Equal(t, fiber.StatusOK, resp2.StatusCode) }) + + t.Run("GET request - Read audit is skipped by default", func(t *testing.T) { + app := fiber.New() + repo := &recordingAuditRepository{} + + app.Use(AuditMiddleware(AuditConfig{ + Repo: repo, + })) + + app.Get("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("GET", "/test", nil) + resp, _ := app.Test(req) + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 0, repo.Calls()) + }) } diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d9d7f8fb..07682403 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -7,7 +6,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../domain/linked_rp_launch.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; @@ -16,7 +14,6 @@ import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/logout_service.dart'; import '../../../../core/services/auth_token_store.dart'; -import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../../../core/widgets/theme_toggle_button.dart'; @@ -54,10 +51,6 @@ class _DashboardScreenState extends ConsumerState { final ScrollController _pageScrollController = ScrollController(); final ScrollController _rpScrollController = ScrollController(); - final List _auditLogs = []; - String? _auditNextCursor; - bool _auditLoading = false; - bool _auditLoadingMore = false; bool _isRevoking = false; String? _revokingSessionId; bool _redirectingToSignin = false; @@ -559,95 +552,9 @@ class _DashboardScreenState extends ConsumerState { ref.read(authTimelineProvider.notifier).refresh(), ]); - await _loadAuditLogs(reset: true); await ref.read(linkedRpsProvider.notifier).refresh(); } - static String _envOrDefault(String key, String fallback) { - if (!dotenv.isInitialized) { - return fallback; - } - return dotenv.env[key] ?? fallback; - } - - Future _fetchAuditLogs({String? cursor}) async { - final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); - final queryParameters = {'limit': '20'}; - if (cursor != null && cursor.isNotEmpty) { - queryParameters['cursor'] = cursor; - } - final url = Uri.parse( - '$baseUrl/api/v1/audit/auth/timeline', - ).replace(queryParameters: queryParameters); - final useCookie = AuthTokenStore.usesCookie(); - final token = AuthTokenStore.getToken(); - - final client = createHttpClient(withCredentials: useCookie); - final headers = {'Content-Type': 'application/json'}; - if (!useCookie && token != null) { - headers['Authorization'] = 'Bearer $token'; - } - - try { - final response = await client.get(url, headers: headers); - if (response.statusCode != 200) { - throw Exception('Failed to load audit logs'); - } - - final body = jsonDecode(response.body) as Map; - final items = (body['items'] as List?) ?? []; - final nextCursor = body['next_cursor']?.toString(); - final logs = items - .whereType>() - .map(AuditLogEntry.fromJson) - .toList(); - - return AuditPage(items: logs, nextCursor: nextCursor); - } finally { - client.close(); - } - } - - Future _loadAuditLogs({bool reset = false}) async { - if (!_isLoggedIn()) { - return; - } - if (_auditLoading || _auditLoadingMore) { - return; - } - final nextCursor = _auditNextCursor; - if (!reset && (nextCursor == null || nextCursor.isEmpty)) { - return; - } - - if (reset) { - setState(() { - _auditLogs.clear(); - _auditNextCursor = null; - _auditLoading = true; - }); - } else { - setState(() { - _auditLoadingMore = true; - }); - } - - try { - final page = await _fetchAuditLogs(cursor: _auditNextCursor); - setState(() { - _auditLogs.addAll(page.items); - _auditNextCursor = page.nextCursor; - }); - } catch (_) { - // 에러는 상위 UI에서 재시도 UX로 처리합니다. - } finally { - setState(() { - _auditLoading = false; - _auditLoadingMore = false; - }); - } - } - String _formatDateTime(DateTime dateTime) { final yyyy = dateTime.year.toString().padLeft(4, '0'); final mm = dateTime.month.toString().padLeft(2, '0'); @@ -2539,7 +2446,6 @@ class _DashboardScreenState extends ConsumerState { _redirectToSignin(); return; } - await _loadAuditLogs(reset: true); } finally { _authBootstrapInProgress = false; } diff --git a/userfront/test/dashboard_timeline_dedup_test.dart b/userfront/test/dashboard_timeline_dedup_test.dart new file mode 100644 index 00000000..bed1d1f6 --- /dev/null +++ b/userfront/test/dashboard_timeline_dedup_test.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('대시보드 화면은 auth timeline fetch 구현을 직접 가지지 않는다', () async { + final screenFile = File( + 'lib/features/dashboard/presentation/dashboard_screen.dart', + ); + final source = await screenFile.readAsString(); + + expect(source.contains('_fetchAuditLogs('), isFalse); + expect(source.contains('_loadAuditLogs('), isFalse); + expect(source.contains('/api/v1/audit/auth/timeline'), isFalse); + }); +}