Работая над бэкендом 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