M10 · Security + observability

5 câu hỏi~3–4 ngày codeTừ khóa: Keycloak, realm, PKCE, client credentials, resource server, 401/403, BOLA, OpenTelemetry, Prometheus, Grafana, SLO

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

2. Ôn lại trước khi code

3. Đề bài

3.1 Keycloak realm ticket (file infra/keycloak/ticket-realm.json, import khi container khởi động):

Thành phầnCấu hình
Realm rolecustomer, admin
User testalice (customer), bob (customer), admin1 (admin). Password lấy từ env khi import, không commit.
Client ticket-gatewaypublic 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-serviceconfidential, chỉ bật Service accounts (Client Credentials), service-account có role payment:write (client role của payment-service).
Client payment-servicebearer-only / không flow nào, chỉ để định nghĩa client role payment:write, audience mapper.
Client notification-serviceClient Credentials nếu cần gọi API khác (tùy chọn).

3.2 Phân quyền endpoint

MethodPathAi được gọiKhông tokenSai quyền
GET/api/events, /api/events/{id}/seatspublic200–
POST/api/eventsadmin401403
POST/api/bookings/holdscustomer401403
POST/api/bookings/{id}/confirmcustomer sở hữu booking401404 (không lộ tồn tại)
GET/api/bookings/{id}chủ booking hoặc admin401404
POST/api/paymentsservice token có payment:write401403 (token user)
POST/api/payments/webhookPSP, xác thực bằng HMAC signature (không phải JWT)401 nếu sai chữ ký–
GET/actuator/prometheuschỉ 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

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

  1. Realm được import tự động từ file; docker compose down -v && up vẫn dựng lại đủ role, client.
  2. Lấy token bằng Postman (Auth Code + PKCE) cho alice gọi được hold/confirm.
  3. 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.
  4. bob confirm/GET booking của alice → 404, không có thay đổi dữ liệu.
  5. Gọi POST /api/payments bằng token user → 403; bằng token client credentials của booking-service → 2xx.
  6. Không có secret nào trong git (git grep -i secret chỉ ra ${...}).
  7. Một booking thành công cho ra 1 trace chứa span của gateway, booking, payment, Kafka producer/consumer, notification.
  8. Từ một dòng log lỗi, copy traceId mở được đúng trace.
  9. Dashboard provision từ file, có SLO panel; chạy k6 5 phút thấy số liệu thay đổi.
  10. Có ít nhất 1 alert rule (burn rate nhanh) trong infra/prometheus/alerts.yml.

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
S1Không tokenPOST holds không header Authorization401, header WWW-Authenticate: Bearer
S2Sai roleToken alice gọi POST /api/events403
S3BOLAbob confirm booking của alice404, booking vẫn PENDING
S4Token hết hạnJWT exp quá khứ401
S5Bỏ qua gatewayGọi thẳng booking-service:8082 không token401
S6Service-to-serviceToken user gọi /api/payments403
S7Webhook giảSai chữ ký HMAC401, không đổi trạng thái payment
O1Trace xuyên KafkaConfirm bookingSpan notification có cùng traceId với span gateway
O2Metric10 hold thành công, 3 conflictbooking_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ạoHành vi mong đợi
Keycloak chếtdocker stop keycloakToken đã 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ếtdocker stop otel-collectorBooking vẫn chạy bình thường, chỉ mất trace (exporter không block request).
Prometheus chếtdocker stop prometheusApp không ảnh hưởng; sau khi bật lại, dashboard có khoảng trống.
PSP chậmToxiproxy latency 3s cho paymentDashboard 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

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:

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:

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

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.