feat: add request details

This commit is contained in:
Vlad Stan 2024-11-20 15:08:41 +02:00
parent e75e3eaba6
commit b9370526e9
9 changed files with 184 additions and 84 deletions

View File

@ -674,10 +674,9 @@ async def m029_create_audit_table(db):
ip_address TEXT,
user_id TEXT,
path TEXT,
route_path TEXT,
request_type TEXT,
request_method TEXT,
query_string TEXT,
request_details TEXT,
response_code TEXT,
duration REAL NOT NULL,
delete_at TIMESTAMP,

View File

@ -14,10 +14,9 @@ class AuditEntry(BaseModel):
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
route_path: Optional[str] = None
request_type: Optional[str] = None
request_method: Optional[str] = None
query_string: Optional[str] = None
request_details: Optional[str] = None
response_code: Optional[str] = None
duration: float
delete_at: Optional[datetime] = None
@ -34,7 +33,6 @@ class AuditFilters(FilterModel):
"ip_address",
"user_id",
"path",
"route_path",
"request_method",
"response_code",
]
@ -46,6 +44,5 @@ class AuditFilters(FilterModel):
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
route_path: Optional[str] = None
request_method: Optional[str] = None
response_code: Optional[str] = None

View File

@ -2,8 +2,8 @@
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<div class="row q-mb-lg">
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
@ -23,7 +23,30 @@
</q-item-section>
</q-item>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row">
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_request_body"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Request Body</q-item-label>
<q-item-label caption
>Warning: the request body can have large size. Use it with
caution.</q-item-label
>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
@ -36,15 +59,46 @@
</q-item-section>
<q-item-section>
<q-item-label>Record IP Address</q-item-label>
<q-item-label caption
>If disabled audit entries will not record the IP
address</q-item-label
>
<q-item-label caption>Save the client IP address.</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_path_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Path Parameters</q-item-label>
<q-item-label caption>Recommended. </q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_query_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Query Parameters</q-item-label>
<q-item-label caption>Recommended.</q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include HTTP Methods</p>
@ -65,7 +119,7 @@
@keydown.enter="addIncludeResponseCode"
type="text"
label="HTTP Response code (regex)"
hint="List of HTTP codes to be included (regex match). Empty lists means all."
hint="List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*"
>
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
</q-input>
@ -83,7 +137,7 @@
</div>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include Paths</p>

View File

@ -17,7 +17,7 @@
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path', 'route_path', 'request_method', 'request_type', 'response_code',].includes(col.name)"
v-if="['ip_address', 'user_id', 'path', 'request_method', 'response_code',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy(col.name)"
dense
@ -41,11 +41,28 @@
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span
v-if="col.name == 'created_at'"
v-text="formatDate(props.row.created_at)"
></span>
<div v-else-if="['user_id', 'query_string'].includes(col.name)">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Request Details</q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"

View File

