Skip to content

Commit 35918c0

Browse files
committed
feat: audit log and structured logging
WIP feat: audit log and structured logging
1 parent d671831 commit 35918c0

11 files changed

Lines changed: 535 additions & 14 deletions

File tree

Dockerfile

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ FROM docker.io/python:3.13.5-slim
22

33
WORKDIR /app
44

5+
# hadolint ignore=DL3008
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends jq && rm -rf /var/lib/apt/lists/*
8+
59
COPY requirements.txt /app
610
ENV PATH=/venv/bin:$PATH
711
RUN : \
8-
&& python3 -m venv /venv \
9-
&& pip --no-cache-dir install -r requirements.txt
12+
&& python3 -m venv /venv \
13+
&& pip --no-cache-dir install -r requirements.txt
1014

1115
COPY . /app
1216

@@ -18,9 +22,9 @@ ENV PYTHONUNBUFFERED=1
1822

1923
# Creates a non-root user with an explicit UID and adds permission to access the /app folder
2024
RUN : \
21-
&& adduser -u 1000 --disabled-password --gecos "" appuser \
22-
&& chown -R appuser /app && chmod -R 0750 /app
25+
&& adduser -u 1000 --disabled-password --gecos "" appuser \
26+
&& chown -R appuser /app && chmod -R 0750 /app
2327
USER appuser
2428

2529

26-
CMD ["uvicorn", "--host", "*", "--port", "8000", "powerdns_api_proxy.proxy:app"]
30+
CMD ["python", "-m", "powerdns_api_proxy"]

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ integration: ## run integration tests
1919
python -m pytest -vvl --setup-show -vvl tests/integration/ --showlocals
2020

2121
run: ## run project
22-
uvicorn --host 0.0.0.0 --port 8000 --reload powerdns_api_proxy.proxy:app
22+
python -m powerdns_api_proxy --reload
2323

2424
clean: ## clean cache and temp dirs
2525
rm -rf ./.mypy_cache ./.pytest_cache
@@ -38,4 +38,4 @@ pre-commit: ## run pre-commit
3838
pre-commit run
3939

4040
run-docker-debug: build-docker ## run debug with docker on port 8000
41-
docker run --rm -it -v "${PWD}:/app" -p 8000:8000 -e "PROXY_CONFIG_PATH=${PROXY_CONFIG_PATH}" --rm $(PROJECT_NAME):test uvicorn --host 0.0.0.0 --port 8000 --reload powerdns_api_proxy.proxy:app
41+
docker run --rm -it -v "${PWD}:/app" -p 8000:8000 -e "PROXY_CONFIG_PATH=${PROXY_CONFIG_PATH}" --rm $(PROJECT_NAME):test python -m powerdns_api_proxy --reload

README.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ Within a zone, the token can be limited to one or more records.
1515
Containers are available under [Packages](https://github.com/akquinet/powerdns-api-proxy/pkgs/container/powerdns-api-proxy).
1616

1717
```bash
18-
docker run -v config:/config -e PROXY_CONFIG_PATH=/config/config.yaml -e LOG_LEVEL=WARNING --name powerdns-api-proxy ghcr.io/akquinet/powerdns-api-proxy:latest
18+
docker run -v config:/config \
19+
-e PROXY_CONFIG_PATH=/config/config.yaml \
20+
-e LOG_LEVEL=INFO \
21+
-e LOG_FORMAT=json \
22+
--name powerdns-api-proxy \
23+
ghcr.io/akquinet/powerdns-api-proxy:latest
1924
```
2025

2126
### Authentication
@@ -303,6 +308,78 @@ index_enabled: false # default is true
303308
index_html: "<html><body><h1>PowerDNS API Proxy</h1></body></html>"
304309
```
305310

311+
## Logging
312+
313+
### Environment Variables
314+
315+
```bash
316+
LOG_LEVEL=DEBUG # Optional: DEBUG, INFO, WARNING, ERROR (default: DEBUG)
317+
LOG_FORMAT=json # Optional: "text" (default) or "json" for structured logging
318+
ALLOWED_HOSTS=0.0.0.0 # Optional: Host to bind to (default: 0.0.0.0)
319+
LISTEN_PORT=8000 # Optional: Port to listen on (default: 8000)
320+
```
321+
322+
When `LOG_FORMAT=json` is set, all logs (application and uvicorn access logs) will be output in JSON format.
323+
324+
Set `AUDIT_LOGGING=false` to disable automatic audit logging of API requests.
325+
326+
### Audit Logging
327+
328+
All API operations (GET, POST, PUT, PATCH, DELETE) are automatically logged to stderr with structured data via middleware.
329+
330+
This includes both successful operations and forbidden attempts (HTTP 403).
331+
332+
Each audit log entry contains:
333+
334+
* `timestamp`: ISO 8601 timestamp
335+
* `level`: Log level (INFO)
336+
* `event_type`: "audit" for easy filtering
337+
* `audit`: Structured audit data object containing:
338+
* `environment`: Name of the authenticated environment/token
339+
* `method`: HTTP method (GET, POST, PUT, PATCH, DELETE)
340+
* `path`: Resource path that was accessed/modified
341+
* `status_code`: HTTP response status code
342+
* `payload`: Request payload (optional, for write operations; omitted for sensitive endpoints like `/cryptokeys` and `/tsigkeys`)
343+
* `query_params`: Query parameters (optional, for GET requests)
344+
345+
#### Text Format (default)
346+
347+
```
348+
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 PATCH /zones/example.com 204 payload={'rrsets': []}
349+
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 GET /zones 200 query_params={'rrsets': 'true'}
350+
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 DELETE /zones/test.com 403
351+
```
352+
353+
#### JSON Format (set LOG_FORMAT=json)
354+
355+
```json
356+
{"timestamp": "2026-03-26T11:39:29", "level": "INFO", "event_type": "audit", "audit": {"environment": "Test1", "method": "PATCH", "path": "/zones/example.com", "status_code": 204, "payload": {"rrsets": [...]}}}
357+
```
358+
359+
#### Analyzing Audit Logs
360+
361+
With JSON format enabled, you can use `jq` to filter and analyze audit logs:
362+
363+
```bash
364+
# Filter only audit events
365+
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit")'
366+
367+
# Filter by environment
368+
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.environment == "Test1")'
369+
370+
# Filter by HTTP method
371+
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.method == "PATCH")'
372+
373+
# Filter by zone
374+
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and (.audit.path | contains("/zones/example.com")))'
375+
376+
# Extract only key audit fields
377+
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit") | {timestamp, environment: .audit.environment, method: .audit.method, path: .audit.path, status: .audit.status_code}'
378+
379+
# Show failed attempts (403)
380+
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.status_code == 403)'
381+
```
382+
306383
## Development
307384

308385
### Install requirements

powerdns_api_proxy/__main__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
1+
import os
2+
import sys
3+
import uvicorn
4+
from powerdns_api_proxy.uvicorn_config import LOGGING_CONFIG
5+
6+
17
def main() -> int:
2-
print("Please start me with uvicorn :)")
3-
return 1
8+
host = os.getenv("ALLOWED_HOSTS", "0.0.0.0")
9+
port = int(os.getenv("LISTEN_PORT", "8000"))
10+
reload = "--reload" in sys.argv
11+
12+
uvicorn.run(
13+
"powerdns_api_proxy.proxy:app",
14+
host=host,
15+
port=port,
16+
log_config=LOGGING_CONFIG,
17+
reload=reload,
18+
)
19+
return 0
420

521

622
if __name__ == "__main__":

powerdns_api_proxy/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ def load_config(path: Optional[Path] = None) -> ProxyConfig:
3535
with open(path) as f:
3636
data = safe_load(f)
3737

38-
return ProxyConfig(**data)
38+
config = ProxyConfig(**data)
39+
40+
return config
3941

4042

4143
def token_defined(config: ProxyConfig, token: str) -> bool:

powerdns_api_proxy/logging.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,96 @@
11
import logging
22
import logging.handlers
3+
import json
34
from os import getenv
45
from sys import stderr
56

67
LOG_LEVEL = getenv("LOG_LEVEL") or "DEBUG"
8+
LOG_FORMAT = getenv("LOG_FORMAT", "text") # text or json
79

810
logging_format = (
911
"%(levelname)s - %(asctime)s - %(name)s - "
1012
+ "%(filename)s - %(funcName)s - %(lineno)s - %(message)s"
1113
)
1214

13-
default_formatter = logging.Formatter(logging_format)
15+
16+
class JSONFormatter(logging.Formatter):
17+
"""Format log records as JSON for structured logging"""
18+
19+
def format(self, record: logging.LogRecord) -> str:
20+
log_data: dict[str, str | int | dict] = {
21+
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
22+
"level": record.levelname,
23+
}
24+
25+
# Special handling for audit logs
26+
if hasattr(record, "audit"):
27+
log_data["event_type"] = "audit"
28+
log_data["audit"] = record.audit
29+
else:
30+
# Regular logs include full context
31+
log_data["event_type"] = "log"
32+
log_data["logger"] = record.name
33+
log_data["message"] = record.getMessage()
34+
log_data["module"] = record.module
35+
log_data["function"] = record.funcName
36+
log_data["line"] = record.lineno
37+
38+
if record.exc_info:
39+
log_data["exception"] = self.formatException(record.exc_info)
40+
41+
return json.dumps(log_data)
42+
43+
44+
class AuditLogger(logging.Logger):
45+
"""Custom logger with audit() method"""
46+
47+
def audit(
48+
self,
49+
environment: str,
50+
method: str,
51+
path: str,
52+
status_code: int,
53+
payload: dict | None = None,
54+
query_params: dict | None = None,
55+
):
56+
"""Log audit events with structured data"""
57+
# Skip payload logging for sensitive endpoints
58+
if payload is not None and any(
59+
sensitive in path for sensitive in ["/cryptokeys", "/tsigkeys"]
60+
):
61+
payload = None
62+
63+
audit_data = {
64+
"environment": environment,
65+
"method": method,
66+
"path": path,
67+
"status_code": status_code,
68+
}
69+
if payload is not None:
70+
audit_data["payload"] = payload
71+
if query_params is not None and query_params:
72+
audit_data["query_params"] = query_params
73+
74+
# Build message with optional query_params/payload info
75+
msg_parts = [f"AUDIT: {environment} {method} {path} {status_code}"]
76+
if query_params:
77+
msg_parts.append(f"query_params={query_params}")
78+
if payload is not None:
79+
msg_parts.append(f"payload={payload}")
80+
81+
self.info(
82+
" ".join(msg_parts),
83+
extra={"audit": audit_data},
84+
stacklevel=2,
85+
)
86+
87+
88+
logging.setLoggerClass(AuditLogger)
89+
90+
if LOG_FORMAT == "json":
91+
default_formatter: logging.Formatter = JSONFormatter()
92+
else:
93+
default_formatter = logging.Formatter(logging_format)
1494

1595
default_stream_handler = logging.StreamHandler(stderr)
1696
default_stream_handler.setLevel(LOG_LEVEL)
@@ -22,7 +102,7 @@
22102
file_handler.setLevel("DEBUG")
23103
file_handler.setFormatter(default_formatter)
24104

25-
logger = logging.getLogger("powerdns_api_proxy")
105+
logger: AuditLogger = logging.getLogger("powerdns_api_proxy") # type: ignore
26106
logger.addHandler(default_stream_handler)
27107
logger.addHandler(file_handler)
28108

powerdns_api_proxy/middleware.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import json
2+
import os
3+
4+
from fastapi import Request, Response
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
7+
from powerdns_api_proxy.config import get_environment_for_token, load_config
8+
from powerdns_api_proxy.logging import logger
9+
10+
_config = load_config()
11+
12+
13+
class AuditMiddleware(BaseHTTPMiddleware):
14+
"""Middleware to automatically log all API requests and their response status code"""
15+
16+
async def dispatch(self, request: Request, call_next):
17+
if os.getenv("AUDIT_LOGGING", "true").lower() == "false":
18+
return await call_next(request)
19+
20+
if not request.url.path.startswith("/api/v1/"):
21+
return await call_next(request)
22+
23+
environment_name = "UNAUTHENTICATED"
24+
try:
25+
token = request.headers.get("X-API-Key", "")
26+
if token:
27+
environment = get_environment_for_token(_config, token)
28+
environment_name = environment.name
29+
except Exception:
30+
pass
31+
32+
# Store request body for logging (only for write operations)
33+
payload = None
34+
if request.method in ["POST", "PUT", "PATCH"]:
35+
try:
36+
# Read raw request body bytes; FastAPI/Starlette will cache the body for downstream handlers
37+
body_bytes = await request.body()
38+
if body_bytes:
39+
payload = json.loads(body_bytes)
40+
except Exception:
41+
pass
42+
43+
query_params = dict(request.query_params) if request.query_params else None
44+
45+
path = request.url.path.replace("/api/v1/servers/localhost", "")
46+
status_code = 500
47+
try:
48+
# Call the actual endpoint
49+
response: Response = await call_next(request)
50+
status_code = response.status_code
51+
return response
52+
except Exception:
53+
# Ensure that exceptions are logged as 500 responses while allowing
54+
# FastAPI/Starlette's normal exception handling to proceed.
55+
logger.exception("Unhandled exception during request processing")
56+
raise
57+
finally:
58+
# Log the request, even if an exception occurred
59+
logger.audit(
60+
environment_name,
61+
request.method,
62+
path,
63+
status_code,
64+
payload,
65+
query_params,
66+
)

powerdns_api_proxy/proxy.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
from http import HTTPStatus
44
from typing import Literal
55

6-
from fastapi import APIRouter, Depends, FastAPI, Header, Request, Response
6+
from fastapi import (
7+
APIRouter,
8+
Depends,
9+
FastAPI,
10+
Header,
11+
Request,
12+
Response,
13+
)
714
from fastapi.responses import HTMLResponse, JSONResponse
815
from prometheus_fastapi_instrumentator import Instrumentator, metrics
916
from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -29,6 +36,7 @@
2936
UpstreamException,
3037
)
3138
from powerdns_api_proxy.logging import logger
39+
from powerdns_api_proxy.middleware import AuditMiddleware
3240
from powerdns_api_proxy.metrics import http_requests_total_environment
3341
from powerdns_api_proxy.models import (
3442
ResponseAllowed,
@@ -73,6 +81,8 @@ async def _startup(app: FastAPI):
7381
openapi_url=None,
7482
)
7583

84+
app.add_middleware(AuditMiddleware)
85+
7686
if config.metrics_enabled:
7787
instrumentator = Instrumentator(
7888
should_group_status_codes=False,

0 commit comments

Comments
 (0)