Кто-то делает аудит
Кто-то делает аудит

Статья родилась, как водится, из рабочей задачи — нужно было внедрить аудит-логирование в некоторые микросервисы на Java и Spring.

В статье коротко рассмотрел различия между обычными логами и аудит-логами. Основное внимание уделено реализации аудит-логирования в Java, включая использование аннотаций и АОП, приведен пример настройки роутинга логов через rsyslog. По мере знакомства с инфраструктурой аудита, создал небольшой демо-проект, чтобы изучить тему глубже.

Что такое аудит-логирование

Аудит-логирование (audit logging) появляется в жизни многих проектов по мере их роста. И зачастую это не прихоть, а вынужденная необходимость, продиктованная внутренними процессами компании, безопасностью и законами.

Аудит -логирование - это фиксация всех значимых (а иногда и незначительных) действий, совершенных пользователями или системами в приложении. Аудит-логирование записывает каждое действие, позволяя не только отслеживать, кто, что и когда сделал, но и отвечать на более сложные вопросы, такие как «Почему это было сделано?» или «Кто предоставил соответствующие права?».

В сущности, если рассматривать наш IT-продукт как шпионский роман, то аудит-логи являются ежедневными отчетами агента, фиксирующими все события и действия.

Чем аудит-логи отличаются от обычных?

Концептуально что первое, что второе - это логи, но цели у них разные:

  • Обычные логи: основная цель — помочь разработчикам и системным администраторам в диагностике и устранении неполадок. Эти логи могут включать информацию о запуске и остановке приложений, ошибках, производительности и других технических деталях. Любая информация, без жестких стандартов и структуры.

  • Аудит-логи: Служат для безопасности продукта и организации, а также соответствия требованиям законодательства. Они фиксируют действия пользователей и системы, чтобы можно было отслеживать, кто что сделал, когда и зачем. Это помогает в обнаружении и предотвращении несанкционированных действий, нарушений безопасности, попыток мошеничества. Иногда это просто требования юрисдикции, в которой базируется бизнес.

Отличий можно придумать еще много, но база - это ответ на вопрос: “Какая цель?”.

Почему аудит-логирование важно?

Потому что они работают на безопасность проекта и позволяют отследить подозрительное поведение пользователей. Либо если что-то плохое уже произошло, такие логи помогут быстро восстановить картину инцидента Но если опустить вопросы прозрачности, безопасности и мониторинга, то в копилку важности пару монет подкинут GDPR-подобные законы.

GDPR (General Data Protection Regulation) настолько на слуху, что уже оброс мемами.

Вообще, аудит-логирование по требованиям GDPR не является строго обязательным, но крайне рекомендуемо. GDPR требует от компаний демонстрировать соответствие требованиям регламента, и многие органы по защите данных считают ведение логов эффективным способом достижения этой цели. То есть логи вроде бы необязательны, но докажи, что не осел логи предоставляют доказательства соблюдения принципов обработки данных по GDPR.

Untitled

Поэтому, независимо от желания команды разработки, если продукт компании должен соответствовать требованиям GDPR, аудит-логирование может стать необходимостью.

И из этой необходимости будет следовать, что, как и в каком виде необходимо логировать, как данные хранить, как их дополнительно обрабатывать. В какой-то момент это приведет к стандартизации требований к аудит-логированию внутри организации, чтобы избегать “каши” в форматах и паттернах логов.

Допустим разработчик хочет залогировать изменение данных пользователя и вставляет строчку кода:

log.debug("Пользователь 12345 изменил имя питомца")

А по GDPR этого может быть недостаточно и надо залогировать весь путь к изменению персональных данных - audit trail.

И получается уже примерно такое:

Timestamp: 2024-06-12T14:55:23Z
User ID: 12345
Event: Login
Description: Пользователь 12345 вошел в систему с IP-адреса 192.168.1.1
--------------------------------------
Timestamp: 2024-06-12T15:10:45Z
User ID: 12345
Event: Data Modification
Description: Пользователь 12345 изменил данные в таблице 'pets'
--------------------------------------
Timestamp: 2024-06-12T15:20:10Z
User ID: 12345
Event: Logout
Description: Пользователь 12345 вышел из системы

Пример реализации аудит-логов

С концепцией определились, а что с реализацией?