@ -1,4 +1,5 @@
import asyncio
import json
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any, List, Optional, Union
@ -135,23 +136,29 @@ class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
start_time = datetime.now(timezone.utc)
request_details = await self._request_details(request)
response: Optional[Response] = None
try:
response = await call_next(request)
assert response
return response
finally:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
await self._log_audit(request, response, duration)
await self._log_audit(request, response, duration, request_details)
async def _log_audit(
self, request: Request, response: Optional[Response], duration: float
self,
request: Request,
response: Optional[Response],
duration: float,
request_details: Optional[str],
):
try:
http_method = request.scope.get("method", None)
path = request.scope.get("path", None)
response_code = str(response.status_code) if response else None
if not settings.is_http_request_auditable(http_method, path, response_code):
if not settings.audit_http_request(http_method, path, response_code):
return None
ip_address = (
request.client.host
@ -161,11 +168,10 @@ class AuditMiddleware(BaseHTTPMiddleware):
data = AuditEntry(
ip_address=ip_address,
user_id=request.scope.get("user_id", None),
path=path,
route_path=getattr(request.scope.get("route", {}), "path", None),
path=getattr(request.scope.get("route", {}), "path", None),
request_type=request.scope.get("type", None),
request_method=http_method,
query_string=request.scope.get("query_string", None),
request_details=request_details,
response_code=response_code,
duration=duration,
)
@ -173,6 +179,30 @@ class AuditMiddleware(BaseHTTPMiddleware):
except Exception as ex:
logger.warning(ex)
async def _request_details(self, request: Request) -> Optional[str]:
if not settings.audit_http_request_details():
return None
try:
http_method = request.scope.get("method", None)
path = request.scope.get("path", None)
if not settings.audit_http_request(http_method, path):
return None
details: dict = {}
if settings.lnbits_audit_log_path_params:
details["path_params"] = request.path_params
if settings.lnbits_audit_log_query_params:
details["query_params"] = dict(request.query_params)
if settings.lnbits_audit_log_request_body:
_body = await request.body()
details["body"] = _body.decode("utf-8")
return json.dumps(details)
except Exception as e:
logger.warning(e)
return None
def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter()

View File

@ -517,6 +517,9 @@ class AuditSettings(LNbitsSettings):
lnbits_audit_retention_days: int = Field(default=7)
lnbits_audit_log_ip_address: bool = Field(default=False)
lnbits_audit_log_path_params: bool = Field(default=True)
lnbits_audit_log_query_params: bool = Field(default=True)
lnbits_audit_log_request_body: bool = Field(default=False)
# List of paths to be included (regex match). Empty list means all.
lnbits_audit_include_paths: list[str] = Field(default=[".*api/v1/.*"])
@ -534,31 +537,32 @@ class AuditSettings(LNbitsSettings):
# List of HTTP codes to be included (regex match). Empty lists means all.
lnbits_audit_http_response_codes: list[str] = Field(default=[])
def is_http_request_auditable(
def audit_http_request_details(self) -> bool:
return (
self.lnbits_audit_log_path_params
or self.lnbits_audit_log_query_params
or self.lnbits_audit_log_request_body
)
def audit_http_request(
self,
http_method: Optional[str],
path: Optional[str],
http_response_code: Optional[str],
http_method: Optional[str] = None,
path: Optional[str] = None,
http_response_code: Optional[str] = None,
) -> bool:
if not self.lnbits_audit_enabled:
return False
if len(self.lnbits_audit_http_methods) != 0:
if not http_method or http_method not in self.lnbits_audit_http_methods:
if not http_method:
return False
if http_method not in self.lnbits_audit_http_methods:
return False
if not self._is_http_request_path_auditable(path):
return False
if len(self.lnbits_audit_http_response_codes) != 0:
is_response_code_included = True
if not http_response_code:
return False
for response_code in self.lnbits_audit_http_response_codes:
if _re_fullmatch_safe(response_code, http_response_code):
is_response_code_included = True
break
if not is_response_code_included:
return False
if not self._is_http_response_code_auditable(http_response_code):
return False
return True
@ -577,6 +581,22 @@ class AuditSettings(LNbitsSettings):
return False
def _is_http_response_code_auditable(
self, http_response_code: Optional[str]
) -> bool:
if not http_response_code:
# No response code means only request filters should apply
return True
if len(self.lnbits_audit_http_response_codes) == 0:
return True
for response_code in self.lnbits_audit_http_response_codes:
if _re_fullmatch_safe(response_code, http_response_code):
return True
return False
class EditableSettings(
UsersSettings,

View File

@ -112,7 +112,10 @@ window.app = Vue.createApp({
}
const paths = this.formData.lnbits_audit_include_paths
if (!paths.includes(this.formAddIncludePath)) {
paths.push(this.formAddIncludePath)
this.formData.lnbits_audit_include_paths = [
...paths,
this.formAddIncludePath
]
}
this.formAddIncludePath = ''
},
@ -126,7 +129,10 @@ window.app = Vue.createApp({
}
const paths = this.formData.lnbits_audit_exclude_paths
if (!paths.includes(this.formAddExcludePath)) {
paths.push(this.formAddExcludePath)
this.formData.lnbits_audit_exclude_paths = [
...paths,
this.formAddExcludePath
]
}
this.formAddExcludePath = ''
},
@ -139,9 +145,12 @@ window.app = Vue.createApp({
if (!this.formAddIncludeResponseCode) {
return
}
const paths = this.formData.lnbits_audit_http_response_codes
if (!paths.includes(this.formAddIncludeResponseCode)) {
paths.push(this.formAddIncludeResponseCode)
const codes = this.formData.lnbits_audit_http_response_codes
if (!codes.includes(this.formAddIncludeResponseCode)) {
this.formData.lnbits_audit_http_response_codes = [
...codes,
this.formAddIncludeResponseCode
]
}
this.formAddIncludeResponseCode = ''
},

View File

@ -10,14 +10,13 @@ window.app = Vue.createApp({
request_type: '',
request_method: '',
response_code: '',
path: '',
route_path: ''
path: ''
},
auditTable: {
columns: [
{
name: 'created_at',
align: 'left',
align: 'center',
label: 'Date',
field: 'created_at',
sortable: true
@ -29,7 +28,13 @@ window.app = Vue.createApp({
field: 'duration',
sortable: true
},
{
name: 'request_method',
align: 'left',
label: 'Method',
field: 'request_method',
sortable: false
},
{
name: 'user_id',
align: 'left',
@ -44,21 +49,6 @@ window.app = Vue.createApp({
field: 'ip_address',
sortable: false
},
{
name: 'request_type',
align: 'left',
label: 'Type',
field: 'request_type',
sortable: false
},
{
name: 'request_method',
align: 'left',
label: 'Method',
field: 'request_method',
sortable: false
},
{
name: 'response_code',
align: 'left',
@ -72,22 +62,6 @@ window.app = Vue.createApp({
label: 'Path',
field: 'path',
sortable: false
},
{
name: 'route_path',
align: 'left',
label: 'Route Path',
field: 'route_path',
sortable: false
},
{
name: 'query_string',
align: 'left',
label: 'Query',
field: 'query_string',
sortable: false
}
],
pagination: {

View File

@ -205,7 +205,7 @@
<q-item-section side>
<q-icon
name="playlist_add_check_circle"
:color="isActive('/users') ? 'primary' : 'grey-5'"
:color="isActive('/audit') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>