audit logs
This commit is contained in:
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"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/config"
|
||||||
"github.com/kms/api-key-service/internal/database"
|
"github.com/kms/api-key-service/internal/database"
|
||||||
"github.com/kms/api-key-service/internal/handlers"
|
"github.com/kms/api-key-service/internal/handlers"
|
||||||
@ -65,6 +66,9 @@ func main() {
|
|||||||
grantRepo := postgres.NewGrantedPermissionRepository(db)
|
grantRepo := postgres.NewGrantedPermissionRepository(db)
|
||||||
auditRepo := postgres.NewAuditRepository(db)
|
auditRepo := postgres.NewAuditRepository(db)
|
||||||
|
|
||||||
|
// Initialize audit logger
|
||||||
|
auditLogger := audit.NewAuditLogger(cfg, logger, auditRepo)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
appService := services.NewApplicationService(appRepo, auditRepo, logger)
|
appService := services.NewApplicationService(appRepo, auditRepo, logger)
|
||||||
tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, cfg.GetString("INTERNAL_HMAC_KEY"), cfg, 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)
|
appHandler := handlers.NewApplicationHandler(appService, authService, logger)
|
||||||
tokenHandler := handlers.NewTokenHandler(tokenService, authService, logger)
|
tokenHandler := handlers.NewTokenHandler(tokenService, authService, logger)
|
||||||
authHandler := handlers.NewAuthHandler(authService, tokenService, cfg, logger)
|
authHandler := handlers.NewAuthHandler(authService, tokenService, cfg, logger)
|
||||||
|
auditHandler := handlers.NewAuditHandler(auditLogger, authService, logger)
|
||||||
|
|
||||||
// Set up router
|
// Set up router
|
||||||
router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler)
|
router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler, auditHandler)
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@ -151,7 +156,7 @@ func initLogger(cfg config.ConfigProvider) *zap.Logger {
|
|||||||
return 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
|
// Set Gin mode based on environment
|
||||||
if cfg.IsProduction() {
|
if cfg.IsProduction() {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
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.POST("/applications/:id/tokens", tokenHandler.Create)
|
||||||
protected.DELETE("/tokens/:id", tokenHandler.Delete)
|
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
|
// Documentation endpoint
|
||||||
protected.GET("/docs", func(c *gin.Context) {
|
protected.GET("/docs", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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",
|
"POST /api/applications/:id/tokens",
|
||||||
"DELETE /api/tokens/:id",
|
"DELETE /api/tokens/:id",
|
||||||
},
|
},
|
||||||
|
"audit": []string{
|
||||||
|
"GET /api/audit/events",
|
||||||
|
"GET /api/audit/events/:id",
|
||||||
|
"GET /api/audit/stats",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
254
internal/handlers/audit.go
Normal file
254
internal/handlers/audit.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Card,
|
Card,
|
||||||
@ -28,6 +28,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { apiService, AuditEvent, AuditQueryParams } from '../services/apiService';
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@ -35,103 +36,10 @@ const { Title, Text } = Typography;
|
|||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
const { Option } = Select;
|
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<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 Audit: React.FC = () => {
|
||||||
const [auditData, setAuditData] = useState<AuditLogEntry[]>(mockAuditData);
|
const [auditData, setAuditData] = useState<AuditEvent[]>([]);
|
||||||
const [filteredData, setFilteredData] = useState<AuditLogEntry[]>(mockAuditData);
|
const [filteredData, setFilteredData] = useState<AuditEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
dateRange: null as any,
|
dateRange: null as any,
|
||||||
action: '',
|
action: '',
|
||||||
@ -140,36 +48,70 @@ const Audit: React.FC = () => {
|
|||||||
resourceType: '',
|
resourceType: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyFilters = () => {
|
// Load audit data on component mount
|
||||||
let filtered = [...auditData];
|
useEffect(() => {
|
||||||
|
loadAuditData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
const loadAuditData = async () => {
|
||||||
const [start, end] = filters.dateRange;
|
try {
|
||||||
filtered = filtered.filter(entry => {
|
setLoading(true);
|
||||||
const entryDate = dayjs(entry.timestamp);
|
const response = await apiService.getAuditEvents({
|
||||||
return entryDate.isAfter(start) && entryDate.isBefore(end);
|
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) {
|
const applyFilters = async () => {
|
||||||
filtered = filtered.filter(entry => entry.action === filters.action);
|
// 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 = () => {
|
const clearFilters = () => {
|
||||||
@ -180,7 +122,7 @@ const Audit: React.FC = () => {
|
|||||||
user: '',
|
user: '',
|
||||||
resourceType: '',
|
resourceType: '',
|
||||||
});
|
});
|
||||||
setFilteredData(auditData);
|
loadAuditData(); // Reload original data
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
@ -216,42 +158,42 @@ const Audit: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
sorter: (a: AuditLogEntry, b: AuditLogEntry) =>
|
sorter: (a: AuditEvent, b: AuditEvent) =>
|
||||||
dayjs(a.timestamp).unix() - dayjs(b.timestamp).unix(),
|
dayjs(a.timestamp).unix() - dayjs(b.timestamp).unix(),
|
||||||
defaultSortOrder: 'descend' as const,
|
defaultSortOrder: 'descend' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'User',
|
title: 'User',
|
||||||
dataIndex: 'user_id',
|
dataIndex: 'actor_id',
|
||||||
key: 'user_id',
|
key: 'actor_id',
|
||||||
render: (userId: string) => (
|
render: (actorId: string) => (
|
||||||
<div>
|
<div>
|
||||||
<UserOutlined style={{ marginRight: '8px' }} />
|
<UserOutlined style={{ marginRight: '8px' }} />
|
||||||
{userId}
|
{actorId || 'System'}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Action',
|
title: 'Action',
|
||||||
dataIndex: 'action',
|
dataIndex: 'type',
|
||||||
key: 'action',
|
key: 'type',
|
||||||
render: (action: string) => (
|
render: (type: string) => (
|
||||||
<div>
|
<div>
|
||||||
{getActionIcon(action)}
|
{getActionIcon(type)}
|
||||||
<span style={{ marginLeft: '8px' }}>{action.replace(/_/g, ' ')}</span>
|
<span style={{ marginLeft: '8px' }}>{type.replace(/_/g, ' ').replace(/\./g, ' ')}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Resource',
|
title: 'Resource',
|
||||||
key: 'resource',
|
key: 'resource',
|
||||||
render: (_: any, record: AuditLogEntry) => (
|
render: (_: any, record: AuditEvent) => (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<Tag color="blue">{record.resource_type.toUpperCase()}</Tag>
|
<Tag color="blue">{record.resource_type?.toUpperCase() || 'N/A'}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
{record.resource_id}
|
{record.resource_id || 'N/A'}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -271,13 +213,13 @@ const Audit: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'IP Address',
|
title: 'IP Address',
|
||||||
dataIndex: 'ip_address',
|
dataIndex: 'actor_ip',
|
||||||
key: 'ip_address',
|
key: 'actor_ip',
|
||||||
render: (ip: string) => <Text code>{ip}</Text>,
|
render: (ip: string) => <Text code>{ip || 'N/A'}</Text>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const expandedRowRender = (record: AuditLogEntry) => (
|
const expandedRowRender = (record: AuditEvent) => (
|
||||||
<Card size="small" title="Event Details">
|
<Card size="small" title="Event Details">
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
@ -360,7 +302,7 @@ const Audit: React.FC = () => {
|
|||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<UserOutlined style={{ fontSize: '32px', color: '#722ed1', marginBottom: '8px' }} />
|
<UserOutlined style={{ fontSize: '32px', color: '#722ed1', marginBottom: '8px' }} />
|
||||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
||||||
{new Set(filteredData.map(e => e.user_id)).size}
|
{new Set(filteredData.map(e => e.actor_id).filter(id => id)).size}
|
||||||
</div>
|
</div>
|
||||||
<div>Unique Users</div>
|
<div>Unique Users</div>
|
||||||
</div>
|
</div>
|
||||||
@ -401,12 +343,14 @@ const Audit: React.FC = () => {
|
|||||||
onChange={(value) => setFilters({ ...filters, action: value })}
|
onChange={(value) => setFilters({ ...filters, action: value })}
|
||||||
allowClear
|
allowClear
|
||||||
>
|
>
|
||||||
<Option value="CREATE_APPLICATION">Create Application</Option>
|
<Option value="app.created">Application Created</Option>
|
||||||
<Option value="UPDATE_APPLICATION">Update Application</Option>
|
<Option value="app.updated">Application Updated</Option>
|
||||||
<Option value="DELETE_APPLICATION">Delete Application</Option>
|
<Option value="app.deleted">Application Deleted</Option>
|
||||||
<Option value="CREATE_TOKEN">Create Token</Option>
|
<Option value="auth.token_created">Token Created</Option>
|
||||||
<Option value="DELETE_TOKEN">Delete Token</Option>
|
<Option value="auth.token_revoked">Token Revoked</Option>
|
||||||
<Option value="VERIFY_TOKEN">Verify Token</Option>
|
<Option value="auth.token_validated">Token Validated</Option>
|
||||||
|
<Option value="auth.login">Login</Option>
|
||||||
|
<Option value="auth.login_failed">Login Failed</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={4}>
|
<Col span={4}>
|
||||||
@ -465,16 +409,16 @@ const Audit: React.FC = () => {
|
|||||||
color={entry.status === 'success' ? 'green' : entry.status === 'failure' ? 'red' : 'orange'}
|
color={entry.status === 'success' ? 'green' : entry.status === 'failure' ? 'red' : 'orange'}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Text strong>{entry.action.replace(/_/g, ' ')}</Text>
|
<Text strong>{entry.type.replace(/_/g, ' ').replace(/\./g, ' ')}</Text>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
{entry.user_id} • {dayjs(entry.timestamp).fromNow()}
|
{entry.actor_id || 'System'} • {dayjs(entry.timestamp).fromNow()}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Tag>{entry.resource_type}</Tag>
|
<Tag>{entry.resource_type || 'N/A'}</Tag>
|
||||||
<Text type="secondary" style={{ marginLeft: '8px' }}>
|
<Text type="secondary" style={{ marginLeft: '8px' }}>
|
||||||
{entry.resource_id}
|
{entry.resource_id || 'N/A'}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -485,13 +429,15 @@ const Audit: React.FC = () => {
|
|||||||
|
|
||||||
{/* Audit Log Table */}
|
{/* Audit Log Table */}
|
||||||
<Card title="Audit Log Entries">
|
<Card title="Audit Log Entries">
|
||||||
<Alert
|
{filteredData.length === 0 && !loading && (
|
||||||
message="Demo Data"
|
<Alert
|
||||||
description="This audit log shows simulated data for demonstration purposes. In production, this would display real audit events from your KMS system."
|
message="No Audit Events Found"
|
||||||
type="info"
|
description="No audit events match your current filters. Try adjusting the filters or check if any events have been logged to the system."
|
||||||
showIcon
|
type="info"
|
||||||
style={{ marginBottom: '16px' }}
|
showIcon
|
||||||
/>
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@ -90,6 +90,59 @@ export interface VerifyResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
timestamp: string;
|
||||||
|
actor_id?: string;
|
||||||
|
actor_ip?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
resource_id?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
details?: Record<string, any>;
|
||||||
|
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<string, number>;
|
||||||
|
by_severity: Record<string, number>;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
by_time?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditStatsParams {
|
||||||
|
event_types?: string[];
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
|
group_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
@ -204,6 +257,55 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audit endpoints
|
||||||
|
async getAuditEvents(params?: AuditQueryParams): Promise<AuditResponse> {
|
||||||
|
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<AuditEvent> {
|
||||||
|
const response = await this.api.get(`/api/audit/events/${eventId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuditStats(params?: AuditStatsParams): Promise<AuditStats> {
|
||||||
|
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();
|
export const apiService = new ApiService();
|
||||||
|
|||||||
Reference in New Issue
Block a user