Начинается самая творческая часть, потому что аудит-логирование не имеет универсального решения, и его можно реализовать по-разному в зависимости от размера и специфики проекта. Можно хоть все логи в специальный файл писать, главное, чтобы по требованию можно было без лишней боли передать логи заинтересованной стороне в удобоваримом виде. А это Задача.

Аудитом можно покрыть операции в БД, например, в PostgreSQL через pgaudit. Или делать это через ORM, например, через Hibernate Envers.

Способов много, и все зависит от проекта.

Для более крупных проектов, можно усложнить инфраструктуру для аудит-логирования. Например, отправка логов всех сервисов напрямую в ELK или использование продуктов облачного провайдера, вроде AWS и его CloudTrail. Также можно использовать rsyslog как лог-сервер или роутер для гибкой настройки получателей логов. Получателем может быть топик Kafka, другой сервис (например, переслать лог по HTTP), разные популярные БД или HDFS. Список неисчерпывающий, подробнее тут.

Последнее “широкими мазками” выглядит так:

Я сделал небольшой демо-проект (ссылка на гитхаб), демонстрирующий, как с помощью rsyslog можно доставлять логи из Java/Spring сервиса в Kafka или, например, в другой сервис по HTTP.

На чем код запускал?

на Docker Engine 20.10.14 под MacOS

Демо устроено просто:

  • rsyslog - содержит conf файл с правилами для входящих/исходящих сообщений

  • Java-модули:

    • logging - настраиваем логгирование через кастомную аннотацию @Audit

    • service-a - сервис спамит логами для Кафка

    • service-b - сервис спамит логами для service-dumb и чуть-чуть для Kafka

    • service-dumb - сервис принимает логи по HTTP из rsyslog

  • docker-compose.yml, который поднимет всё демо + контейнеры с Kafka и rsyslog

Запустить демо можно через start_demo.sh , скрипт только билдит Java сервисы, собирает Dockerfile ’ы и поднимает весь проект с docker-compose.yml. Параметры запуска rsyslog и kafka стандартные, только для kafka задано создание топика rsyslog-output сразу после запуска контейнера.

Конфигурация rsyslog задана в файле rsyslog/conf/rsyslog.conf :

module(load="imudp")
module(load="omkafka")
module(load="omhttp")

input(type="imudp" port="514")


template(name="Text" type="string" string="%msg%\n")
template(name="JsonTemplate" type="list") {
    constant(value="{")
    constant(value="\"message\":\"")    property(name="msg" format="json")
    constant(value="\",\"hostname\":\"") property(name="hostname")
    constant(value="\",\"timestamp\":\"") property(name="timereported" dateFormat="rfc3339")
    constant(value="\"}\n")
}

if $msg contains "[HTTP]" then {
    action(
        type="omhttp"
        server="service-dumb"
        serverport="8080"
        restpath="audit"
        template="JsonTemplate"
        useHttps="off"
    )
    stop
}

action(
    type="omkafka"
    topic="rsyslog-output"
    broker=["kafka:9092"]
    template="Text"
)

rsyslog загружает модули для приема UDP сообщений (imudp), отправки логов в Kafka (omkafka) и отправки HTTP запросов (omhttp). Он слушает UDP порт 514 для получения сообщений. Если сообщение содержит "[HTTP]", оно отправляется на HTTP сервер. Все другие сообщения отправляются в Kafka в топик rsyslog-output. После отправки HTTP-сообщений процесс обрабатывания этих сообщений останавливается, чтобы они не дублировались в Kafka.

Переходим к Java модулям.

Настройки логирования в сервисах A и B заданы в соответствующих logback-spring.xml :

<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="true">


    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>[ServiceA] - %msg %n</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>

    <appender name="SYSLOG" class="ch.qos.logback.classic.net.SyslogAppender">
        <syslogHost>${SYSLOG_HOST}</syslogHost>
        <port>${SYSLOG_PORT}</port>
        <facility>USER</facility>
        <suffixPattern>[ServiceA] - %msg %n</suffixPattern>
    </appender>

    <root level="INFO" additivity="false">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="SYSLOG"/>
    </root>
</configuration>

Syslog аппендер отправляет все логи на хост и порт, заданные в переменных окружения SYSLOG_HOST и SYSLOG_PORT в docker-compose.yml . Паттерн лога максимально упрощенный.

Чтобы упросить аудит-логирование, можно заранее завести для этой цели аннотацию и обрабатывать всю логику через AOP. Я разместил весь этот код в модуле logging , он же добавляется в модулиservice-a и service-b как зависимость в build.gradle:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audit {
    EventType eventType();
}

