Audit logging in Python
- I'd like to make note of sensitive changes in my app.
- I'd like to be able to query these changes from day one.
- I'd like to leave architecting a long term solution for later.
- I'd like these records to persist.
- I'd like to create alarms around some changes that get sent to a business (not development) team.
💡 Idea!
- Add a sub-logger to python's logging module.
- Add a rule in my infrastructure that recognizes the sub-logger logs and directs them to a log group with different persistence rules.
- Format the sub-logger logs to be JSON for easier querying and processing.
By making this a part of the logging infrastructure, the sub-logger is always handy and easily retrievable. AWS CloudWatch has support for queries, so we have a strong search ability out of the box. In theory, an update to the sub-logger could have the records be directed to a more appropriate storage option, such as DynamoDB.
import logging
from typing import cast
import ecs_logging
class AuditLogger(logging.Logger):
"""A logger class with extra functions and customization for use in tracking sensitive changes in the app."""
def __init__(self, name, level=logging.INFO):
super().__init__(name, level)
self._setup()
def _setup(self):
handler = logging.StreamHandler()
handler.setFormatter(ecs_logging.StdlibFormatter())
self.addHandler(handler)
def record_updated(self, msg: str, record_type: str, before: dict, after: dict, *args, **kwargs):
"""A general use audit function for when a model has been changed."""
updated = {'record_type': record_type, 'before': {}, 'after': {}}
# Find keys with different values
for key in before.keys() & after.keys():
if before[key] != after[key]:
updated['before'][key] = before[key]
updated['after'][key] = after[key]
if 'extra' not in kwargs:
kwargs['extra'] = {}
kwargs['extra']['changes'] = updated
self.info(msg, *args, **kwargs)
class MyLogger(logging.Logger):
"""Custom app logger with a special sub-logger for auditing."""
def __init__(self, name, level=logging.NOTSET, add_starlette_context: bool = False):
super().__init__(name, level)
self.audit = AuditLogger(f"{name}.audit", logging.INFO)
@classmethod
def get(cls, name: str, add_starlette_context: bool = False):
"""Use in place of logging.getLogger() so typing systems recognize AuditLogger.audit"""
return cast(cls, logging.getLogger(name))
import logging
from audit_logger import MyLogger
logging.setLoggerClass(MyLogger)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = MyLogger.get(__name__)
if __name__ == '__main__':
logger.info('This is an info log from the regular logger, nothing has changed about it\'s functionality')
logger.audit.info(
'Simple text audit logs can be made by using the regular level functions',
stack_info=True,
extra={"Extra info": "can be passed via extra"}
)
user_before = {'id': 1, 'phone': '+1234567890'}
user_after = {'id': 1, 'phone': '+19876543210'}
logger.audit.record_updated(
"Edit made to MFA information",
record_type='user',
before=user_before,
after=user_after
)
2024-08-20 10:18:53,049 - __main__ - INFO - This is an info log from the regular logger, nothing has changed about it's functionality
{"@timestamp":"2024-08-20T14:18:53.049Z","log.level":"info","message":"Simple text audit logs can be made by using the regular level functions","Extra info":"can be passed via extra","ecs.version":"1.6.0","error":{"stack_trace":"Stack (most recent call last):\n File \"C:\\Users\\avroh\\PycharmProjects\\audit_logger\\test.py\", line 16, in \n logger.audit.info("},"log":{"logger":"__main__.audit","origin":{"file":{"line":16,"name":"test.py"},"function":""},"original":"Simple text audit logs can be made by using the regular level functions"},"process":{"name":"MainProcess","pid":18768,"thread":{"id":11020,"name":"MainThread"}}}
{"@timestamp":"2024-08-20T14:18:53.050Z","log.level":"info","message":"Edit made to MFA information","changes":{"after":{"phone":"+19876543210"},"before":{"phone":"+1234567890"},"record_type":"user"},"ecs.version":"1.6.0","log":{"logger":"__main__.audit","origin":{"file":{"line":47,"name":"__init__.py"},"function":"record_updated"},"original":"Edit made to MFA information"},"process":{"name":"MainProcess","pid":18768,"thread":{"id":11020,"name":"MainThread"}}}