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ó tenantId cụ 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ôngDLS chặnFLS chặn
Đọc data tenant kháckhông (chỉ ẩn field)
Đọc cột password hashkhông (vẫn thấy doc)
Đếm số doc tenant kháckhông
Aggregation trên field cấmkhông

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 UserToken không bao giờ trả về.
  • Field UserEmail chỉ hiển thị domain (mask local part).
  • Field Ip chỉ trả về cho role security.

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ì alice@example.com, 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ớ:

  1. 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.
  2. 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ệcCách làm
Bật DLSField query trong index privilege của role
Bật FLSField field_security.grant hoặc except
Query template theo user"query": { "template": { "source": "..." } } plus {{_user.metadata.x}}
API key tenant-specificrole_descriptors với DLS query hardcoded
Multi-tenant ACL độngterms lookup vào index ACL riêng
Mask fieldIngest 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 privilegePOST /_security/user/_has_privileges
License yêu cầuPlatinum 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.