Powtórka · offline · telefon

Employee Data Service

Gdy powiedzą "przejdźmy przez kod"

Mapa kodu

Struktura pakietów + rola każdego pliku. Rozwiń kartę, żeby zobaczyć kluczowy fragment i to, co warto o nim powiedzieć.

employee — domena crypto — bezpieczeństwo SSN common — przekrojowe root / config
com.krx2.employeedatamanagement
├─ EmployeeDataManagementApplication.java — punkt wejścia
├─ common/
│  ├─ ApiKeyAuthFilter.java
│  ├─ EmployeeNotFoundException.java
│  ├─ FixedLocaleMessageInterpolator.java
│  ├─ GlobalExceptionHandler.java
│  └─ ValidationConfig.java
├─ crypto/
│  ├─ SsnAttributeConverter.java
│  ├─ SsnEncryptionService.java
│  └─ SsnMasker.java
└─ employee/
   ├─ Employee.java, Gender.java
   ├─ EmployeeController / Service / Mapper / Repository.java
   └─ dto/ — EmployeeCreateRequest, EmployeeResponse,
      ReasonableDateOfBirth(+Validator), ValidSsn(+Validator)
resources/application.properties, db/migration/V1__create_employee_table.sql
EmployeeDataManagementApplication.java

Punkt wejścia Spring Boota. Świadomie bez Locale.setDefault() — to była wcześniejsza wersja poprawki na komunikaty walidacji; wycofana na rzecz beana ograniczonego do samej walidacji (patrz ValidationConfig).

Warto wiedzieć: jeśli zapytają "gdzie konfigurujecie locale" — odpowiedź nie jest tutaj, celowo.
common/ApiKeyAuthFilter.java

Jedyna bramka autoryzacji. OncePerRequestFilter, działa przed DispatcherServlet — dlatego 401 buduje własny ProblemDetail ręcznie (ale przez wspólny ObjectMapper, nie przez string literal).

if (!MessageDigest.isEqual(expectedApiKey, provided)) {
    // 401 + ProblemDetail przez wstrzyknięty ObjectMapper
}
Warto wiedzieć: MessageDigest.isEqual, nie .equals() — stałoczasowe porównanie, odporne na timing attack. Wyklucza też ścieżki /swagger-ui i /v3/api-docs.
common/GlobalExceptionHandler.java

Jedno miejsce mapujące wyjątki na odpowiedzi HTTP. @RestControllerAdvice z pięcioma handlerami: walidacja → 400, zły JSON/enum → 400, zły typ parametru (np. nie-UUID) → 400, brak rekordu → 404, wszystko inne → 500.

Warto wiedzieć: to tu był realny bug z pierwszej rundy review — zbyt ogólny @ExceptionHandler(Exception.class) łapał też wyjątki, które Spring domyślnie mapuje na 400 (HttpMessageNotReadableException, MethodArgumentTypeMismatchException), więc zły enum czy nie-UUID w ścieżce dawały 500. Dodanie dedykowanych handlerów to naprawiło.
common/ValidationConfig.java + FixedLocaleMessageInterpolator.java

Wymusza angielskie komunikaty walidacji niezależnie od locale hosta. Bean LocalValidatorFactoryBean z owiniętym interpolatorem, który zawsze woła delegata z Locale.ENGLISH.

Warto wiedzieć: pierwsza wersja robiła to przez Locale.setDefault() w bloku statycznym głównej klasy — działało, ale zmieniało domyślne locale całej JVM. To świadomie zawężone do samej walidacji.
common/EmployeeNotFoundException.java

Prosty, jednozdaniowy wyjątek — konstruktor przyjmuje UUID, buduje komunikat "Employee not found: …". Łapany przez GlobalExceptionHandler → 404.

crypto/SsnEncryptionService.java

Serce sekcji bezpieczeństwa. AES-256-GCM: losowy 96-bit nonce na każde wywołanie, wynik = base64(nonce ‖ ciphertext+tag). Klucz z APP_ENCRYPTION_KEY, walidowany przy starcie (musi dekodować się do dokładnie 32 bajtów).

byte[] nonce = new byte[12]; // GCM_NONCE_LENGTH_BYTES
secureRandom.nextBytes(nonce);
cipher.init(ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
Warto wiedzieć: ten sam SSN zaszyfrowany dwa razy daje różny ciphertext — losowy nonce per operację, nie per klucz.
crypto/SsnAttributeConverter.java

JPA @Converter — transparentnie szyfruje przy zapisie (convertToDatabaseColumn) i deszyfruje przy odczycie (convertToEntityAttribute). Reszta kodu nie wie, że kolumna jest szyfrowana.

Warto wiedzieć: to jest Spring-managed bean (@Component) — Hibernate w tym projekcie wspiera wstrzykiwanie zależności do konwerterów przez kontener Springa, więc konwerter może wołać SsnEncryptionService bez ręcznego statycznego dostępu.
crypto/SsnMasker.java

Statyczna funkcja maskująca — zostawia tylko ostatnie 4 cyfry: "***-**-6789". Jedyne miejsce, które konwertuje plaintext SSN na coś, co bezpiecznie trafia do odpowiedzi API.

if (digitsOnly.length() < 4) {
    throw new IllegalArgumentException(...);
}
Warto wiedzieć: ma jawne guard clause'y (null, <4 cyfr) — dodane po review, bo wcześniej bezpieczeństwo tej metody po cichu zależało od tego, że ktoś inny (walidacja) już sprawdził dane wcześniej w łańcuchu.
employee/Employee.java

Encja JPA — anemiczna, mapuje 1:1 na tabelę. id generowane przez Hibernate (GenerationType.UUID), pole ssn z @Convert(SsnAttributeConverter.class).

String ssnForInternalUseOnly() { return ssn; } // package-private
Warto wiedzieć: getter SSN jest celowo pakietowy, nie publiczny, i jawnie nazwany — wywoływalny tylko przez EmployeeMapper w tym samym pakiecie. Przypadkowe wystawienie na zewnątrz wymaga świadomej zmiany widoczności.
employee/EmployeeController.java

4 endpointy: POST, GET /{id}, GET (paginacja przez Pageable), DELETE /{id}. Cienka warstwa — cała logika w serwisie.

employee/EmployeeService.java

Logika biznesowa + transakcje. create/getById/list/delete — wszystkie cztery w tym samym, spójnym idiomie: findById(...).orElseThrow(...) tam, gdzie trzeba sprawdzić istnienie.

Warto wiedzieć: delete() wcześniej używał existsById + osobny if (inny styl niż getById, plus mikroskopijne okno TOCTOU) — ujednolicone po review do tego samego wzorca co reszta klasy.
employee/EmployeeMapper.java

request → encja, encja → response. Jedyne miejsce, które woła SsnMasker.mask(employee.ssnForInternalUseOnly()) — most między domeną a API, gwarantujący brak wycieku plaintextu.

employee/dto/ValidSsn.java + ValidSsnValidator.java

Własny constraint walidacyjny — poza formatem XXX-XX-XXXX sprawdza realne reguły SSA: obszar ≠ 000/666/900–999, grupa ≠ 00, numer seryjny ≠ 0000.

// "900" <= area <= "999" -- bezpieczne leksykograficznie,
// bo regex wcześniej gwarantuje dokładnie 3 cyfry
area.compareTo("900") >= 0
employee/dto/ReasonableDateOfBirth.java + Validator.java

Dolna granica daty urodzenia (1900-01-01 do dziś) — zastępuje samo @Past, które przepuszczało np. rok 1700.

employee/EmployeeRepository.java, Gender.java, dto/EmployeeCreateRequest.java, dto/EmployeeResponse.java

EmployeeRepository — zwykłe JpaRepository<Employee, UUID>, zero customowych metod. Gender — enum (MALE/FEMALE/OTHER/UNSPECIFIED). EmployeeCreateRequest/EmployeeResponse — rekordy DTO; response celowo nie ma pola z pełnym SSN, tylko maskedSsn.

resources/application.properties + db/migration/V1__create_employee_table.sql

spring.jpa.hibernate.ddl-auto=validate — Hibernate tylko sprawdza mapowanie, nigdy nie modyfikuje schematu; jedyne źródło prawdy to migracja Flyway. Dwie wymagane zmienne: APP_ENCRYPTION_KEY, APP_API_KEY — bez defaultów, brak = fail-fast.

Warto wiedzieć: kolumna key_version SMALLINT NOT NULL DEFAULT 1 istnieje w schemacie, ale nic jej dziś nie czyta — świadomy hook pod przyszłą rotację klucza, nie przeoczenie.

Otwarcie

Elevator pitch (30 sekund)

Mikroserwis do przechowywania danych pracowników dla wewnętrznego HR-u — Java 25 / Spring Boot 4.1, PostgreSQL, w pełni skonteneryzowany. Nacisk na to, czego zadanie wymagało wprost: SSN nigdy nie trafia do bazy ani do odpowiedzi API w postaci jawnej — szyfrowanie AES-256-GCM, klucz wyłącznie w zmiennej środowiskowej.

Punkt, o który zapytają na pewno

Szyfrowanie kontra hashowanie SSN

✕ Hashing (BCrypt/Argon2)
  • Dobre, gdy tylko sprawdzasz wartość (jak hasło), nigdy jej nie odzyskując.
  • SSN ma za małą entropię — nawet solony hash w zasięgu ataku słownikowego przy wycieku bazy.
  • Nieodwracalność wyklucza legalne przypadki (maskowanie, integracja płacowa).
✓ AES-256-GCM (wybór)
  • Odwracalne pod kluczem trzymanym poza bazą.
  • GCM = uwierzytelnione szyfrowanie, odporność na manipulację.
  • Losowy nonce → identyczny SSN dwóch osób daje różny ciphertext.

Drugi pewniak

Jak korzystałem z Claude Code

złapane przez weryfikację

Spring Boot 4.1 łamie "podręcznikowe" AI

flyway-core bez spring-boot-starter-flyway kompiluje się, ale nic nie robi. Testcontainers 2.x zmienił artifactId i PostgreSQLContainer przestał być generyczny. @MockBean zniknął (→ @MockitoBean). Jackson przeniósł ObjectMapper do tools.jackson.databind. Żadne nie zawiodło przy kompilacji — wszystkie złapane dopiero przy realnym uruchomieniu.

odrzucona własna propozycja

Zaproponowałem wykrywanie duplikatów SSN — i to wycofałem

Dodałem HMAC-lookup-hash — funkcję spoza zakresu zadania. Po krytycznej recenzji: nowy, długożyjący sekret bez ścieżki rotacji, gorzej zabezpieczony niż to, co zadanie kazało chronić. Usunięte w całości.

Rehearsal

Q&A do przećwiczenia

Dlaczego szyfrowanie, a nie hashowanie SSN?
Za mała entropia SSN dla bezpiecznego hasha przy wycieku bazy; hash nieodwracalny, a czasem legalnie potrzebny dostęp do wartości (payroll). AES-GCM: odwracalne pod kluczem + integralność + losowy nonce.
Dlaczego nie ma rotacji kluczy, skoro jest kolumna key_version?
AttributeConverter w JPA widzi tylko jedną kolumnę, nie sąsiednie w tym samym wierszu — nie może sam wybrać klucza po wersji. Poprawne rozwiązanie: wersja klucza zakodowana wewnątrz ciphertextu, obok nonce.
Dlaczego dodałeś i usunąłeś wykrywanie duplikatów SSN?
Sam to zaproponowałem, a po krytycznym spojrzeniu okazało się rozwiązaniem niepytanego problemu nowym, gorzej zabezpieczonym sekretem. Prostszy, uczciwie opisany serwis wygrywa.
Jak działa autoryzacja?
Własny filtr, jeden współdzielony klucz w X-API-Key, porównanie stałoczasowe. Celowo minimalne — produkcyjnie: OAuth2/mTLS.
Dlaczego Flyway zamiast ddl-auto=update?
Migracja jako jedyne źródło prawdy o schemacie; Hibernate tylko waliduje, nigdy nie zmienia bazy.
Największa luka względem prawdziwego systemu HR?
Brak realnej autoryzacji, brak audytu dostępu, brak polityki retencji/RODO — wszystkie świadomie odłożone i opisane.
Jak testowałeś?
37 testów: jednostkowe (szyfrowanie, maskowanie, walidatory), @WebMvcTest na kontrolerze, @SpringBootTest+Testcontainers na pełnym przepływie z prawdziwym, jednorazowym Postgresem.

Powiedz zanim zapytają

Znane luki

brak
Rotacja kluczakolumna istnieje, nic jej nie czyta
brak
Pełna autoryzacjaklucz API zamiast OAuth2/mTLS
brak
Audyt dostępunikt nie loguje kto/kiedy
częściowo
Retencja / RODODELETE jest, polityki retencji nie ma
brak
CItesty tylko lokalnie

"Pokaż mi to na żywo"

Ściągawka

MetodaŚcieżkaKod
POST/employees201/400/401
GET/employees/{id}200/400/401/404
GET/employees200 (stronicowane)
DELETE/employees/{id}204/401/404
docker compose up --build -d
curl -H "X-API-Key: $APP_API_KEY" http://localhost:8080/employees
# Swagger bez klucza: /swagger-ui/index.html