diff --git a/cmd/server/main.go b/cmd/server/main.go index 6b75575..5e2a029 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "github.com/kms/api-key-service/internal/audit" "github.com/kms/api-key-service/internal/config" "github.com/kms/api-key-service/internal/database" "github.com/kms/api-key-service/internal/handlers" @@ -65,6 +66,9 @@ func main() { grantRepo := postgres.NewGrantedPermissionRepository(db) auditRepo := postgres.NewAuditRepository(db) + // Initialize audit logger + auditLogger := audit.NewAuditLogger(cfg, logger, auditRepo) + // Initialize services appService := services.NewApplicationService(appRepo, auditRepo, logger) tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, cfg.GetString("INTERNAL_HMAC_KEY"), cfg, logger) @@ -75,9 +79,10 @@ func main() { appHandler := handlers.NewApplicationHandler(appService, authService, logger) tokenHandler := handlers.NewTokenHandler(tokenService, authService, logger) authHandler := handlers.NewAuthHandler(authService, tokenService, cfg, logger) + auditHandler := handlers.NewAuditHandler(auditLogger, authService, logger) // Set up router - router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler) + router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler, auditHandler) // Create HTTP server srv := &http.Server{ @@ -151,7 +156,7 @@ func initLogger(cfg config.ConfigProvider) *zap.Logger { return logger } -func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *handlers.HealthHandler, appHandler *handlers.ApplicationHandler, tokenHandler *handlers.TokenHandler, authHandler *handlers.AuthHandler) *gin.Engine { +func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *handlers.HealthHandler, appHandler *handlers.ApplicationHandler, tokenHandler *handlers.TokenHandler, authHandler *handlers.AuthHandler, auditHandler *handlers.AuditHandler) *gin.Engine { // Set Gin mode based on environment if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) @@ -199,6 +204,11 @@ func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *h protected.POST("/applications/:id/tokens", tokenHandler.Create) protected.DELETE("/tokens/:id", tokenHandler.Delete) + // Audit management + protected.GET("/audit/events", auditHandler.ListEvents) + protected.GET("/audit/events/:id", auditHandler.GetEvent) + protected.GET("/audit/stats", auditHandler.GetStats) + // Documentation endpoint protected.GET("/docs", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -223,6 +233,11 @@ func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *h "POST /api/applications/:id/tokens", "DELETE /api/tokens/:id", }, + "audit": []string{ + "GET /api/audit/events", + "GET /api/audit/events/:id", + "GET /api/audit/stats", + }, }, }) }) diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go new file mode 100644 index 0000000..d68f0f4 --- /dev/null +++ b/internal/handlers/audit.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kms/api-key-service/internal/audit" + "github.com/kms/api-key-service/internal/errors" + "github.com/kms/api-key-service/internal/services" + "github.com/kms/api-key-service/internal/validation" +) + +// AuditHandler handles audit-related HTTP requests +type AuditHandler struct { + auditLogger audit.AuditLogger + authService services.AuthenticationService + validator *validation.Validator + errorHandler *errors.ErrorHandler + logger *zap.Logger +} + +// NewAuditHandler creates a new audit handler +func NewAuditHandler( + auditLogger audit.AuditLogger, + authService services.AuthenticationService, + logger *zap.Logger, +) *AuditHandler { + return &AuditHandler{ + auditLogger: auditLogger, + authService: authService, + validator: validation.NewValidator(logger), + errorHandler: errors.NewErrorHandler(logger), + logger: logger, + } +} + +// AuditQueryRequest represents the request for querying audit events +type AuditQueryRequest struct { + EventTypes []string `json:"event_types,omitempty" form:"event_types"` + Statuses []string `json:"statuses,omitempty" form:"statuses"` + ActorID string `json:"actor_id,omitempty" form:"actor_id"` + ResourceID string `json:"resource_id,omitempty" form:"resource_id"` + ResourceType string `json:"resource_type,omitempty" form:"resource_type"` + StartTime *string `json:"start_time,omitempty" form:"start_time"` + EndTime *string `json:"end_time,omitempty" form:"end_time"` + Limit int `json:"limit,omitempty" form:"limit"` + Offset int `json:"offset,omitempty" form:"offset"` + OrderBy string `json:"order_by,omitempty" form:"order_by"` + OrderDesc *bool `json:"order_desc,omitempty" form:"order_desc"` +} + +// AuditStatsRequest represents the request for audit statistics +type AuditStatsRequest struct { + EventTypes []string `json:"event_types,omitempty" form:"event_types"` + StartTime *string `json:"start_time,omitempty" form:"start_time"` + EndTime *string `json:"end_time,omitempty" form:"end_time"` + GroupBy string `json:"group_by,omitempty" form:"group_by"` +} + +// AuditResponse represents the response structure for audit queries +type AuditResponse struct { + Events []AuditEventResponse `json:"events"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// AuditEventResponse represents a single audit event in API responses +type AuditEventResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Timestamp string `json:"timestamp"` + ActorID string `json:"actor_id,omitempty"` + ActorIP string `json:"actor_ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + ResourceID string `json:"resource_id,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Action string `json:"action"` + Description string `json:"description"` + Details map[string]interface{} `json:"details,omitempty"` + RequestID string `json:"request_id,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// ListEvents handles GET /audit/events +func (h *AuditHandler) ListEvents(c *gin.Context) { + // Parse query parameters + var req AuditQueryRequest + if err := c.ShouldBindQuery(&req); err != nil { + h.errorHandler.HandleValidationError(c, "query_params", "Invalid query parameters") + return + } + + // Set defaults + if req.Limit <= 0 || req.Limit > 1000 { + req.Limit = 100 + } + if req.Offset < 0 { + req.Offset = 0 + } + if req.OrderBy == "" { + req.OrderBy = "timestamp" + } + if req.OrderDesc == nil { + orderDesc := true + req.OrderDesc = &orderDesc + } + + // Convert request to audit filter + filter := &audit.AuditFilter{ + ActorID: req.ActorID, + ResourceID: req.ResourceID, + ResourceType: req.ResourceType, + Limit: req.Limit, + Offset: req.Offset, + OrderBy: req.OrderBy, + OrderDesc: *req.OrderDesc, + } + + // Convert event types + for _, et := range req.EventTypes { + filter.EventTypes = append(filter.EventTypes, audit.EventType(et)) + } + + // Convert statuses + for _, st := range req.Statuses { + filter.Statuses = append(filter.Statuses, audit.EventStatus(st)) + } + + // Parse time filters + if req.StartTime != nil && *req.StartTime != "" { + if startTime, err := time.Parse(time.RFC3339, *req.StartTime); err == nil { + filter.StartTime = &startTime + } else { + h.errorHandler.HandleValidationError(c, "start_time", "Invalid start_time format, use RFC3339") + return + } + } + + if req.EndTime != nil && *req.EndTime != "" { + if endTime, err := time.Parse(time.RFC3339, *req.EndTime); err == nil { + filter.EndTime = &endTime + } else { + h.errorHandler.HandleValidationError(c, "end_time", "Invalid end_time format, use RFC3339") + return + } + } + + // Query audit events + events, err := h.auditLogger.QueryEvents(c.Request.Context(), filter) + if err != nil { + h.logger.Error("Failed to query audit events", zap.Error(err)) + h.errorHandler.HandleInternalError(c, err) + return + } + + // Convert to response format + response := &AuditResponse{ + Events: make([]AuditEventResponse, len(events)), + Total: len(events), // Note: This is just the count of returned events, not total matching + Limit: req.Limit, + Offset: req.Offset, + } + + for i, event := range events { + response.Events[i] = AuditEventResponse{ + ID: event.ID.String(), + Type: string(event.Type), + Status: string(event.Status), + Timestamp: event.Timestamp.Format(time.RFC3339), + ActorID: event.ActorID, + ActorIP: event.ActorIP, + UserAgent: event.UserAgent, + ResourceID: event.ResourceID, + ResourceType: event.ResourceType, + Action: event.Action, + Description: event.Description, + Details: event.Details, + RequestID: event.RequestID, + SessionID: event.SessionID, + } + } + + c.JSON(http.StatusOK, response) +} + +// GetEvent handles GET /audit/events/:id +func (h *AuditHandler) GetEvent(c *gin.Context) { + eventIDStr := c.Param("id") + _, err := uuid.Parse(eventIDStr) + if err != nil { + h.errorHandler.HandleValidationError(c, "id", "Invalid event ID format") + return + } + + // Single event retrieval not yet implemented + c.JSON(http.StatusNotImplemented, gin.H{ + "error": "Single event retrieval not yet implemented", + }) +} + +// GetStats handles GET /audit/stats +func (h *AuditHandler) GetStats(c *gin.Context) { + // Parse query parameters + var req AuditStatsRequest + if err := c.ShouldBindQuery(&req); err != nil { + h.errorHandler.HandleValidationError(c, "query_params", "Invalid query parameters") + return + } + + // Convert request to audit stats filter + filter := &audit.AuditStatsFilter{ + GroupBy: req.GroupBy, + } + + // Convert event types + for _, et := range req.EventTypes { + filter.EventTypes = append(filter.EventTypes, audit.EventType(et)) + } + + // Parse time filters + if req.StartTime != nil && *req.StartTime != "" { + if startTime, err := time.Parse(time.RFC3339, *req.StartTime); err == nil { + filter.StartTime = &startTime + } else { + h.errorHandler.HandleValidationError(c, "start_time", "Invalid start_time format, use RFC3339") + return + } + } + + if req.EndTime != nil && *req.EndTime != "" { + if endTime, err := time.Parse(time.RFC3339, *req.EndTime); err == nil { + filter.EndTime = &endTime + } else { + h.errorHandler.HandleValidationError(c, "end_time", "Invalid end_time format, use RFC3339") + return + } + } + + // Get audit statistics + stats, err := h.auditLogger.GetEventStats(c.Request.Context(), filter) + if err != nil { + h.logger.Error("Failed to get audit statistics", zap.Error(err)) + h.errorHandler.HandleInternalError(c, err) + return + } + + c.JSON(http.StatusOK, stats) +} \ No newline at end of file diff --git a/kms-frontend/src/components/Audit.tsx b/kms-frontend/src/components/Audit.tsx index 24fd2f1..59d3ed8 100644 --- a/kms-frontend/src/components/Audit.tsx +++ b/kms-frontend/src/components/Audit.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Table, Card, @@ -28,6 +28,7 @@ import { } from '@ant-design/icons'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; +import { apiService, AuditEvent, AuditQueryParams } from '../services/apiService'; dayjs.extend(relativeTime); @@ -35,103 +36,10 @@ const { Title, Text } = Typography; const { RangePicker } = DatePicker; const { Option } = Select; -interface AuditLogEntry { - id: string; - timestamp: string; - user_id: string; - action: string; - resource_type: string; - resource_id: string; - status: 'success' | 'failure' | 'warning'; - ip_address: string; - user_agent: string; - details: Record; -} - -// Mock audit data for demonstration -const mockAuditData: AuditLogEntry[] = [ - { - id: '1', - timestamp: dayjs().subtract(1, 'hour').toISOString(), - user_id: 'admin@example.com', - action: 'CREATE_APPLICATION', - resource_type: 'application', - resource_id: 'com.example.newapp', - status: 'success', - ip_address: '192.168.1.100', - user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - details: { - app_link: 'https://newapp.example.com', - owner: 'Development Team' - } - }, - { - id: '2', - timestamp: dayjs().subtract(2, 'hours').toISOString(), - user_id: 'user@example.com', - action: 'CREATE_TOKEN', - resource_type: 'token', - resource_id: 'token-abc123', - status: 'success', - ip_address: '192.168.1.101', - user_agent: 'curl/7.68.0', - details: { - app_id: 'com.example.app', - permissions: ['repo.read', 'repo.write'] - } - }, - { - id: '3', - timestamp: dayjs().subtract(3, 'hours').toISOString(), - user_id: 'admin@example.com', - action: 'DELETE_TOKEN', - resource_type: 'token', - resource_id: 'token-xyz789', - status: 'success', - ip_address: '192.168.1.100', - user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - details: { - app_id: 'com.example.oldapp', - reason: 'Token compromised' - } - }, - { - id: '4', - timestamp: dayjs().subtract(4, 'hours').toISOString(), - user_id: 'user@example.com', - action: 'VERIFY_TOKEN', - resource_type: 'token', - resource_id: 'token-def456', - status: 'failure', - ip_address: '192.168.1.102', - user_agent: 'PostmanRuntime/7.28.4', - details: { - app_id: 'com.example.app', - error: 'Token expired' - } - }, - { - id: '5', - timestamp: dayjs().subtract(6, 'hours').toISOString(), - user_id: 'admin@example.com', - action: 'UPDATE_APPLICATION', - resource_type: 'application', - resource_id: 'com.example.app', - status: 'success', - ip_address: '192.168.1.100', - user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - details: { - changes: { - callback_url: 'https://updated.example.com/callback' - } - } - }, -]; - const Audit: React.FC = () => { - const [auditData, setAuditData] = useState(mockAuditData); - const [filteredData, setFilteredData] = useState(mockAuditData); - const [loading, setLoading] = useState(false); + const [auditData, setAuditData] = useState([]); + const [filteredData, setFilteredData] = useState([]); + const [loading, setLoading] = useState(true); const [filters, setFilters] = useState({ dateRange: null as any, action: '', @@ -140,36 +48,70 @@ const Audit: React.FC = () => { resourceType: '', }); - const applyFilters = () => { - let filtered = [...auditData]; + // Load audit data on component mount + useEffect(() => { + loadAuditData(); + }, []); - if (filters.dateRange && filters.dateRange.length === 2) { - const [start, end] = filters.dateRange; - filtered = filtered.filter(entry => { - const entryDate = dayjs(entry.timestamp); - return entryDate.isAfter(start) && entryDate.isBefore(end); + const loadAuditData = async () => { + try { + setLoading(true); + const response = await apiService.getAuditEvents({ + limit: 100, + order_by: 'timestamp', + order_desc: true, }); + setAuditData(response.events); + setFilteredData(response.events); + } catch (error) { + console.error('Failed to load audit data:', error); + // Keep empty arrays on error + setAuditData([]); + setFilteredData([]); + } finally { + setLoading(false); } + }; - if (filters.action) { - filtered = filtered.filter(entry => entry.action === filters.action); + const applyFilters = async () => { + // For real-time filtering, we'll use the API with filters + try { + setLoading(true); + const params: AuditQueryParams = { + limit: 100, + order_by: 'timestamp', + order_desc: true, + }; + + if (filters.dateRange && filters.dateRange.length === 2) { + const [start, end] = filters.dateRange; + params.start_time = start.toISOString(); + params.end_time = end.toISOString(); + } + + if (filters.action) { + params.event_types = [filters.action]; + } + + if (filters.status) { + params.statuses = [filters.status]; + } + + if (filters.user) { + params.actor_id = filters.user; + } + + if (filters.resourceType) { + params.resource_type = filters.resourceType; + } + + const response = await apiService.getAuditEvents(params); + setFilteredData(response.events); + } catch (error) { + console.error('Failed to apply filters:', error); + } finally { + setLoading(false); } - - if (filters.status) { - filtered = filtered.filter(entry => entry.status === filters.status); - } - - if (filters.user) { - filtered = filtered.filter(entry => - entry.user_id.toLowerCase().includes(filters.user.toLowerCase()) - ); - } - - if (filters.resourceType) { - filtered = filtered.filter(entry => entry.resource_type === filters.resourceType); - } - - setFilteredData(filtered); }; const clearFilters = () => { @@ -180,7 +122,7 @@ const Audit: React.FC = () => { user: '', resourceType: '', }); - setFilteredData(auditData); + loadAuditData(); // Reload original data }; const getStatusIcon = (status: string) => { @@ -216,42 +158,42 @@ const Audit: React.FC = () => { ), - sorter: (a: AuditLogEntry, b: AuditLogEntry) => + sorter: (a: AuditEvent, b: AuditEvent) => dayjs(a.timestamp).unix() - dayjs(b.timestamp).unix(), defaultSortOrder: 'descend' as const, }, { title: 'User', - dataIndex: 'user_id', - key: 'user_id', - render: (userId: string) => ( + dataIndex: 'actor_id', + key: 'actor_id', + render: (actorId: string) => (
- {userId} + {actorId || 'System'}
), }, { title: 'Action', - dataIndex: 'action', - key: 'action', - render: (action: string) => ( + dataIndex: 'type', + key: 'type', + render: (type: string) => (
- {getActionIcon(action)} - {action.replace(/_/g, ' ')} + {getActionIcon(type)} + {type.replace(/_/g, ' ').replace(/\./g, ' ')}
), }, { title: 'Resource', key: 'resource', - render: (_: any, record: AuditLogEntry) => ( + render: (_: any, record: AuditEvent) => (
- {record.resource_type.toUpperCase()} + {record.resource_type?.toUpperCase() || 'N/A'}
- {record.resource_id} + {record.resource_id || 'N/A'}
), @@ -271,13 +213,13 @@ const Audit: React.FC = () => { }, { title: 'IP Address', - dataIndex: 'ip_address', - key: 'ip_address', - render: (ip: string) => {ip}, + dataIndex: 'actor_ip', + key: 'actor_ip', + render: (ip: string) => {ip || 'N/A'}, }, ]; - const expandedRowRender = (record: AuditLogEntry) => ( + const expandedRowRender = (record: AuditEvent) => ( @@ -360,7 +302,7 @@ const Audit: React.FC = () => {
- {new Set(filteredData.map(e => e.user_id)).size} + {new Set(filteredData.map(e => e.actor_id).filter(id => id)).size}
Unique Users
@@ -401,12 +343,14 @@ const Audit: React.FC = () => { onChange={(value) => setFilters({ ...filters, action: value })} allowClear > - - - - - - + + + + + + + + @@ -465,16 +409,16 @@ const Audit: React.FC = () => { color={entry.status === 'success' ? 'green' : entry.status === 'failure' ? 'red' : 'orange'} >
- {entry.action.replace(/_/g, ' ')} + {entry.type.replace(/_/g, ' ').replace(/\./g, ' ')}
- {entry.user_id} • {dayjs(entry.timestamp).fromNow()} + {entry.actor_id || 'System'} • {dayjs(entry.timestamp).fromNow()}
- {entry.resource_type} + {entry.resource_type || 'N/A'} - {entry.resource_id} + {entry.resource_id || 'N/A'}
@@ -485,13 +429,15 @@ const Audit: React.FC = () => { {/* Audit Log Table */} - + {filteredData.length === 0 && !loading && ( + + )} ; + request_id?: string; + session_id?: string; +} + +export interface AuditQueryParams { + event_types?: string[]; + statuses?: string[]; + actor_id?: string; + resource_id?: string; + resource_type?: string; + start_time?: string; + end_time?: string; + limit?: number; + offset?: number; + order_by?: string; + order_desc?: boolean; +} + +export interface AuditResponse { + events: AuditEvent[]; + total: number; + limit: number; + offset: number; +} + +export interface AuditStats { + total_events: number; + by_type: Record; + by_severity: Record; + by_status: Record; + by_time?: Record; +} + +export interface AuditStatsParams { + event_types?: string[]; + start_time?: string; + end_time?: string; + group_by?: string; +} + class ApiService { private api: AxiosInstance; private baseURL: string; @@ -204,6 +257,55 @@ class ApiService { }); return response.data; } + + // Audit endpoints + async getAuditEvents(params?: AuditQueryParams): Promise { + const queryString = new URLSearchParams(); + + if (params) { + if (params.event_types?.length) { + params.event_types.forEach(type => queryString.append('event_types', type)); + } + if (params.statuses?.length) { + params.statuses.forEach(status => queryString.append('statuses', status)); + } + if (params.actor_id) queryString.set('actor_id', params.actor_id); + if (params.resource_id) queryString.set('resource_id', params.resource_id); + if (params.resource_type) queryString.set('resource_type', params.resource_type); + if (params.start_time) queryString.set('start_time', params.start_time); + if (params.end_time) queryString.set('end_time', params.end_time); + if (params.limit) queryString.set('limit', params.limit.toString()); + if (params.offset) queryString.set('offset', params.offset.toString()); + if (params.order_by) queryString.set('order_by', params.order_by); + if (params.order_desc !== undefined) queryString.set('order_desc', params.order_desc.toString()); + } + + const url = `/api/audit/events${queryString.toString() ? '?' + queryString.toString() : ''}`; + const response = await this.api.get(url); + return response.data; + } + + async getAuditEvent(eventId: string): Promise { + const response = await this.api.get(`/api/audit/events/${eventId}`); + return response.data; + } + + async getAuditStats(params?: AuditStatsParams): Promise { + const queryString = new URLSearchParams(); + + if (params) { + if (params.event_types?.length) { + params.event_types.forEach(type => queryString.append('event_types', type)); + } + if (params.start_time) queryString.set('start_time', params.start_time); + if (params.end_time) queryString.set('end_time', params.end_time); + if (params.group_by) queryString.set('group_by', params.group_by); + } + + const url = `/api/audit/stats${queryString.toString() ? '?' + queryString.toString() : ''}`; + const response = await this.api.get(url); + return response.data; + } } export const apiService = new ApiService();