Безопасное ведение журналов доступа к персональным данным

Безопасное ведение журналов доступа к персональным данным

Работая над бэкендом BanJangNote, я занимался несколькими направлениями, но особенно много внимания уделил системе аудита доступа к персональным данным.

Сначала я был настолько занят разработкой основных функций сервиса, таких как подбор сотрудников и поиск работы, что почти не успевал думать о чём-либо ещё. Но когда работа над функциями сервиса стала подходить к концу, пришло время внедрить полноценную систему аудита доступа, чтобы соблюдать Закон о защите персональных данных, — задачу, которую я откладывал. В частности, BanJangNote — это платформа, где в процессе найма обмениваются большими объёмами персональных данных, поэтому просто записывать несколько строк в файлы журналов было недостаточно.

Когда я наконец приступил к работе, выяснилось, что требования законодательства строгие, а при наличии более 300 API полагаться на то, что люди будут помнить, куда добавлять журналы, — верный способ допустить пропуски по мере развития системы. Поэтому я хочу рассказать, как создал конвейер, предотвращающий пропуски записей аудита и сохраняющий время отклика API даже при высокой нагрузке.

Преобразование требований законодательства в спецификации кода

Статья 29 Закона о защите персональных данных обязывает организации хранить записи о доступе к системе и принимать меры безопасности, предотвращающие изменение или подделку этих записей. Более подробные требования приведены в статье 8 Уведомления о стандартах обеспечения безопасности персональных данных.

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

Во-первых, в журнале доступа всегда должны присутствовать пять обязательных сведений.

  • Идентификатор (ID или учётная запись), позволяющий определить, кто обращался к системе

  • Дата и время обращения

  • IP-адрес, с которого был осуществлён доступ к системе

  • Выполненное действие, показывающее, что именно было сделано в системе: например, просмотр, изменение, удаление или скачивание

  • Идентификатор субъекта данных, к сведениям о котором обращались

Кроме того, есть несколько дополнительных требований.

  • Если обрабатываются сведения о 50 000 и более людях или уникальные идентификационные сведения, записи необходимо хранить не менее двух лет.

  • После сохранения записи никто не должен иметь возможности произвольно её изменить.

  • При скачивании персональных данных в виде таблицы Excel или другого файла необходимо записать причину скачивания.

  • Должна быть предусмотрена возможность регулярно отслеживать массовые запросы и необычные шаблоны доступа.

Сами требования были понятны, но, когда я задумался о том, как реализовать их в коде, обнаружилось несколько сложностей.

С какими сложностями я столкнулся при разработке

В среде Spring при мысли об аудите доступа обычно в первую очередь вспоминают интерсепторы и AOP. На раннем этапе проектирования я рассматривал возможность перехватывать запросы на входе с помощью интерсептора и записывать их. Но когда я приступил к проработке решения, оказалось, что всё не так просто.

1. Сотни конечных точек. Неужели кто-то сможет уследить за ними всеми?

В BanJangNote было более 300 API, разделённых по ролям: для прорабов, руководителей бригад, администраторов и других пользователей. Нереалистично ожидать, что разработчики каждый раз при создании нового API будут помнить: «Этот API обрабатывает персональные данные, значит, нужно добавить запись в журнал». Когда вы заняты разработкой, легко что-нибудь упустить, а одним лишь ревью кода такие пропуски выявить сложно. Возникала и обратная проблема: при удалении существующего API или изменении его пути прежняя конфигурация могла остаться.

2. Интерсептор не знает, к чьим данным обращались

Интерсептору легко определить URL и заголовки запроса, отправленные клиентом, а также токен вошедшего в систему пользователя. Но он никак не может определить требуемый законом «идентификатор субъекта данных», то есть ответить на вопрос: «Чьи персональные данные просмотрел этот пользователь?» Например, если API получает список прорабов на конкретном объекте, ID десяти работников из результатов становятся известны только после того, как сервисная логика выполнит запрос к базе данных. Фактически данные терялись между интерсептором веб-слоя и слоем бизнес-логики.

3. Справится ли база данных с INSERT при каждом просмотре данных?

С учётом специфики строительных объектов мы ожидали всплеска запросов в ранние утренние часы, когда работники прибывают на объект. Если при каждом запросе списка, содержащего персональные данные, синхронно выполнять INSERT в таблицу журнала аудита, это, скорее всего, сразу создало бы нагрузку на пул подключений к БД. В результате важные бизнес-API могли бы работать медленнее только из-за записи журналов аудита. Кроме того, нужно было избежать отката фактических действий пользователей — например, просмотра вакансий или проверки отработанных часов — из-за временной блокировки БД или задержки при записи журнала аудита.