...

public enum EventType {
    LOGIN("Login"),
    LOGOUT("Logout"),
    UPDATE_PROFILE("Update Profile"),
    DELETE_ACCOUNT("Delete Account"),
    ANY_NOT_IMPORTANT("Not important message which will be sent via http");

    private final String description;

    EventType(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}

Логика аспекта:

package com.mmpoznyak.syslogdemo.logging;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

@Aspect
@Component
public class AuditLoggingAspect {

    private static final String USER_ID = "userId";
    private static final String TABLE = "table";
    private static final String IPADDRESS = "ipAddress";

    private final MessageSource messageSource;

    public AuditLoggingAspect(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @Before("@annotation(audit)")
    public void logBefore(JoinPoint joinPoint, Audit audit) {
        Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
        var params = getMethodParams(joinPoint);
        var description = createDescription(params, audit.eventType());
        var message = String.format("Timestamp: %s, User ID: %s, Event: %s, Description: %s",
                LocalDateTime.now(), params.get(USER_ID), audit.eventType(), description);
        logger.info(message);
    }

    private String createDescription(Map<String, String> params, EventType eventType) {
        String userId = params.get(USER_ID);
        Object[] messageParams = switch (eventType) {
            case LOGIN -> new Object[]{userId, params.get(IPADDRESS)};
            case UPDATE_PROFILE -> new Object[]{userId, params.get(TABLE)};
            case LOGOUT, DELETE_ACCOUNT -> new Object[]{userId};
            case ANY_NOT_IMPORTANT -> new Object[]{};
        };
        return messageSource.getMessage(eventType.name(), messageParams, Locale.ENGLISH);
    }

    private Map<String, String> getMethodParams(JoinPoint joinPoint) {
        var args = joinPoint.getArgs();
        var signature = (MethodSignature) joinPoint.getSignature();
        var parameterNames = signature.getParameterNames();

        Map<String, String> namedArguments = new HashMap<>();
        for (int i = 0; i < args.length; i++) {
            namedArguments.put(parameterNames[i], (String) args[i]);
        }
        return namedArguments;
    }

}

service-a и service-b простые, поэтому ООП и раскидывание классов по пакетам тут отсутствуют. Оба сервиса имеют по 2 класса: ServiceApplication и ServiceOperations

В классе ServiceOperations помечаем аннотацией @Audit методы для аудита:

@Service
public class ServiceOperations {

    @Audit(eventType = EventType.LOGIN)
    public void login(String userId, String ipAddress) { }

    @Audit(eventType = EventType.LOGOUT)
    public void logout(String userId) { }

    @Audit(eventType = EventType.UPDATE_PROFILE)
    public void updateProfile(String userId, String table) { }

    @Audit(eventType = EventType.DELETE_ACCOUNT)
    public void deleteAccount(String userId) { }
}

и через @Scheduled(fixedRate =...) в ServiceAApplication / ServiceBApplicationспамим сообщения с произвольными интервалами 3-10 секунд.

Все логи с обоих сервисов будут поступать в контейнер rsyslog и роутиться по правилам conf файла либо на эндпойнт /audit в service-dumb сервисе, либо в топик Kafka.


На этом оканчивается статья, спасибо за прочтение. Надеюсь, она окажется полезной для знакомства с темой аудит-логирования.

Комментарии (5)


  1. Semenych
    19.06.2024 14:30

    А не лучше использовать https://github.com/danielwegener/logback-kafka-appender ?


    1. mr_iamam Автор
      19.06.2024 14:30

      Если логи отправляем только в kafka - да, но в данном примере предполагается несколько получателей с разными типами подключения


      1. Semenych
        19.06.2024 14:30

        logback апендеры достаточно гибкая штука, их конфигурация позволяют отправлять в несколько получателей.


        1. mr_iamam Автор
          19.06.2024 14:30

          Согласен, все так. но java здесь больше для примера, как генератор логов, целью было показать как в целом централизованный лог-сервер типа rsyslog может раскидывать логи по разным источникам, т.е. поставщики логов могут быть реализованы не только на java, но и на другом стэке без логбэка.

          Возможно, мне стоило сделать акцент на этом в статье, спасибо.


          1. Semenych
            19.06.2024 14:30

            ну просто если говорить об аудито то надо как минимум вспомнить MDC и UUID запроса например.