|
| 1 | +""" |
| 2 | +Implementation of an audit‑log storage backend that encrypts logs using GPG. |
| 3 | +
|
| 4 | +The :class:`GPGAuditorLogStorage` class conforms to the |
| 5 | +:class:`~llm_router_api.core.auditor.log_storage.log_storage_interface.AuditorLogStorageInterface` |
| 6 | +protocol. It writes each log entry to a timestamped file inside |
| 7 | +``logs/auditor`` and encrypts the JSON payload with a public GPG key |
| 8 | +located in ``resources/keys``. This ensures that audit data is stored |
| 9 | +at rest in a confidential, tamper‑evident format. |
| 10 | +
|
| 11 | +Typical workflow |
| 12 | +---------------- |
| 13 | +1. An instance of :class:`GPGAuditorLogStorage` is created – the public |
| 14 | + key is imported automatically. |
| 15 | +2. The :meth:`store_log` method is called with a serialisable ``audit_log`` |
| 16 | + and an ``audit_type`` string. |
| 17 | +3. The log is JSON‑encoded, encrypted with the imported key, and written to |
| 18 | + ``logs/auditor/<audit_type>__<timestamp>.audit``. |
| 19 | +""" |
| 20 | + |
| 21 | +import json |
| 22 | +import gnupg |
| 23 | + |
| 24 | +from pathlib import Path |
| 25 | +from datetime import datetime |
| 26 | + |
| 27 | +from llm_router_api.core.auditor.log_storage.log_storage_interface import ( |
| 28 | + AuditorLogStorageInterface, |
| 29 | +) |
| 30 | + |
| 31 | + |
| 32 | +class GPGAuditorLogStorage(AuditorLogStorageInterface): |
| 33 | + """ |
| 34 | + GPG‑backed storage for audit logs. |
| 35 | +
|
| 36 | + The storage writes each log entry to a file under ``DEFAULT_AUDITOR_OUT_DIR`` |
| 37 | + and encrypts the content with the public key found at |
| 38 | + ``AUDITOR_PUB_KEY_FILE``. The key is imported once during initialisation. |
| 39 | +
|
| 40 | + Attributes |
| 41 | + ---------- |
| 42 | + _gpg : gnupg.GPG |
| 43 | + Configured GPG instance pointing at ``AUDITOR_PUB_KEY_DIR``. |
| 44 | + _import_result : gnupg.ImportResult |
| 45 | + Result of importing the public key; contains the fingerprint(s) used |
| 46 | + for encryption. |
| 47 | + """ |
| 48 | + |
| 49 | + # Base output directory for the auditor |
| 50 | + DEFAULT_AUDITOR_OUT_DIR = Path("logs/auditor") |
| 51 | + DEFAULT_AUDITOR_OUT_DIR.mkdir(parents=True, exist_ok=True) |
| 52 | + |
| 53 | + AUDITOR_PUB_KEY_DIR = Path("resources/keys") |
| 54 | + AUDITOR_PUB_KEY_FILE = AUDITOR_PUB_KEY_DIR / Path("llm-router-auditor-pub.asc") |
| 55 | + |
| 56 | + def __init__(self): |
| 57 | + """ |
| 58 | + Create a new GPG‑based audit log storage instance. |
| 59 | +
|
| 60 | + The constructor loads the public key from ``AUDITOR_PUB_KEY_FILE``. |
| 61 | + If the key cannot be imported, a :class:`RuntimeError` is raised. |
| 62 | +
|
| 63 | + Raises |
| 64 | + ------ |
| 65 | + RuntimeError |
| 66 | + If the public key file is missing or contains no importable keys. |
| 67 | + """ |
| 68 | + |
| 69 | + self._import_result = None |
| 70 | + self._gpg = gnupg.GPG(gnupghome=str(self.AUDITOR_PUB_KEY_DIR)) |
| 71 | + self._gpg.encoding = "utf-8" |
| 72 | + |
| 73 | + with self.AUDITOR_PUB_KEY_FILE.open("r", encoding="utf-8") as f: |
| 74 | + self._import_result = self._gpg.import_keys(f.read()) |
| 75 | + if not self._import_result.count: |
| 76 | + raise RuntimeError( |
| 77 | + f"Failed to import public key from {self.AUDITOR_PUB_KEY_FILE}" |
| 78 | + ) |
| 79 | + |
| 80 | + def store_log(self, audit_log, audit_type: str): |
| 81 | + """ |
| 82 | + Encrypt and persist an audit log entry. |
| 83 | +
|
| 84 | + Parameters |
| 85 | + ---------- |
| 86 | + audit_log : Any |
| 87 | + JSON‑serialisable object representing the audit record. |
| 88 | + audit_type : str |
| 89 | + Category of the log (e.g., ``"request"``, ``"error"``). The value |
| 90 | + is incorporated into the output filename. |
| 91 | +
|
| 92 | + The method creates a filename of the form |
| 93 | + ``<audit_type>__<YYYYMMDD_HHMMSS.microseconds>.audit`` inside |
| 94 | + ``DEFAULT_AUDITOR_OUT_DIR``. The log is JSON‑encoded with indentation |
| 95 | + for readability, encrypted using the previously imported public key, |
| 96 | + and written in ASCII‑armored format. |
| 97 | +
|
| 98 | + Raises |
| 99 | + ------ |
| 100 | + Exception |
| 101 | + Propagates any exception raised by the underlying GPG encryption |
| 102 | + or file I/O operations. |
| 103 | + """ |
| 104 | + date_str = datetime.now().strftime("%Y%m%d_%H%M%S.%f") |
| 105 | + out_file_name = f"{audit_type}__{date_str}.audit" |
| 106 | + out_file_path = self.DEFAULT_AUDITOR_OUT_DIR / out_file_name |
| 107 | + with open(out_file_path, "wt") as f: |
| 108 | + audit_str = json.dumps(audit_log, indent=2, ensure_ascii=False) |
| 109 | + encrypted_data = self._gpg.encrypt( |
| 110 | + audit_str, |
| 111 | + recipients=self._import_result.fingerprints, |
| 112 | + always_trust=True, |
| 113 | + armor=True, |
| 114 | + ) |
| 115 | + f.write(str(encrypted_data)) |
0 commit comments