forked from baron/baron-sso
fix(audit): stop default read logging and dedupe dashboard timeline
- skip read audit logging unless a path is explicitly allowlisted - exclude audit-facing endpoints from backend audit collection - remove duplicate auth timeline fetch logic from dashboard screen - add regression tests for default GET skip and dashboard timeline dedup Co-Authored-By: First Fluke <our.first.fluke@gmail.com>
This commit is contained in:
@@ -514,7 +514,9 @@ func main() {
|
|||||||
Repo: auditRepo,
|
Repo: auditRepo,
|
||||||
ExcludePaths: map[string]struct{}{
|
ExcludePaths: map[string]struct{}{
|
||||||
"/api/v1/audit": {},
|
"/api/v1/audit": {},
|
||||||
|
"/api/v1/audit/auth/timeline": {},
|
||||||
"/api/v1/client-log": {},
|
"/api/v1/client-log": {},
|
||||||
|
"/api/v1/dev/audit-logs": {},
|
||||||
},
|
},
|
||||||
BodyDump: true,
|
BodyDump: true,
|
||||||
WorkerCount: workerCount,
|
WorkerCount: workerCount,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type AuditConfig struct {
|
type AuditConfig struct {
|
||||||
Repo domain.AuditRepository
|
Repo domain.AuditRepository
|
||||||
ExcludePaths map[string]struct{}
|
ExcludePaths map[string]struct{}
|
||||||
|
ReadPaths map[string]struct{}
|
||||||
BodyDump bool
|
BodyDump bool
|
||||||
WorkerCount int
|
WorkerCount int
|
||||||
QueueSize int
|
QueueSize int
|
||||||
@@ -30,9 +31,8 @@ func isNil(i any) bool {
|
|||||||
return v.Kind() == reflect.Ptr && v.IsNil()
|
return v.Kind() == reflect.Ptr && v.IsNil()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditMiddleware provides comprehensive audit logging for all requests.
|
// AuditMiddleware provides comprehensive audit logging for write requests by default.
|
||||||
// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH)
|
// Read requests are skipped unless they are explicitly allowlisted in ReadPaths.
|
||||||
// and best-effort logging for queries (GET, HEAD, OPTIONS).
|
|
||||||
func AuditMiddleware(config AuditConfig) fiber.Handler {
|
func AuditMiddleware(config AuditConfig) fiber.Handler {
|
||||||
// 0. Initialize Worker Pool for Async Logging
|
// 0. Initialize Worker Pool for Async Logging
|
||||||
if config.WorkerCount <= 0 {
|
if config.WorkerCount <= 0 {
|
||||||
@@ -77,6 +77,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
if config.ExcludePaths == nil {
|
if config.ExcludePaths == nil {
|
||||||
config.ExcludePaths = map[string]struct{}{}
|
config.ExcludePaths = map[string]struct{}{}
|
||||||
}
|
}
|
||||||
|
if config.ReadPaths == nil {
|
||||||
|
config.ReadPaths = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
// 1. Check exclusions
|
// 1. Check exclusions
|
||||||
@@ -186,6 +189,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
|
|
||||||
// 9. Store Log (Policy Enforcement)
|
// 9. Store Log (Policy Enforcement)
|
||||||
_, isWrite := writeMethods[c.Method()]
|
_, isWrite := writeMethods[c.Method()]
|
||||||
|
_, allowRead := config.ReadPaths[c.Path()]
|
||||||
|
|
||||||
if isNil(config.Repo) {
|
if isNil(config.Repo) {
|
||||||
if isWrite {
|
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)
|
slog.Error("Failed to write audit log (sync)", "error", createErr, "req_id", reqID)
|
||||||
return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed")
|
return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed")
|
||||||
}
|
}
|
||||||
} else {
|
} else if allowRead {
|
||||||
// Best Effort: Load Shedding via Buffered Channel
|
// Best Effort: Load Shedding via Buffered Channel
|
||||||
select {
|
select {
|
||||||
case auditQueue <- auditLog:
|
case auditQueue <- auditLog:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,6 +49,44 @@ func (m *MockAuditRepository) Ping(ctx context.Context) error {
|
|||||||
return args.Error(0)
|
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) {
|
func TestAuditMiddleware(t *testing.T) {
|
||||||
t.Run("POST request - Sync Success", func(t *testing.T) {
|
t.Run("POST request - Sync Success", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -191,4 +230,24 @@ func TestAuditMiddleware(t *testing.T) {
|
|||||||
resp2, _ := app.Test(req2)
|
resp2, _ := app.Test(req2)
|
||||||
assert.Equal(t, fiber.StatusOK, resp2.StatusCode)
|
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())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import '../domain/linked_rp_launch.dart';
|
import '../domain/linked_rp_launch.dart';
|
||||||
import '../domain/session_time_resolver.dart';
|
import '../domain/session_time_resolver.dart';
|
||||||
import '../domain/providers/linked_rps_provider.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/auth_proxy_service.dart';
|
||||||
import '../../../../core/services/logout_service.dart';
|
import '../../../../core/services/logout_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/services/http_client.dart';
|
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
import '../../../../core/widgets/theme_toggle_button.dart';
|
import '../../../../core/widgets/theme_toggle_button.dart';
|
||||||
@@ -54,10 +51,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
final ScrollController _pageScrollController = ScrollController();
|
final ScrollController _pageScrollController = ScrollController();
|
||||||
final ScrollController _rpScrollController = ScrollController();
|
final ScrollController _rpScrollController = ScrollController();
|
||||||
final List<AuditLogEntry> _auditLogs = [];
|
|
||||||
String? _auditNextCursor;
|
|
||||||
bool _auditLoading = false;
|
|
||||||
bool _auditLoadingMore = false;
|
|
||||||
bool _isRevoking = false;
|
bool _isRevoking = false;
|
||||||
String? _revokingSessionId;
|
String? _revokingSessionId;
|
||||||
bool _redirectingToSignin = false;
|
bool _redirectingToSignin = false;
|
||||||
@@ -559,95 +552,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
ref.read(authTimelineProvider.notifier).refresh(),
|
ref.read(authTimelineProvider.notifier).refresh(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await _loadAuditLogs(reset: true);
|
|
||||||
await ref.read(linkedRpsProvider.notifier).refresh();
|
await ref.read(linkedRpsProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _envOrDefault(String key, String fallback) {
|
|
||||||
if (!dotenv.isInitialized) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return dotenv.env[key] ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
|
||||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
|
||||||
final queryParameters = <String, String>{'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 = <String, String>{'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<String, dynamic>;
|
|
||||||
final items = (body['items'] as List?) ?? [];
|
|
||||||
final nextCursor = body['next_cursor']?.toString();
|
|
||||||
final logs = items
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(AuditLogEntry.fromJson)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return AuditPage(items: logs, nextCursor: nextCursor);
|
|
||||||
} finally {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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) {
|
String _formatDateTime(DateTime dateTime) {
|
||||||
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
||||||
final mm = dateTime.month.toString().padLeft(2, '0');
|
final mm = dateTime.month.toString().padLeft(2, '0');
|
||||||
@@ -2539,7 +2446,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
_redirectToSignin();
|
_redirectToSignin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _loadAuditLogs(reset: true);
|
|
||||||
} finally {
|
} finally {
|
||||||
_authBootstrapInProgress = false;
|
_authBootstrapInProgress = false;
|
||||||
}
|
}
|
||||||
|
|||||||
16
userfront/test/dashboard_timeline_dedup_test.dart
Normal file
16
userfront/test/dashboard_timeline_dedup_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user