Có một câu hỏi luôn xuất hiện khi đưa Kibana lên production multi-tenant: “Làm sao một SaaS app cho tenant A vào Kibana chỉ thấy data của tenant A, không thấy của B?”. Cách sai là tạo cluster riêng cho mỗi tenant. Cách đúng là dùng Document-Level Security (DLS) plus Field-Level Security (FLS), hai feature ít ai động vào nhưng giải đúng bài toán.
Đây là bài 14 trong series Kibana từ A đến Z. Sau bài này bạn sẽ làm được:
- Hiểu DLS, FLS khác nhau gì và khi nào kết hợp
- Tạo API key chỉ đọc document có
tenantIdcụ thể - Mask field PII (token, ssn, email) khỏi search response
- Dùng query template để DLS chạy theo user hiện tại
- Tránh pitfall multi-role và DLS terms_lookup chậm
Phần 1: DLS và FLS trong 60 giây
DLS: filter document theo query. User chỉ thấy document khớp query. Document còn lại không tồn tại đối với user đó.
FLS: filter field trong document. Document được trả về nhưng các field không cho phép sẽ bị xoá khỏi response.
| Vector tấn công | DLS chặn | FLS chặn |
|---|---|---|
| Đọc data tenant khác | có | không (chỉ ẩn field) |
| Đọc cột password hash | không (vẫn thấy doc) | có |
| Đếm số doc tenant khác | có | không |
| Aggregation trên field cấm | không | có |
Thực tế: dùng cả hai. DLS để cô lập theo tenant, FLS để che PII.
Lưu ý license: DLS và FLS yêu cầu Platinum trở lên (hoặc Enterprise Trial). Basic license không có. Đừng quên check GET /_license trước khi setup, không thì dev một tuần xong mới biết không kích hoạt được.
Phần 2: Schema giả định để minh hoạ
Index app-logs-* có mapping:
{
"@timestamp": "date",
"Level": "keyword",
"Properties": {
"TenantId": "keyword",
"UserId": "keyword",
"UserEmail": "keyword",
"UserToken": "keyword",
"Action": "keyword",
"Ip": "ip"
},
"RenderedMessage": "text"
}
Yêu cầu:
- Tenant A user chỉ đọc doc có
Properties.TenantId = "tenant-a". - Field
UserTokenkhông bao giờ trả về. - Field
UserEmailchỉ hiển thị domain (mask local part). - Field
Ipchỉ trả về cho rolesecurity.
Phần 3: Tạo role có DLS
curl -s -u "$ES_USER:$ES_PASS" \
-H "Content-Type: application/json" \
-X PUT "$ES_URL/_security/role/tenant_a_reader" \
-d '{
"indices": [
{
"names": ["app-logs-*"],
"privileges": ["read", "view_index_metadata"],
"query": "{\"term\":{\"Properties.TenantId\":\"tenant-a\"}}"
}
]
}'
Test:
curl -s -u tenant_a_user:pwd \
"$ES_URL/app-logs-2026.05/_search?q=*&size=1" \
| jq '.hits.hits[0]._source.Properties.TenantId'
# Output: "tenant-a"
Thử search doc của tenant khác:
curl -s -u tenant_a_user:pwd \
"$ES_URL/app-logs-2026.05/_search?q=Properties.TenantId:tenant-b&size=5" \
| jq '.hits.total.value'
# Output: 0
DLS được áp dụng AND với query của user. Không có cách bypass từ phía client.
Phần 4: Thêm FLS che PII
Mở rộng role với field_security:
{
"indices": [
{
"names": ["app-logs-*"],
"privileges": ["read", "view_index_metadata"],
"query": "{\"term\":{\"Properties.TenantId\":\"tenant-a\"}}",
"field_security": {
"grant": [
"@timestamp",
"Level",
"Properties.TenantId",
"Properties.UserId",
"Properties.Action",
"RenderedMessage"
]
}
}
]
}
Hai cách khai báo:
grant: whitelist, chỉ field liệt kê được trả về.except: blacklist, mọi field trừ field liệt kê.
Khuyến nghị grant. Whitelist an toàn hơn khi schema thêm field mới (mặc định cấm), không cần update role.
Test:
curl -s -u tenant_a_user:pwd \
"$ES_URL/app-logs-2026.05/_search?size=1" \
| jq '.hits.hits[0]._source'
Response sẽ KHÔNG có UserToken, UserEmail, Ip. Aggregation trên các field này cũng fail:
curl -s -u tenant_a_user:pwd \
-H "Content-Type: application/json" \
-X POST "$ES_URL/app-logs-2026.05/_search" \
-d '{
"aggs": { "by_ip": { "terms": { "field": "Properties.Ip" } } }
}'
# Error: "field [Properties.Ip] does not exist"
Đây là behaviour có chủ đích. Attacker không thể infer field cấm qua aggregation.
Phần 5: Mask field thay vì cấm hoàn toàn
FLS standard không có pattern “show partial”. Để hiển thị ***@example.com thay vì [email protected], dùng ingest pipeline lúc index plus runtime field trong data view.
Cách 1: mask khi index (đề xuất, hiệu năng cao):
PUT _ingest/pipeline/mask_email
{
"processors": [
{
"script": {
"lang": "painless",
"source": """
if (ctx.Properties != null && ctx.Properties.UserEmail != null) {
String email = ctx.Properties.UserEmail;
int at = email.indexOf('@');
if (at > 0) {
ctx.Properties.UserEmailMasked = '***' + email.substring(at);
}
}
"""
}
}
]
}
Sửa index template để pipeline chạy default:
PUT _index_template/app-logs-template
{
"index_patterns": ["app-logs-*"],
"template": {
"settings": { "index.default_pipeline": "mask_email" }
}
}
Role grant field Properties.UserEmailMasked, không grant Properties.UserEmail. Dev thấy ***@example.com.
Cách 2: runtime field trong data view (cho data đã index):
{
"runtime_mappings": {
"userEmailMasked": {
"type": "keyword",
"script": {
"source": """
String email = doc['Properties.UserEmail'].value;
int at = email.indexOf('@');
emit('***' + email.substring(at));
"""
}
}
}
}
Trade-off: runtime tính lúc query, chậm hơn. Index-time mask nhanh hơn nhưng phải reindex doc cũ.
Phần 6: DLS động theo user hiện tại
Yêu cầu phức tạp hơn: tenantId không hardcode trong role, mà lấy từ thuộc tính user (ví dụ user metadata hoặc OIDC claim).
Ví dụ user trong native realm:
curl -s -u "$ES_USER:$ES_PASS" \
-H "Content-Type: application/json" \
-X POST "$ES_URL/_security/user/alice" \
-d '{
"password": "<set>",
"roles": ["dynamic_tenant_reader"],
"full_name": "Alice",
"metadata": { "tenantId": "tenant-a" }
}'
Role với query template:
PUT _security/role/dynamic_tenant_reader
{
"indices": [
{
"names": ["app-logs-*"],
"privileges": ["read", "view_index_metadata"],
"query": {
"template": {
"source": "{\"term\":{\"Properties.TenantId\":\"{{_user.metadata.tenantId}}\"}}"
}
}
}
]
}
Tham chiếu {{_user.metadata.tenantId}} resolve theo user hiện tại. Tạo user bob với tenantId: tenant-b dùng cùng role này sẽ tự thấy data tenant-b.
Với OIDC/SAML, dùng {{_user.roles}}, {{_user.username}}, hoặc map claim qua role mapping rồi đẩy vào metadata.
Phần 7: API key kế thừa role descriptor
Sinh API key cho tenant A backend service:
POST /_security/api_key
{
"name": "tenant-a-backend",
"expiration": "30d",
"role_descriptors": {
"tenant_a_reader": {
"cluster": ["monitor"],
"indices": [
{
"names": ["app-logs-*"],
"privileges": ["read"],
"query": "{\"term\":{\"Properties.TenantId\":\"tenant-a\"}}",
"field_security": {
"grant": [
"@timestamp", "Level", "Properties.TenantId",
"Properties.UserId", "Properties.Action", "RenderedMessage"
]
}
}
]
}
},
"metadata": {
"tenant": "tenant-a",
"owner": "platform-team"
}
}
Hai điểm cần nhớ:
- API key có DLS/FLS được áp dụng AND với DLS/FLS của user tạo nó. Nếu user tạo đã có DLS hẹp hơn, key cũng hẹp theo.
- API key role descriptor không tham chiếu
_user.metadata. Phải hardcode giá trị tại thời điểm tạo.
Phần 8: Pitfall multi-role và DLS
User có 2 role:
- Role A: DLS
term: TenantId = "tenant-a" - Role B: DLS
term: TenantId = "tenant-b"
Câu hỏi: user thấy data gì? Trả lời: data của tenant-a OR tenant-b. DLS gộp theo logic OR, không AND.
Pitfall: muốn cộng dồn restrict (AND) thì gộp vào CÙNG MỘT role. Nếu tách thành 2 role thì sẽ là OR và rộng hơn.
Một team tôi từng làm cùng setup security role plus tenant role. Audit phát hiện user xem được log của tenant khác vì security role có DLS bare match_all. Fix: gỡ DLS khỏi security role, chỉ giữ DLS ở tenant role.
Phần 9: DLS terms_lookup (linh hoạt nhưng chậm)
Khi danh sách tenantId thuộc về user lớn (ví dụ user là admin của 50 tenant), nhồi vào template không khả thi. Dùng terms lookup:
{
"indices": [
{
"names": ["app-logs-*"],
"privileges": ["read"],
"query": {
"terms": {
"Properties.TenantId": {
"index": "tenant-acl",
"id": "{{_user.username}}",
"path": "allowed_tenants"
}
}
}
}
]
}
Index tenant-acl có doc dạng:
{ "_id": "alice", "allowed_tenants": ["tenant-a", "tenant-c", "tenant-z"] }
Trade-off: mỗi search thêm 1 round trip get doc từ tenant-acl. Với cluster nhỏ vẫn chạy được, cluster lớn nên cache theo session hoặc hardcode trong role.
Phần 10: Audit và debug DLS/FLS
Bật slowlog để spotting query bị block DLS:
PUT app-logs-*/_settings
{
"index.search.slowlog.threshold.query.warn": "5s",
"index.search.slowlog.threshold.fetch.warn": "1s"
}
Test quyền nhanh:
curl -s -u alice:pwd \
-H "Content-Type: application/json" \
-X POST "$ES_URL/_security/user/_has_privileges" \
-d '{
"index": [
{ "names": ["app-logs-*"], "privileges": ["read"] }
]
}'
Test query thực:
curl -s -u alice:pwd \
"$ES_URL/app-logs-2026.05/_search?q=*&size=0" \
| jq '.hits.total'
So với cùng query bằng superuser:
curl -s -u "$ES_USER:$ES_PASS" \
"$ES_URL/app-logs-2026.05/_search?q=*&size=0" \
| jq '.hits.total'
Chênh lệch = doc bị DLS filter.
Pitfall debug: profile API không expose DLS query. Đừng kỳ vọng _profile cho thấy filter đã add. Phải dùng audit log (bài 15) để thấy effective query.
Cheatsheet
| Việc | Cách làm |
|---|---|
| Bật DLS | Field query trong index privilege của role |
| Bật FLS | Field field_security.grant hoặc except |
| Query template theo user | "query": { "template": { "source": "..." } } plus {{_user.metadata.x}} |
| API key tenant-specific | role_descriptors với DLS query hardcoded |
| Multi-tenant ACL động | terms lookup vào index ACL riêng |
| Mask field | Ingest pipeline plus runtime field, không phải FLS pure |
| Check user thấy gì | _search?size=0 so với superuser, đếm .hits.total |
| Check privilege | POST /_security/user/_has_privileges |
| License yêu cầu | Platinum trở lên (DLS, FLS, role template) |
Lời kết
DLS và FLS là cách rẻ nhất để chia một cluster Elasticsearch cho nhiều tenant. Hai pattern tránh dùng: cấp superuser rồi “tin tưởng app” filter giúp (sai vì app bị compromise là vỡ trận), hoặc dựng 1 cluster cho mỗi tenant (đắt và lệch version). Đặt DLS ở role, gắn role vào API key, để Elasticsearch enforce, không phụ thuộc app layer.
Bài 15 sẽ đi vào audit logging: ghi lại ai làm gì, khi nào và từ đâu. Đây là tài liệu duy nhất bạn có khi auditor SOC2 hỏi “chứng minh user X không đọc data tenant Y vào ngày Z”. Không có audit log = không qua audit.