audit logs

This commit is contained in:
2025-08-25 21:42:41 -04:00
parent 19364fcc76
commit b39da8d233
4 changed files with 477 additions and 160 deletions

View File

@ -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",
},
},
})
})

254
internal/handlers/audit.go Normal file
View 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)
}

View File

@ -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<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 [auditData, setAuditData] = useState<AuditLogEntry[]>(mockAuditData);
const [filteredData, setFilteredData] = useState<AuditLogEntry[]>(mockAuditData);
const [loading, setLoading] = useState(false);
const [auditData, setAuditData] = useState<AuditEvent[]>([]);
const [filteredData, setFilteredData] = useState<AuditEvent[]>([]);
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 = () => {
</Text>
</div>
),
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) => (
<div>
<UserOutlined style={{ marginRight: '8px' }} />
{userId}
{actorId || 'System'}
</div>
),
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
render: (action: string) => (
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<div>
{getActionIcon(action)}
<span style={{ marginLeft: '8px' }}>{action.replace(/_/g, ' ')}</span>
{getActionIcon(type)}
<span style={{ marginLeft: '8px' }}>{type.replace(/_/g, ' ').replace(/\./g, ' ')}</span>
</div>
),
},
{
title: 'Resource',
key: 'resource',
render: (_: any, record: AuditLogEntry) => (
render: (_: any, record: AuditEvent) => (
<div>
<div>
<Tag color="blue">{record.resource_type.toUpperCase()}</Tag>
<Tag color="blue">{record.resource_type?.toUpperCase() || 'N/A'}</Tag>
</div>
<Text type="secondary" style={{ fontSize: '12px' }}>
{record.resource_id}
{record.resource_id || 'N/A'}
</Text>
</div>
),
@ -271,13 +213,13 @@ const Audit: React.FC = () => {
},
{
title: 'IP Address',
dataIndex: 'ip_address',
key: 'ip_address',
render: (ip: string) => <Text code>{ip}</Text>,
dataIndex: 'actor_ip',
key: 'actor_ip',
render: (ip: string) => <Text code>{ip || 'N/A'}</Text>,
},
];
const expandedRowRender = (record: AuditLogEntry) => (
const expandedRowRender = (record: AuditEvent) => (
<Card size="small" title="Event Details">
<Row gutter={16}>
<Col span={12}>
@ -360,7 +302,7 @@ const Audit: React.FC = () => {
<div style={{ textAlign: 'center' }}>
<UserOutlined style={{ fontSize: '32px', color: '#722ed1', marginBottom: '8px' }} />
<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>Unique Users</div>
</div>
@ -401,12 +343,14 @@ const Audit: React.FC = () => {
onChange={(value) => setFilters({ ...filters, action: value })}
allowClear
>
<Option value="CREATE_APPLICATION">Create Application</Option>
<Option value="UPDATE_APPLICATION">Update Application</Option>
<Option value="DELETE_APPLICATION">Delete Application</Option>
<Option value="CREATE_TOKEN">Create Token</Option>
<Option value="DELETE_TOKEN">Delete Token</Option>
<Option value="VERIFY_TOKEN">Verify Token</Option>
<Option value="app.created">Application Created</Option>
<Option value="app.updated">Application Updated</Option>
<Option value="app.deleted">Application Deleted</Option>
<Option value="auth.token_created">Token Created</Option>
<Option value="auth.token_revoked">Token Revoked</Option>
<Option value="auth.token_validated">Token Validated</Option>
<Option value="auth.login">Login</Option>
<Option value="auth.login_failed">Login Failed</Option>
</Select>
</Col>
<Col span={4}>
@ -465,16 +409,16 @@ const Audit: React.FC = () => {
color={entry.status === 'success' ? 'green' : entry.status === 'failure' ? 'red' : 'orange'}
>
<div>
<Text strong>{entry.action.replace(/_/g, ' ')}</Text>
<Text strong>{entry.type.replace(/_/g, ' ').replace(/\./g, ' ')}</Text>
<div>
<Text type="secondary">
{entry.user_id} {dayjs(entry.timestamp).fromNow()}
{entry.actor_id || 'System'} {dayjs(entry.timestamp).fromNow()}
</Text>
</div>
<div>
<Tag>{entry.resource_type}</Tag>
<Tag>{entry.resource_type || 'N/A'}</Tag>
<Text type="secondary" style={{ marginLeft: '8px' }}>
{entry.resource_id}
{entry.resource_id || 'N/A'}
</Text>
</div>
</div>
@ -485,13 +429,15 @@ const Audit: React.FC = () => {
{/* Audit Log Table */}
<Card title="Audit Log Entries">
<Alert
message="Demo Data"
description="This audit log shows simulated data for demonstration purposes. In production, this would display real audit events from your KMS system."
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
{filteredData.length === 0 && !loading && (
<Alert
message="No Audit Events Found"
description="No audit events match your current filters. Try adjusting the filters or check if any events have been logged to the system."
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
)}
<Table
columns={columns}

View File

@ -90,6 +90,59 @@ export interface VerifyResponse {
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 {
private api: AxiosInstance;
private baseURL: string;
@ -204,6 +257,55 @@ class ApiService {
});
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();