Triển khai ABAC với Open Policy Agent (OPA) và Node.js cho hệ thống Microservices phức tạp

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Bài toán phân quyền khiến mình mất ăn mất ngủ 3 tháng

Mình đang làm ở một dự án web app với 5 developer, hệ thống gồm khoảng 8 microservice viết bằng Node.js. Ban đầu, phân quyền rất đơn giản: admin làm được mọi thứ, user thường thì không. RBAC (Role-Based Access Control) — phân quyền theo vai trò — dùng JWT là xong.

Rồi business logic bắt đầu phình ra. Khách hàng yêu cầu: “Manager chỉ được xem báo cáo của department mình, không được xem của department khác. Nhưng nếu là Manager cấp regional thì lại xem được tất cả departments trong khu vực. Trừ department X đang trong giai đoạn audit thì chỉ Auditor mới được vào.”

Mình thử nhồi hết logic đó vào middleware JWT. Sau 2 tuần, file auth.middleware.js dài 600 dòng, đầy if-else lồng nhau, không ai dám sửa. Thêm yêu cầu mới là cả team ngồi lo vì không biết sửa chỗ nào sẽ break chỗ nào.

Tại sao RBAC không còn đủ dùng

RBAC gán quyền theo vai trò (role). Vấn đề là thực tế không chỉ phụ thuộc vào vai trò — nó còn phụ thuộc vào thuộc tính (attributes):

  • Thuộc tính của user: department, region, cấp độ, trạng thái hợp đồng…
  • Thuộc tính của resource: trạng thái, owner, classification, ngày tạo…
  • Thuộc tính của environment: giờ truy cập, IP, thiết bị…

Khi logic phân quyền kết hợp nhiều thuộc tính như vậy, RBAC buộc bạn phải tạo ra hàng chục role mới (regional-manager-excluding-audit… ai mà đặt tên nổi). Hoặc nhét hết vào code — và đó là con đường dẫn đến địa ngục bảo trì.

ABAC (Attribute-Based Access Control) giải quyết đúng bài toán này: quyền được xác định bằng chính sách (policy) dựa trên tổ hợp thuộc tính, không phải role cứng nhắc.

Ba hướng giải quyết mình đã thử

1. Tự viết ABAC engine trong app

Viết một module policy-engine.js, định nghĩa policy dưới dạng JSON hoặc JS objects, evaluate tại runtime. Cách này nhanh nhưng policy nằm trong code — muốn thay đổi chính sách phải deploy lại app. Với microservice, mỗi service lại có policy riêng, không đồng bộ được.

2. Dùng Casbin

Casbin là thư viện phân quyền mạnh, hỗ trợ ABAC. Policy lưu trong file .conf.csv. Cách này tốt hơn, nhưng khi cần policy phức tạp với nhiều điều kiện lồng nhau, cú pháp Casbin trở nên khó đọc và debug rất khó chịu.

3. Open Policy Agent (OPA) — cách mình đang dùng

OPA là policy engine độc lập, chạy như một sidecar hoặc service riêng. Policy viết bằng ngôn ngữ Rego — declarative, dễ test, dễ audit. Quan trọng nhất: policy tách hoàn toàn khỏi code ứng dụng. Thay đổi chính sách không cần deploy lại service.

Sau 6 tháng chạy production, mình chọn OPA và không hối hận.

Triển khai thực tế với OPA và Node.js

Bước 1: Chạy OPA bằng Docker

OPA chạy như một HTTP service, các microservice gọi API để kiểm tra quyền:

# docker-compose.yml
services:
  opa:
    image: openpolicyagent/opa:latest
    ports:
      - "8181:8181"
    command:
      - "run"
      - "--server"
      - "--log-format=json"
      - "/policies"
    volumes:
      - ./policies:/policies

Bước 2: Viết policy bằng Rego

Tạo file policies/reports.rego — policy cho service báo cáo:

package reports

import future.keywords.if
import future.keywords.in

# Mặc định từ chối
default allow := false

# Auditor được phép xem mọi report đang trong giai đoạn audit
allow if {
    input.user.role == "auditor"
    input.resource.status == "under_audit"
    input.action == "read"
}

# Regional manager xem được report trong khu vực của mình
allow if {
    input.user.role == "manager"
    input.user.level == "regional"
    input.action == "read"
    input.resource.region == input.user.region
    input.resource.status != "under_audit"
}

# Department manager chỉ xem report của department mình
allow if {
    input.user.role == "manager"
    input.user.level == "department"
    input.action == "read"
    input.resource.department_id == input.user.department_id
    input.resource.status != "under_audit"
}

