diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index b0299695..a504554a 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -1,21 +1,138 @@ -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { Filter, ListChecks, Search, Terminal } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + Copy, + ListChecks, + RefreshCw, + Search, + Terminal, +} from "lucide-react"; +import * as React from "react"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; +import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; -const auditFilters = [ - "Actor role = admin", - "Action = client.rotate_secret", - "Tenant = selected header", +const defaultAuditFilters = [ + "method:POST path:/api/v1/*", + "status:failure", + "latency_ms:>1000", ]; +type AuditDetails = { + request_id?: string; + method?: string; + path?: string; + status?: number; + latency_ms?: number; + error?: string; + tenant_id?: string; + actor_id?: string; + action?: string; + target?: string; + before?: unknown; + after?: unknown; +}; + +function parseDetails(details?: string): AuditDetails { + if (!details) { + return {}; + } + try { + const parsed = JSON.parse(details); + if (parsed && typeof parsed === "object") { + return parsed as AuditDetails; + } + } catch {} + return {}; +} + +function formatCellValue(value: unknown) { + if (value === null || value === undefined || value === "") { + return "-"; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function formatIsoDateTime(value: string) { + if (!value) { + return { date: "-", time: "-" }; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return { date: value, time: "-" }; + } + const date = parsed.toISOString().slice(0, 10); + const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); + return { date, time }; +} + function AuditLogsPage() { - const { data, isLoading, error } = useQuery({ + const [filters, setFilters] = React.useState(defaultAuditFilters); + const [filterDraft, setFilterDraft] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState< + Record + >({}); + + const handleCopy = (value: string) => { + if (!value) { + return; + } + navigator.clipboard.writeText(value); + }; + const { + data, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + refetch, + } = useInfiniteQuery({ queryKey: ["audit-logs"], - queryFn: () => fetchAuditLogs(), + queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, }); - const logs = data?.items || []; + const logs = + data?.pages?.flatMap((page) => + page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], + ) ?? []; + + const handleAddFilter = () => { + const trimmed = filterDraft.trim(); + if (!trimmed) { + return; + } + setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); + setFilterDraft(""); + }; if (isLoading) { return
Loading audit logs...
; @@ -34,121 +151,307 @@ function AuditLogsPage() { return (
-
+
-

- Audit stream -

-

- Observe admin actions per tenant -

+
+ Audit + / + Logs +
+

감사 로그

- ClickHouse-backed feed. Filter by tenant, actor, action, and - rate-limit status. Enforce admin-only access under /admin. + Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 + 추후 세션 연동 시 자동 채워집니다.

- - + +
-
+ -
-
-
- - - Try: tenant:TENANT-12 action:client.* - -
-
- {auditFilters.map((filter) => ( - - - {filter} - - ))} -
-
- {logs.length === 0 ? ( -
- No audit logs found. +
+ + +
+ Audit registry + 로드된 로그 {logs.length}건 +
+ Command only +
+ +
+
+ + setFilterDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAddFilter(); + } + }} + placeholder="필터 추가 (예: status:failure)" + className="w-full bg-transparent text-sm text-foreground outline-none" + /> +
- ) : ( - logs.map((row) => ( -
-
{row.event_type}
-
- {/* Tenant info not yet in basic schema, show generic or details snippet */} - Tenant-? -
-
- {row.user_id} -
-
- + 필터 없음 + + ) : ( + filters.map((filter) => ( + + + {filter} +
-
- )) - )} -
-
+ × + + + )) + )} +
+ + + + TIME + ACTOR (ID) + REQUEST + PATH + STATUS + Action / Target + + + + + {isLoading && ( + + 로딩 중... + + )} + {!isLoading && logs.length === 0 && ( + + + 아직 수집된 감사 로그가 없습니다. + + + )} + {logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = + details.action || + (details.method && details.path + ? `${details.method} ${details.path}` + : row.event_type); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const isExpanded = Boolean(expandedRows[rowKey]); + return ( + + + + {(() => { + const { date, time } = formatIsoDateTime( + row.timestamp, + ); + return ( +
+
{date}
+
{time}
+
+ ); + })()} +
+ +
+ + {row.user_id || details.actor_id || "-"} + + {(row.user_id || details.actor_id) && ( + + )} +
+
+ +
+ + {formatCellValue(details.request_id)} + + {details.request_id && ( + + )} +
+
+ +
+ {formatCellValue(details.method)} +
+
+ {formatCellValue(details.path)} +
+
+ + + {row.status} + + + +
+ {actionLabel} +
+ {details.target && ( +
+ + Target · {details.target} + + +
+ )} +
+ + + +
+ {isExpanded && ( + + +
+
+
+ Request +
+
+ Request ID · {formatCellValue(details.request_id)} +
+
+ Event ID · {formatCellValue(row.event_id)} +
+
IP · {formatCellValue(row.ip_address)}
+
+ Latency ·{" "} + {details.latency_ms !== undefined + ? `${details.latency_ms}ms` + : "-"} +
+
+
+
+ Actor +
+
+ Actor ID · {row.user_id || details.actor_id || "-"} +
+
Tenant · {formatCellValue(details.tenant_id)}
+
Device · {formatCellValue(row.device_id)}
+
+
+
+ Result +
+
+ Error · {formatCellValue(details.error)} +
+
+ Before · {formatCellValue(details.before)} +
+
+ After · {formatCellValue(details.after)} +
+
+
+
+
+ )} +
+ ); + })} +
+
+
+ {hasNextPage ? ( + + ) : ( + + End of audit feed + + )} +
+ + -
-
-

