Skip to content

Commit 3ea54c8

Browse files
committed
feat: enhance audit log functionality with status code filtering
- Added `status_code` as an optional filter in the `get_audit_logs` API. - Updated the audit log metadata endpoint to include distinct status codes. - Modified the frontend to support filtering by status codes with a new multi-select component. - Implemented a utility function to provide descriptions for common HTTP status codes. - Added comprehensive tests for the new status code filtering feature.
1 parent 20324a6 commit 3ea54c8

File tree

5 files changed

+775
-9
lines changed

5 files changed

+775
-9
lines changed

app/api/audit.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
from sqlalchemy.orm import Session
33
from typing import List, Optional
44
from datetime import datetime
5+
from sqlalchemy import distinct, text, cast, String
56
from app.db.database import get_db
67
from app.api.auth import get_current_user_from_auth
78
from app.schemas.models import AuditLogResponse, PaginatedAuditLogResponse, AuditLogMetadata
89
from app.db.models import DBAuditLog, DBUser
9-
from sqlalchemy import distinct
1010
import logging
1111

1212
logger = logging.getLogger(__name__)
@@ -25,11 +25,12 @@ async def get_audit_logs(
2525
user_email: Optional[str] = None,
2626
from_date: Optional[datetime] = None,
2727
to_date: Optional[datetime] = None,
28+
status_code: Optional[str] = None,
2829
):
2930
"""
3031
Retrieve audit logs with optional filtering.
3132
Only accessible by admin users.
32-
event_type and resource_type can be comma-separated lists for multiple values.
33+
event_type, resource_type, and status_code can be comma-separated lists for multiple values.
3334
"""
3435
if not current_user.is_admin:
3536
logger.warning(f"Non-admin user {current_user.id} attempted to access audit logs")
@@ -56,6 +57,9 @@ async def get_audit_logs(
5657
query = query.filter(DBAuditLog.timestamp >= from_date)
5758
if to_date:
5859
query = query.filter(DBAuditLog.timestamp <= to_date)
60+
if status_code:
61+
status_codes = [sc.strip() for sc in status_code.split(',')]
62+
query = query.filter(cast(DBAuditLog.details['status_code'], String).in_(status_codes))
5963

6064
# Get total count
6165
total = query.count()
@@ -96,7 +100,7 @@ async def get_audit_logs_metadata(
96100
current_user: DBUser = Depends(get_current_user_from_auth),
97101
):
98102
"""
99-
Retrieve distinct event types and resource types from audit logs.
103+
Retrieve distinct event types, resource types, and status codes from audit logs.
100104
Only accessible by admin users.
101105
"""
102106
if not current_user.is_admin:
@@ -117,9 +121,16 @@ async def get_audit_logs_metadata(
117121
.filter(DBAuditLog.resource_type != '')
118122
.all()]
119123

124+
# Get distinct status codes from the details JSON field
125+
status_codes = [sc[0] for sc in db.query(distinct(cast(DBAuditLog.details['status_code'], String)))
126+
.filter(cast(DBAuditLog.details['status_code'], String).isnot(None))
127+
.filter(cast(DBAuditLog.details['status_code'], String) != '')
128+
.all()]
129+
120130
return {
121131
"event_types": sorted(event_types),
122-
"resource_types": sorted(resource_types)
132+
"resource_types": sorted(resource_types),
133+
"status_codes": sorted(status_codes, key=lambda x: int(x) if x.isdigit() else 0)
123134
}
124135

125136
except Exception as e:

app/schemas/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ class PaginatedAuditLogResponse(BaseModel):
229229
class AuditLogMetadata(BaseModel):
230230
event_types: List[str]
231231
resource_types: List[str]
232+
status_codes: List[str]
232233
model_config = ConfigDict(from_attributes=True)
233234

234235
# Team schemas

frontend/src/app/admin/audit-logs/page.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,37 @@ interface AuditLogFilters {
4545
user_email?: string;
4646
from_date?: string;
4747
to_date?: string;
48+
status_code?: string[];
4849
}
4950

5051
const ITEMS_PER_PAGE = 20;
5152

53+
const getStatusCodeDescription = (code: number): string => {
54+
const descriptions: Record<number, string> = {
55+
200: 'OK',
56+
201: 'Created',
57+
204: 'No Content',
58+
301: 'Moved Permanently',
59+
302: 'Found',
60+
304: 'Not Modified',
61+
307: 'Temporary Redirect',
62+
308: 'Permanent Redirect',
63+
400: 'Bad Request',
64+
401: 'Unauthorized',
65+
403: 'Forbidden',
66+
404: 'Not Found',
67+
405: 'Method Not Allowed',
68+
409: 'Conflict',
69+
422: 'Unprocessable Entity',
70+
429: 'Too Many Requests',
71+
500: 'Internal Server Error',
72+
502: 'Bad Gateway',
73+
503: 'Service Unavailable',
74+
504: 'Gateway Timeout',
75+
};
76+
return descriptions[code] || 'Unknown';
77+
};
78+
5279
export default function AuditLogsPage() {
5380
const { toast } = useToast();
5481
const [filters, setFilters] = useState<AuditLogFilters>({
@@ -61,6 +88,7 @@ export default function AuditLogsPage() {
6188
const [itemsPerPage] = useState<number>(ITEMS_PER_PAGE);
6289
const [eventTypeOptions, setEventTypeOptions] = useState<{ value: string; label: string }[]>([]);
6390
const [resourceTypeOptions, setResourceTypeOptions] = useState<{ value: string; label: string }[]>([]);
91+
const [statusCodeOptions, setStatusCodeOptions] = useState<{ value: string; label: string }[]>([]);
6492

6593
const fetchMetadata = useCallback(async () => {
6694
try {
@@ -85,6 +113,15 @@ export default function AuditLogsPage() {
85113
label: type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(),
86114
}))
87115
);
116+
117+
setStatusCodeOptions(
118+
(data.status_codes || [])
119+
.filter(Boolean)
120+
.map((code: string) => ({
121+
value: code,
122+
label: `${code} - ${getStatusCodeDescription(parseInt(code))}`,
123+
}))
124+
);
88125
} catch (error) {
89126
console.error('Error fetching audit logs metadata:', error);
90127
toast({
@@ -107,6 +144,7 @@ export default function AuditLogsPage() {
107144
...(filters.event_type?.length && { event_type: filters.event_type.join(',') }),
108145
...(filters.resource_type?.length && { resource_type: filters.resource_type.join(',') }),
109146
...(filters.user_email && { user_email: filters.user_email }),
147+
...(filters.status_code?.length && { status_code: filters.status_code.join(',') }),
110148
}).toString();
111149

112150
const response = await get(`audit/logs?${queryParams}`, { credentials: 'include' });
@@ -179,7 +217,7 @@ export default function AuditLogsPage() {
179217
<CardTitle>Filters</CardTitle>
180218
</CardHeader>
181219
<CardContent>
182-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
220+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
183221
<div className="space-y-2">
184222
<label className="text-sm font-medium">Event Type</label>
185223
<MultiSelect
@@ -197,6 +235,18 @@ export default function AuditLogsPage() {
197235
onValueChange={(value) => handleFilterChange('resource_type', value)}
198236
defaultValue={filters.resource_type || []}
199237
placeholder="Select Resources"
238+
variant="default"
239+
/>
240+
</div>
241+
242+
<div className="space-y-2">
243+
<label className="text-sm font-medium">Status Code</label>
244+
<MultiSelect
245+
options={statusCodeOptions}
246+
onValueChange={(value) => handleFilterChange('status_code', value)}
247+
defaultValue={filters.status_code || []}
248+
placeholder="Select Status Codes"
249+
variant="default"
200250
/>
201251
</div>
202252

frontend/src/components/ui/multi-select.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,19 @@ export const MultiSelect = React.forwardRef<
248248
</Badge>
249249
)}
250250
</div>
251-
<div className="flex items-center justify-between">
251+
<div className="flex items-center justify-between gap-1">
252252
<XIcon
253-
className="h-4 mx-2 cursor-pointer text-muted-foreground"
253+
className="h-4 mx-2 cursor-pointer text-muted-foreground flex items-center justify-center"
254254
onClick={(event) => {
255255
event.stopPropagation();
256256
handleClear();
257257
}}
258258
/>
259259
<Separator
260260
orientation="vertical"
261-
className="flex min-h-6 h-full"
261+
className="flex min-h-6 h-full mx-1"
262262
/>
263-
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
263+
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground flex items-center justify-center" />
264264
</div>
265265
</div>
266266
) : (

0 commit comments

Comments
 (0)