mirror of
https://github.com/lnbits/lnbits.git
synced 2025-04-07 11:28:19 +02:00
feat: add request details
This commit is contained in:
parent
e75e3eaba6
commit
b9370526e9
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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 = ''
|
||||
},
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user