- Guard rails -

-

Tenant admin only

-

- Enforce Tenant Admin middleware and admin session TTL before - surfacing any audit feed. Super Admin role can bypass tenant - filter when needed. -

-
-
-

- Export rules -

-

- Rate-limit sensitive exports -

-

- Keep export endpoints behind admin-only routes with ClickHouse - query limits. Log download attempts with IP, role, and tenant - scope. -

-
-
); diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index f0cea060..ebdfd38d 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -1,6 +1,7 @@ import apiClient from "./apiClient"; export type AuditLog = { + event_id: string; timestamp: string; user_id: string; event_type: string; @@ -14,7 +15,8 @@ export type AuditLog = { export type AuditLogListResponse = { items: AuditLog[]; limit: number; - offset: number; + cursor?: string; + next_cursor?: string; }; export type TenantSummary = { @@ -48,9 +50,9 @@ export type TenantUpdateRequest = { status?: string; }; -export async function fetchAuditLogs(limit = 50, offset = 0) { +export async function fetchAuditLogs(limit = 50, cursor?: string) { const { data } = await apiClient.get("/v1/audit", { - params: { limit, offset }, + params: { limit, cursor }, }); return data; } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 74768ad6..db9dd4ee 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "baron-sso-backend/internal/handler" "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" + "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/validator" @@ -335,6 +336,13 @@ func main() { // API Group api := app.Group("/api/v1") + api.Use(middleware.RequireAudit(middleware.AuditRequiredConfig{ + Repo: auditRepo, + ExcludePaths: map[string]struct{}{ + "/api/v1/audit": {}, + "/api/v1/client-log": {}, + }, + })) api.Post("/audit", auditHandler.CreateLog) api.Get("/audit", auditHandler.ListLogs) diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index ac587945..d87881bd 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -7,6 +7,7 @@ import ( // AuditLog represents a single audit event type AuditLog struct { + EventID string `json:"event_id"` Timestamp time.Time `json:"timestamp"` UserID string `json:"user_id"` EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent" @@ -20,6 +21,11 @@ type AuditLog struct { // AuditRepository defines interface for storing logs type AuditRepository interface { Create(log *AuditLog) error - FindAll(ctx context.Context, limit, offset int) ([]AuditLog, error) + FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error) Ping(ctx context.Context) error } + +type AuditCursor struct { + Timestamp time.Time + EventID string +} diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index 5d4484d0..07c4e049 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -2,9 +2,13 @@ package handler import ( "baron-sso-backend/internal/domain" + "encoding/base64" + "errors" + "strings" "time" "github.com/gofiber/fiber/v2" + "github.com/google/uuid" ) type AuditHandler struct { @@ -34,6 +38,9 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error { if req.Timestamp.IsZero() { req.Timestamp = time.Now() } + if req.EventID == "" { + req.EventID = ensureRequestID(c) + } if err := h.repo.Create(&req); err != nil { // Log internal error but don't expose details @@ -50,18 +57,68 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error { // ListLogs handles GET /api/v1/audit func (h *AuditHandler) ListLogs(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) - offset := c.QueryInt("offset", 0) + cursorRaw := c.Query("cursor") + cursor, err := parseAuditCursor(cursorRaw) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid cursor", + }) + } - logs, err := h.repo.FindAll(c.Context(), limit, offset) + logs, err := h.repo.FindPage(c.Context(), limit+1, cursor) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Failed to retrieve audit logs", }) } + nextCursor := "" + if len(logs) > limit { + last := logs[limit-1] + nextCursor = encodeAuditCursor(last) + logs = logs[:limit] + } + return c.JSON(fiber.Map{ - "items": logs, - "limit": limit, - "offset": offset, + "items": logs, + "limit": limit, + "cursor": cursorRaw, + "next_cursor": nextCursor, }) } + +func ensureRequestID(c *fiber.Ctx) string { + reqID := c.Get("X-Request-Id") + if reqID == "" { + reqID = uuid.New().String() + c.Set("X-Request-Id", reqID) + } + return reqID +} + +func parseAuditCursor(raw string) (*domain.AuditCursor, error) { + if raw == "" { + return nil, nil + } + decoded, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, err + } + parts := strings.SplitN(string(decoded), "|", 2) + if len(parts) != 2 { + return nil, errors.New("invalid cursor") + } + ts, err := time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return nil, err + } + return &domain.AuditCursor{ + Timestamp: ts, + EventID: parts[1], + }, nil +} + +func encodeAuditCursor(log domain.AuditLog) string { + payload := log.Timestamp.UTC().Format(time.RFC3339Nano) + "|" + log.EventID + return base64.RawURLEncoding.EncodeToString([]byte(payload)) +} diff --git a/backend/internal/middleware/audit_required.go b/backend/internal/middleware/audit_required.go new file mode 100644 index 00000000..5741df0f --- /dev/null +++ b/backend/internal/middleware/audit_required.go @@ -0,0 +1,106 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type AuditRequiredConfig struct { + Repo domain.AuditRepository + ExcludePaths map[string]struct{} + CommandMethods map[string]struct{} +} + +func RequireAudit(config AuditRequiredConfig) fiber.Handler { + commandMethods := config.CommandMethods + if len(commandMethods) == 0 { + commandMethods = map[string]struct{}{ + fiber.MethodPost: {}, + fiber.MethodPut: {}, + fiber.MethodPatch: {}, + fiber.MethodDelete: {}, + } + } + + excludePaths := config.ExcludePaths + if excludePaths == nil { + excludePaths = map[string]struct{}{} + } + + return func(c *fiber.Ctx) error { + if _, ok := commandMethods[c.Method()]; !ok { + return c.Next() + } + if _, excluded := excludePaths[c.Path()]; excluded { + return c.Next() + } + if config.Repo == nil { + return fiber.NewError(fiber.StatusServiceUnavailable, "audit repository unavailable") + } + + start := time.Now() + reqID := c.Get("X-Request-Id") + if reqID == "" { + reqID = uuid.New().String() + c.Set("X-Request-Id", reqID) + } + + err := c.Next() + latency := time.Since(start) + + status := c.Response().StatusCode() + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + status = fiberErr.Code + } else { + status = fiber.StatusInternalServerError + } + } + + statusText := "success" + if status >= fiber.StatusBadRequest { + statusText = "failure" + } + + details := map[string]any{ + "request_id": reqID, + "method": c.Method(), + "path": c.Path(), + "status": status, + "latency_ms": latency.Milliseconds(), + } + if err != nil { + details["error"] = err.Error() + } + + detailsJSON, jsonErr := json.Marshal(details) + if jsonErr != nil { + slog.Warn("failed to marshal audit details", "error", jsonErr, "req_id", reqID) + } + + auditLog := &domain.AuditLog{ + EventID: reqID, + Timestamp: time.Now(), + UserID: "", + EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()), + Status: statusText, + IPAddress: c.IP(), + UserAgent: c.Get("User-Agent"), + DeviceID: "", + Details: string(detailsJSON), + } + + if createErr := config.Repo.Create(auditLog); createErr != nil { + slog.Error("audit log write failed", "error", createErr, "req_id", reqID, "path", c.Path()) + return fiber.NewError(fiber.StatusServiceUnavailable, "audit logging unavailable") + } + + return err + } +} diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index 360820b9..c6c8723f 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -36,6 +36,7 @@ func NewClickHouseRepository(host string, port int, user, password, db string) ( // Note: In production, use migrations. query := ` CREATE TABLE IF NOT EXISTS audit_logs ( + event_id String, timestamp DateTime DEFAULT now(), user_id String, event_type String, @@ -51,6 +52,14 @@ func NewClickHouseRepository(host string, port int, user, password, db string) ( return nil, fmt.Errorf("failed to create table: %w", err) } + alterQuery := ` + ALTER TABLE audit_logs + ADD COLUMN IF NOT EXISTS event_id String + ` + if err := conn.Exec(context.Background(), alterQuery); err != nil { + return nil, fmt.Errorf("failed to alter table: %w", err) + } + return &ClickHouseRepository{conn: conn}, nil } @@ -63,10 +72,11 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { } query := ` - INSERT INTO audit_logs (timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO audit_logs (event_id, timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` return r.conn.Exec(ctx, query, + log.EventID, log.Timestamp, log.UserID, log.EventType, @@ -78,21 +88,28 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { ) } -func (r *ClickHouseRepository) FindAll(ctx context.Context, limit, offset int) ([]domain.AuditLog, error) { +func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) { if limit <= 0 { limit = 50 } - if offset < 0 { - offset = 0 - } - query := ` - SELECT timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details + SELECT event_id, timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details FROM audit_logs - ORDER BY timestamp DESC - LIMIT ? OFFSET ? ` - rows, err := r.conn.Query(ctx, query, limit, offset) + args := make([]any, 0, 4) + if cursor != nil { + query += ` + WHERE (timestamp < ?) OR (timestamp = ? AND event_id < ?) + ` + args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID) + } + query += ` + ORDER BY timestamp DESC, event_id DESC + LIMIT ? + ` + args = append(args, limit) + + rows, err := r.conn.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to query audit logs: %w", err) } @@ -102,6 +119,7 @@ func (r *ClickHouseRepository) FindAll(ctx context.Context, limit, offset int) ( for rows.Next() { var log domain.AuditLog if err := rows.Scan( + &log.EventID, &log.Timestamp, &log.UserID, &log.EventType,