"""Orchard Audit Log Handler.""" from elasticsearch.exceptions import AuthorizationException from elasticsearch.helpers import bulk from auditlogger.dummy_elasticsearch import DummyElasticSearch from auditlogger.es_client_factory import create_es_client from auditlogger.exceptions import LoggingFailureException class AuditLogger: """Log auditable events.""" max_log_attempts: int = 3 os_host = None os_client = None def __init__(self, hosts: list, os_host: dict | None = None): """Connect to audit data repository. If no hosts are supplied, this class will quietly do nothing. Args: hosts (list): A list of dicts defining hosts. os_host (dict): Optional. Opensearch creds. """ self.es = DummyElasticSearch() self.hosts = hosts if self.hosts: self.es = create_es_client(self.hosts) if os_host: self.os_host = os_host # reuse Elasticsearch package for OS as well. During tear down we # can replace it with opensearch-py package. self.os_client = create_es_client([self.os_host]) def log(self, data): """Send an audit log event to elasticsearch and opensearch.""" is_bulk = isinstance(data, list) data = data if is_bulk else [data] # ES part will get deleted during ES cleanup. for attempt_number in range(1, self.max_log_attempts + 1): try: self._log(data, is_bulk) break except Exception as e: if attempt_number >= self.max_log_attempts: raise LoggingFailureException(str(e)) if isinstance(e, AuthorizationException): self.es = create_es_client(self.hosts) # write to OS only if it is configured. if not self.os_client: return for attempt_number in range(1, self.max_log_attempts + 1): try: self._log_os(data, is_bulk) break except Exception as e: if attempt_number >= self.max_log_attempts: raise LoggingFailureException(str(e)) if isinstance(e, AuthorizationException): self.os_client = create_es_client([self.os_host]) def _log(self, data, is_bulk): """Emit logged data.""" if is_bulk and self.hosts: return bulk(self.es, self.prepare_bulk(data)) for event in data: self.es.index(index=event.ES_INDEX, body=event.get_event_data()) @staticmethod def prepare_bulk(events): """Yield rows.""" for event in events: yield { "_index": event.ES_INDEX, "_type": "_doc", "_source": event.get_event_data()} def _log_os(self, data, is_bulk): """Emit logged data to Opensearch host.""" if is_bulk and self.os_client: return bulk(self.os_client, self.prepare_bulk_os(data)) for event in data: self.os_client.index(index=event.OS_INDEX, body=event.get_event_data()) @staticmethod def prepare_bulk_os(events): """Yield rows for Opensearch host.""" for event in events: # OS don't need _type property. Its always _doc. yield { "_index": event.OS_INDEX, "_source": event.get_event_data()}