Policy rõ ràng đến mức non-technical stakeholder đọc cũng hiểu được — đây là điểm mình thích nhất ở Rego.

Bước 3: Tích hợp vào Node.js middleware

Tạo một helper gọi OPA API:

// lib/opa-client.js
const axios = require('axios');

const OPA_URL = process.env.OPA_URL || 'http://opa:8181';

async function checkPermission(policyPath, input) {
  try {
    const res = await axios.post(
      `${OPA_URL}/v1/data/${policyPath}`,
      { input },
      { timeout: 500 } // fail fast nếu OPA chết
    );
    return res.data.result?.allow === true;
  } catch (err) {
    console.error('[OPA] Policy check failed:', err.message);
    return false; // fail-closed: từ chối nếu OPA không trả lời
  }
}

module.exports = { checkPermission };

Rồi dùng trong Express middleware:

// middleware/authorize.js
const { checkPermission } = require('../lib/opa-client');

function authorize(policyPath, getResource) {
  return async (req, res, next) => {
    const user = req.user; // từ JWT đã verify
    const resource = await getResource(req);

    const allowed = await checkPermission(policyPath, {
      user: {
        id: user.id,
        role: user.role,
        level: user.level,
        department_id: user.department_id,
        region: user.region,
      },
      resource,
      action: req.method === 'GET' ? 'read' : 'write',
    });

    if (!allowed) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

module.exports = { authorize };

Sử dụng trong route:

// routes/reports.js
const { authorize } = require('../middleware/authorize');
const Report = require('../models/report');

router.get(
  '/reports/:id',
  authenticate, // verify JWT
  authorize('reports/allow', async (req) => {
    const report = await Report.findById(req.params.id);
    return {
      department_id: report.department_id,
      region: report.region,
      status: report.status,
    };
  }),
  async (req, res) => {
    const report = await Report.findById(req.params.id);
    res.json(report);
  }
);

Bước 4: Test policy trước khi deploy

OPA có built-in testing framework — đây là lý do mình ưu tiên nó hơn Casbin:

# policies/reports_test.rego
package reports

test_auditor_can_read_audit_report if {
    allow with input as {
        "user": {"role": "auditor"},
        "resource": {"status": "under_audit"},
        "action": "read"
    }
}

test_dept_manager_cannot_read_other_dept if {
    not allow with input as {
        "user": {"role": "manager", "level": "department", "department_id": "D1"},
        "resource": {"department_id": "D2", "status": "active"},
        "action": "read"
    }
}
# Chạy test
docker run --rm -v $(pwd)/policies:/policies \
  openpolicyagent/opa:latest test /policies -v

Mỗi lần thay đổi policy, chạy test suite trước khi push. Tích hợp vào CI/CD là việc của 15 phút.

Những điều cần lưu ý khi vận hành

Latency: Mỗi request thêm một lần gọi HTTP đến OPA (~1-5ms nếu cùng mạng). Với request nhạy cảm về tốc độ, dùng OPA bundle caching hoặc chạy OPA as library trong process (có SDK cho Go, Python, Wasm).

OPA là single point of failure: Chạy ít nhất 2 replica OPA, cấu hình health check, và middleware phải fail-closed (từ chối nếu OPA không trả lời) thay vì fail-open.

Audit log: OPA hỗ trợ decision log — mọi quyết định allow/deny đều được log lại cùng input. Đây là tính năng vàng cho compliance và debugging.

Trong dự án web app gần nhất với 5 developer, mình áp dụng quy trình này và productivity tăng rõ rệt — cụ thể là khi business yêu cầu thay đổi logic phân quyền, thay vì phải đọc 600 dòng middleware code như trước, giờ chỉ cần sửa file .rego, chạy test, và push. Không cần hiểu internals của từng service. Người viết policy và người viết feature hoàn toàn làm việc độc lập nhau.

Khi nào nên dùng OPA, khi nào không

OPA phù hợp khi: hệ thống nhiều service, policy phức tạp và thay đổi thường xuyên, cần audit trail, team có nhiều người cùng quản lý policy.

Nếu app của bạn chỉ có 2-3 role đơn giản và không có kế hoạch mở rộng, RBAC với JWT claims là đủ — đừng over-engineer. OPA thêm một dependency vào stack, và dependency đó cần được vận hành, monitor, scale cùng hệ thống.

Nhưng nếu bạn đang ngồi debug một file middleware 600 dòng đầy if-else lồng nhau… thì có lẽ đã đến lúc nhìn lại.

Share: