M10 · Security + observability
Hệ thống chạy được rồi nhưng ai gọi API cũng được, mà lỗi thì mình mò log từng service. Giờ mình muốn làm M10: gắn Keycloak và trace, metric, dashboard.
Đúng thứ tự: ai được gọi trước, nhìn thấy gì đang xảy ra sau. Hai phần này độc lập, làm song song được.
1. Bối cảnh & bài toán
- Hiện tại ai biết
bookingIdlà confirm được booking của người khác. Đây là BOLA, lỗi số 1 trong OWASP API Top 10. - booking-service gọi payment-service không xác thực: chỉ cần vào được mạng nội bộ là tạo payment giả được.
- Khi một booking "treo" ở PENDING_PAYMENT, bạn không biết nó kẹt ở gateway, payment, Kafka hay notification. Không có trace thì mỗi sự cố mất hàng giờ.
- Không có SLO thì không ai biết hệ thống đang "tốt" hay "tệ". M11, M12 sẽ đo mọi thứ trên dashboard của M10.
2. Ôn lại trước khi code
- Keycloak: realm, client, user, role: phân biệt realm role và client role.
- Dựng Keycloak, export/import realm: realm phải là file JSON trong repo, không click tay.
- Auth Code + PKCE, Client Credentials: flow nào cho người, flow nào cho service.
- Role, mapper, nội dung token: role nằm ở claim nào (
realm_access.roles). - Resource Server & Keycloak:
issuer-uri, JWK, convert role sang authority. - Authorization và Method security & test:
@PreAuthorize, test vớijwt(). - OWASP API Top 10: BOLA khác 403 do sai role thế nào.
- Spring Cloud Gateway: TokenRelay, gateway có phải nơi duy nhất kiểm tra token không.
- Log, metric, trace: ba trụ cột dùng khi nào.
- OpenTelemetry và Distributed tracing: W3C
traceparent, propagation qua Kafka header. - Prometheus & Grafana: histogram,
rate(),histogram_quantile. - SLI, SLO, alert: error budget, burn rate.
3. Đề bài
3.1 Keycloak realm ticket (file infra/keycloak/ticket-realm.json, import khi container khởi động):
| Thành phần | Cấu hình |
|---|---|
| Realm role | customer, admin |
| User test | alice (customer), bob (customer), admin1 (admin). Password lấy từ env khi import, không commit. |
Client ticket-gateway | public client, Standard flow (Auth Code), PKCE S256 bắt buộc, redirect URI của Postman (https://oauth.pstmn.io/v1/callback), access token 5 phút. |
Client booking-service | confidential, chỉ bật Service accounts (Client Credentials), service-account có role payment:write (client role của payment-service). |
Client payment-service | bearer-only / không flow nào, chỉ để định nghĩa client role payment:write, audience mapper. |
Client notification-service | Client Credentials nếu cần gọi API khác (tùy chọn). |
3.2 Phân quyền endpoint
| Method | Path | Ai được gọi | Không token | Sai quyền |
|---|---|---|---|---|
| GET | /api/events, /api/events/{id}/seats | public | 200 | – |
| POST | /api/events | admin | 401 | 403 |
| POST | /api/bookings/holds | customer | 401 | 403 |
| POST | /api/bookings/{id}/confirm | customer sở hữu booking | 401 | 404 (không lộ tồn tại) |
| GET | /api/bookings/{id} | chủ booking hoặc admin | 401 | 404 |
| POST | /api/payments | service token có payment:write | 401 | 403 (token user) |
| POST | /api/payments/webhook | PSP, xác thực bằng HMAC signature (không phải JWT) | 401 nếu sai chữ ký | – |
| GET | /actuator/prometheus | chỉ trong mạng nội bộ / port management riêng | – | – |
3.3 Skeleton (bạn tự hoàn thiện):
// application.yml (mọi service)
spring.security.oauth2.resourceserver.jwt.issuer-uri: ${KEYCLOAK_ISSUER} # http://keycloak:8080/realms/ticket
// booking-service
@Bean SecurityFilterChain api(HttpSecurity http) { /* TODO: stateless, rule theo bảng 3.2 */ }
@Bean Converter<Jwt, AbstractAuthenticationToken> keycloakRoles() { /* TODO: realm_access.roles → ROLE_* */ }
public interface BookingOwnership {
/** Ném BookingNotFoundException nếu booking không tồn tại HOẶC không thuộc userId */
Booking loadOwned(UUID bookingId, String userId); // userId = jwt.sub
}
// bookings: thêm cột user_id
ALTER TABLE bookings ADD COLUMN user_id VARCHAR(64) NOT NULL; -- jwt "sub"
CREATE INDEX ix_bookings_user ON bookings(user_id);
// booking → payment: lấy token client credentials, có cache tới gần hết hạn
@Bean OAuth2AuthorizedClientManager clientManager(...) { /* TODO */ }
spring.security.oauth2.client.registration.payment:
client-id: booking-service
client-secret: ${BOOKING_CLIENT_SECRET}
authorization-grant-type: client_credentials
Gateway: là resource server (chặn sớm token sai) và relay token xuống service. Service vẫn tự validate token (defense in depth, không tin gateway).
3.4 Observability
- Dependency:
micrometer-tracing-bridge-otel,opentelemetry-exporter-otlp,micrometer-registry-prometheus. - Compose thêm:
otel-collector(OTLP → Tempo hoặc Jaeger),prometheus,grafana(datasource + dashboard provision từinfra/grafana/). - Trace phải đi liền:
gateway → booking → payment → (outbox relay) → Kafka payment.events → booking → Kafka booking.events → notification. Outbox: lưutraceparentvào cộtoutbox.headersđể relay gắn lại vào Kafka header. - Log có
traceId,spanIdtrong pattern. Không log token, không log số thẻ. - Metric custom (Micrometer):
booking_holds_total{result="success|conflict|error"} counter
booking_confirm_duration_seconds histogram (SLO buckets 0.1,0.3,0.5,1,2)
payment_psp_calls_total{outcome="ok|timeout|unknown|error"} counter
outbox_pending_events gauge
kafka consumer lag (từ kafka-exporter hoặc micrometer)
SLO (ghi vào docs/slo.md): 99.5% request POST /api/bookings/holds trả về không phải 5xx và dưới 500 ms, cửa sổ 30 ngày. Dashboard Ticket Booking – Overview có: RPS, error rate, p50/p95/p99 theo endpoint, SLO panel (SLI hiện tại + error budget còn lại + burn rate 1h/6h), outbox pending, consumer lag, PSP outcome, JVM heap/GC, Hikari active/pending.
4. Acceptance criteria
- Realm được import tự động từ file;
docker compose down -v && upvẫn dựng lại đủ role, client. - Lấy token bằng Postman (Auth Code + PKCE) cho alice gọi được hold/confirm.
- Không token → 401, token hết hạn → 401, token chữ ký sai → 401, đúng token sai role → 403. Kiểm cả khi gọi thẳng service bỏ qua gateway.
- bob confirm/GET booking của alice → 404, không có thay đổi dữ liệu.
- Gọi
POST /api/paymentsbằng token user → 403; bằng token client credentials của booking-service → 2xx. - Không có secret nào trong git (
git grep -i secretchỉ ra${...}). - Một booking thành công cho ra 1 trace chứa span của gateway, booking, payment, Kafka producer/consumer, notification.
- Từ một dòng log lỗi, copy
traceIdmở được đúng trace. - Dashboard provision từ file, có SLO panel; chạy k6 5 phút thấy số liệu thay đổi.
- Có ít nhất 1 alert rule (burn rate nhanh) trong
infra/prometheus/alerts.yml.
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| S1 | Không token | POST holds không header Authorization | 401, header WWW-Authenticate: Bearer |
| S2 | Sai role | Token alice gọi POST /api/events | 403 |
| S3 | BOLA | bob confirm booking của alice | 404, booking vẫn PENDING |
| S4 | Token hết hạn | JWT exp quá khứ | 401 |
| S5 | Bỏ qua gateway | Gọi thẳng booking-service:8082 không token | 401 |
| S6 | Service-to-service | Token user gọi /api/payments | 403 |
| S7 | Webhook giả | Sai chữ ký HMAC | 401, không đổi trạng thái payment |
| O1 | Trace xuyên Kafka | Confirm booking | Span notification có cùng traceId với span gateway |
| O2 | Metric | 10 hold thành công, 3 conflict | booking_holds_total{result="conflict"} tăng 3 |
@WebMvcTest(BookingController.class)
@Import(SecurityConfig.class)
class BookingSecurityTest {
@Autowired MockMvc mvc;
@MockBean BookingService service;
@Test void noToken_401() throws Exception {
mvc.perform(post("/api/bookings/holds")).andExpect(status().isUnauthorized());
}
@Test void otherUsersBooking_404() throws Exception {
// TODO: given booking của "alice"; jwt().jwt(j -> j.subject("bob")).authorities(...)
// expect 404 và service.confirm KHÔNG được gọi với dữ liệu của alice
}
}
// Integration: Testcontainers dasniko/testcontainers-keycloak + realm file thật
@Testcontainers class KeycloakIT {
@Container static KeycloakContainer kc = new KeycloakContainer().withRealmImportFile("ticket-realm.json");
// TODO: lấy token bằng password grant chỉ trong test (hoặc client credentials), gọi API thật
}
6. Failure scenarios
| Sự cố | Cách tạo | Hành vi mong đợi |
|---|---|---|
| Keycloak chết | docker stop keycloak | Token đã cấp vẫn dùng được (JWK đã cache) tới khi hết hạn; login mới lỗi rõ ràng, không 500 dây chuyền. |
| Xoay key (key rotation) | Tạo key mới trong realm, tắt key cũ | Service tự fetch lại JWK khi gặp kid lạ, không cần restart. |
| Collector chết | docker stop otel-collector | Booking vẫn chạy bình thường, chỉ mất trace (exporter không block request). |
| Prometheus chết | docker stop prometheus | App không ảnh hưởng; sau khi bật lại, dashboard có khoảng trống. |
| PSP chậm | Toxiproxy latency 3s cho payment | Dashboard thấy p99 confirm tăng, burn rate alert bắn; trace chỉ rõ span PSP dài. |
7. Số liệu cần thu
Lưu docs/benchmarks/m10-overhead.md: chạy lại k6 của M9 với tracing sampling 100% và 10%, so p99 và throughput. Ghi lại overhead của JWT validation (thường không đáng kể vì JWK cache). Chụp ảnh trace mẫu và dashboard vào docs/img/.
8. Checklist hoàn thành
- ☐ Acceptance criteria đều có test tự động và xanh.
- ☐ Mọi failure scenario đã chạy thật, có ghi kết quả.
- ☐ Benchmark overhead lưu trong
docs/benchmarks/. - ☐ ADR: "Validate JWT ở cả gateway và service" hoặc "404 thay 403 cho BOLA".
- ☐ Không hardcode secret, config qua
${ENV}. - ☐ Realm JSON, dashboard JSON, alert rule nằm trong repo.
- ☐
docs/slo.mdcó SLI, SLO, error budget, cách tính. - ☐ Postman collection có sẵn cấu hình OAuth2 PKCE.
- ☐ Đã qua lượt review và phỏng vấn của AI.
Token của alice decode ra thấy realm_access.roles có customer mà hasRole("customer") vẫn 403. Sao vậy?
Gợi ý để tự soi:
- Mặc định Spring đọc claim nào để tạo authority, và gắn tiền tố gì? Thử log
authentication.getAuthorities()ra xem. hasRole("customer")thực chất so với chuỗi nào? So vớihasAuthority.- Converter của bạn đã được gắn vào
oauth2ResourceServer(o -> o.jwt(...))chưa, hay chỉ khai báo bean mà không dùng? - Ở gateway (WebFlux) converter là kiểu reactive khác với service MVC.
Trace của mình dừng ở booking. Phần notification ra một trace mới, traceId khác hẳn.
Bạn có outbox, nên luồng không phải "gửi Kafka trong cùng request". Hỏi lại:
- Lúc relay đọc bảng
outbox, context của request gốc còn không? Nếu không thìtraceparentphải đi theo dữ liệu nào? - KafkaTemplate có bật observation chưa (
setObservationEnabled(true))? Listener container cũng vậy. - Mở message trong Kafka UI, xem header có
traceparentkhông. Có rồi mà vẫn đứt thì lỗi ở phía consumer. - Span của relay nên là con của request gốc hay chỉ là link? Cả hai đều được, nhưng phải ghi lý do vào ADR.
Panel SLO mình làm bằng avg latency thì có ổn không?
Không ổn. Gợi ý: SLI là tỉ lệ request tốt / tổng request, không phải trung bình. Dùng _bucket{le="0.5"} của histogram chia cho _count, trừ đi request 5xx. Bucket 0.5 phải tồn tại thật (cấu hình slo trong Micrometer), nếu không bạn sẽ phải nội suy sai. Error budget còn lại = 1 − (tỉ lệ lỗi thực tế / (1 − 0.995)). Burn rate thì đọc lại bài SLI, SLO, alert.
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review AI sẽ soi
- Ownership check nằm ở query (
where id=? and user_id=?) hay load rồi so trong Java? Có endpoint nào quên không? userIdlấy từjwt.sub, không bao giờ từ body/header client gửi.- Service có tự validate token, kể cả
aud/iss, không chỉ tin gateway. - Token client credentials có được cache, hay mỗi call payment lại xin token mới?
- Log có lộ
Authorizationheader, số thẻ, PII không. - Label metric không dùng giá trị vô hạn (
userId,bookingId) → cardinality bùng nổ. - Actuator chỉ mở
health, prometheus, ở port management riêng.
Câu hỏi phỏng vấn
1. 401 và 403 khác nhau thế nào? Vì sao confirm booking người khác bạn trả 404?
🇻🇳 401 là chưa biết bạn là ai (thiếu/sai token). 403 là biết nhưng không được phép. Với tài nguyên của người khác, trả 404 để không xác nhận nó tồn tại, tránh dò ID.
🇬🇧 401 means unauthenticated, 403 means authenticated but not allowed. For another user's booking I return 404 so the API does not reveal that the resource exists.
2. Vì sao service-to-service dùng Client Credentials mà không chuyển tiếp token của user?
🇻🇳 Gọi payment là hành động của booking-service, không phải của user, và có thể xảy ra sau khi token user hết hạn (saga, retry). Token riêng của service có scope hẹp, dễ audit.
🇬🇧 The payment call is made by the booking service, often asynchronously after the user's token expired. A service token with a narrow scope is easier to reason about and audit.
3. Làm sao trace đi qua Kafka khi bạn dùng outbox?
🇻🇳 Lưu traceparent vào cột headers của outbox trong cùng transaction, relay gắn lại vào Kafka header, consumer đọc header để tiếp tục trace.
🇬🇧 I store the W3C traceparent in the outbox row in the same transaction; the relay copies it into the Kafka record headers and the consumer continues the trace from there.
4. SLO của bạn là gì và alert khi nào?
🇻🇳 99.5% hold thành công dưới 500 ms trong 30 ngày. Alert theo burn rate: đốt budget nhanh gấp 14 lần trong 1h thì page, chậm hơn thì tạo ticket.
🇬🇧 99.5% of holds succeed under 500 ms over 30 days. I alert on burn rate: a fast burn over 1 hour pages someone, a slow burn opens a ticket.
Cách nhớ ngắn gọn
Người: Auth Code + PKCE Service: Client Credentials
Không token 401 · sai role 403 · không phải của mình 404
Gateway chặn sớm, service vẫn tự kiểm
Trace: traceparent đi theo outbox → Kafka header
SLO = tỉ lệ tốt / tổng, alert theo burn rate
Tự kiểm tra
Vì sao service phía sau vẫn phải validate JWT dù gateway đã kiểm?
Ai vào được mạng nội bộ có thể gọi thẳng service. Defense in depth: mỗi service tự kiểm chữ ký, iss, aud, exp.
BOLA là gì, chặn ở đâu?
Truy cập object của người khác bằng cách đổi ID. Chặn ở tầng dữ liệu/service: luôn lọc theo user_id lấy từ token.
Keycloak chết thì token đang dùng có hỏng không?
Không, JWT tự chứa và JWK đã cache. Chỉ login mới và refresh bị lỗi.
Vì sao không dùng bookingId làm label metric?
Mỗi giá trị label tạo một time series; ID vô hạn làm Prometheus hết RAM.
SLI nên tính bằng gì?
Tỉ lệ request tốt trên tổng request (từ histogram bucket và status), không dùng trung bình latency.