4. Вторичные утечки, вызванные самими журналами

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

Решение проблем

1. Предотвращение пропуска записей аудита: обязательная регистрация каждой конечной точки с помощью тестового кода

Я решил, что единственный способ не полагаться на память людей — сделать так, чтобы сборка завершалась ошибкой, если конечная точка не зарегистрирована.

Сначала я создал класс-реестр и потребовал зарегистрировать каждую конечную точку системы, указав, обрабатывает ли она персональные данные. Нельзя было просто оставить без классификации даже конечные точки, которые не обрабатывали персональные данные: их тоже нужно было зарегистрировать, указав код причины, например, предназначены ли они для проверки работоспособности или только для изменения данных самого пользователя.

// 대상 엔드포인트 등록
AccessLogTargetRegistry.register(
    "/feature/cm/find-somebodys-detail/fetch",
    true,
    AccessTaskType.Read,
    List.of(
        PersonalDataItem.Name,
        PersonalDataItem.Phone,
        PersonalDataItem.BirthDate,
        PersonalDataItem.Certificate
    )
);

// 비대상 엔드포인트도 사유와 함께 명시
AccessLogNonTargetDeclarations.registerNonTarget(
    "/actuator/health", 
    NonTargetReason.HealthCheck
);

Затем с помощью Spring MetadataReader я написал тест, который собирает пути URL всех @RestController в проекте.

class AccessLogTargetRegistryArchitectureTest {

    @Test
    @DisplayName("모든 Resource 엔드포인트가 레지스트리에 등록되어 있어야 한다")
    void allRoleResourceEndpointsMustBeRegistered() throws Exception {
        Set<String> discoveredEndpoints = scanAllRoleResourceEndpoints();

        // 레지스트리에 대상이든 비대상이든 등록되지 않은 엔드포인트를 찾는다
        List<String> missing = discoveredEndpoints.stream()
                .filter(ep -> !AccessLogTargetRegistry.contains(ep))
                .toList();

        // 하나라도 빠져 있으면 테스트 실패
        assertTrue(missing.isEmpty(),
                "AccessLogTargetRegistry에 등록되지 않은 엔드포인트가 있습니다: " + missing);
    }

    @Test
    @DisplayName("코드에서 삭제된 엔드포인트가 레지스트리에 남아있으면 안 된다")
    void registryMustNotContainStaleEndpoints() throws Exception {
        Set<String> realEndpoints = scanAllRoleResourceEndpoints();
        List<String> stale = AccessLogTargetRegistry.getAll().keySet().stream()
                .filter(ep -> !realEndpoints.contains(ep))
                .sorted()
                .toList();
        assertTrue(stale.isEmpty(), "실제 코드에 없는 엔드포인트가 레지스트리에 남아있습니다: " + stale);
    }
}

Теперь, если разработчик создавал новый API, но не регистрировал его, регрессионные тесты и процесс CI выявляли отсутствие записи аудита для этого API. Они также обнаруживали удалённые API, оставшиеся в реестре. Так регистрация конечных точек всегда оставалась актуальной на 100 %, и разработчикам не требовалось отдельно следить за этим.

2. Передача контекста бизнес-логики через ThreadLocal

Я связал интерсептор и бизнес-логику с помощью контекста на основе ThreadLocal (AccessLogContext).

При поступлении запроса интерсептор на этапе preHandle помещает в AccessLogContext такие сведения, как вызывающая сторона, IP-адрес клиента и время запроса.

Позже, когда запрос проходит через контроллер и попадает в сервисную логику, сервис получает из БД фактические данные и вызывает метод, который записывает в контекст сведения о субъекте данных и просматриваемых персональных данных.

@Service
@Transactional
public class WorkLdrFlow {
    //
    public WorkCrewMemberDetailRdo findMemberDetail(FindWorkCrewMemberDetailUdo udo) {
        //
        // 조회 대상이 된 정보주체 식별자를 컨텍스트(AccessLogContext)에 전달
        AccessLogContext.reportTargetSubject(udo.getMemberId(), AccessLogSubjectKind.Member);

        // 실제 업무 로직 수행
        return workCrewTask.findMemberDetail(udo.getMemberId());
    }
}

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

Затем, когда обработка запроса завершается, метод afterCompletion в интерсепторе объединяет эти сведения и формирует итоговый объект журнала.

3. Сохранение скорости отклика с помощью асинхронной пакетной обработки

Чтобы сохранить время отклика бизнес-API, интерсептор не отправляет запросы напрямую в БД. Вместо этого он помещает объекты во внутрипамятный буфер (AccessLogBatchAccumulator) и сразу возвращает управление.

@Component("accessLogBatchAccumulator")
@RequiredArgsConstructor
public class AccessLogBatchAccumulator {

    private final AccessLogBulkInsertStore bulkInsertStore;
    private final List<AccessLogCdo> buffer = new ArrayList<>();
    private final Object bufferLock = new Object();

    private int batchSize = 200; // 200건 기준
    private long flushIntervalMs = 1000; // 1초 주기
    private volatile long lastFlushTime = System.currentTimeMillis();

    public void add(AccessLogCdo cdo) {
        if (cdo == null) return;
        boolean shouldFlush = false;

        // 버퍼에 담을 때만 빠르게 락을 잡고 빠진다
        synchronized (bufferLock) {
            buffer.add(cdo);
            if (buffer.size() >= batchSize) {
                shouldFlush = true;
            }
        }

        // 실제 무거운 DB 작업은 락 바깥에서 수행
        if (shouldFlush) {
            drainAndInsert();
        }
    }

    @Scheduled(fixedDelayString = "1000")
    public void scheduledFlush() {
        // 200건이 안 차더라도 1초가 지나면 남아있는 로그를 비워준다
        boolean hasItems;
        long elapsed;
        synchronized (bufferLock) {
            hasItems = !buffer.isEmpty();
            elapsed = System.currentTimeMillis() - lastFlushTime;
        }
        if (hasItems && elapsed >= flushIntervalMs) {
            drainAndInsert();
        }
    }

    private void drainAndInsert() {
        List<AccessLogCdo> toInsert;
        synchronized (bufferLock) {
            if (buffer.isEmpty()) return;
            toInsert = new ArrayList<>(buffer);
            buffer.clear();
            lastFlushTime = System.currentTimeMillis();
        }

        // 별도 트랜잭션으로 JDBC Batch Insert 실행
        bulkInsertStore.insertAllInNewTransaction(toInsert);
    }

    @PreDestroy
    public void onShutdown() {
        // 서버가 내려갈 때 버퍼에 남은 로그를 비우고 종료
        drainAndInsert();
    }
}

Вместо JPA saveAll внутрипамятный буфер также использует нативный batchUpdate, объединяя записи в пакеты по 200 штук для вставки и тем самым снижая нагрузку на БД и сеть.

Кроме того, при добавлении записей в буфер берётся кратковременная блокировка, а интенсивный ввод-вывод с БД выполняется вне блокировки. Это предотвращает возникновение узких мест из-за ожидания потоками друг друга. Чтобы при повторном развёртывании или остановке сервера не потерять журналы, ожидающие в памяти, @PreDestroy сохраняет все оставшиеся данные перед завершением работы сервера.

Журналы аудита персональных данных — это чёрный ящик сервиса

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

Но данные, с которыми мы работаем, — это не просто текст, хранящийся в базе данных. В онлайн-мире они представляют конкретного человека; это метка, по которой можно установить личность реального человека. Если обращаться с этой информацией небрежно и допустить её утечку, последствия придётся нести пользователям. Пользователи доверяют нашему сервису свои персональные данные, и я понял, что задача разработчика — оправдывать это доверие с помощью создаваемых нами систем.

Журналы аудита служат защитой не только для пользователей, но и для нашего сервиса и самих разработчиков. Если обнаружится подозрительный внешний доступ или возникнет подозрение на утечку данных, разработчики не смогут защитить себя без чётких записей о том, кто, к каким данным, когда и откуда обращался. Благодаря тщательно поддерживаемым журналам доступа мы сможем ясно объяснить: «Наша система соблюдала надлежащие процедуры, и вот объём доступа к затронутой учётной записи».

Я воспринимаю соблюдение требований и меры безопасности не как препятствие разработке сервиса, а как подготовку, которая поможет предотвратить происшествия, когда в будущем темп нашей работы значительно возрастёт. Надёжный чёрный ящик позволит нам уверенно разрабатывать новые функции по мере роста и развития сервиса.

informalife

Site footer