Bỏ qua, đến nội dung

@digiforce-nc/acl

Engine phân quyền in-memory của Digiforce - quyết định ai được làm gì trên dữ liệu nào.

typescript
import { ACL } from '@digiforce-nc/acl';

Tổng quan

Hãy hình dung hệ thống phân quyền như một bảo vệ tòa nhà:

  • Role = thẻ nhân viên (admin, editor, viewer).
  • Resource = phòng trong tòa nhà (posts, users, orders).
  • Action = hành động trong phòng (create, view, update, destroy).
  • Strategy = quy tắc mặc định trên thẻ (admin vào mọi phòng, viewer chỉ xem).
  • Snippet = nhóm phòng gắn tên (ui.* = tất cả phòng giao diện).
  • Fixed params = kính lọc - dù được vào phòng, chỉ thấy đồ thuộc về mình.
  • Allow = danh sách cửa mở tự do, không cần thẻ (login, health check).

ACL engine không đọc/ghi database - nó hoạt động thuần trên bộ nhớ. Plugin plugin-acl chịu trách nhiệm đọc cấu hình quyền từ DB rồi nạp vào engine.

ACL engine vs plugin-acl

ACL Engineplugin-acl
Vị trí@digiforce-nc/acl@digiforce-nc/plugin-acl
Dữ liệuIn-memory (Map, Set)Database (collections roles, rolesResources…)
Trách nhiệmKiểm tra quyền runtimeCRUD cấu hình quyền, đồng bộ vào engine
APIacl.define(), acl.can()Admin UI, REST API quản lý roles

Kiến trúc tổng thể

Mối quan hệ giữa các thành phần:


1. Định nghĩa Role - acl.define()

Role là đơn vị phân quyền cơ bản. Mỗi role có:

  • Strategy mặc định - quy tắc cho action chưa được gán cụ thể.
  • Actions - quyền gán trực tiếp cho từng resource:action.
  • Snippets - nhóm quyền theo glob pattern.
typescript
const acl = new ACL();

// Tạo role "admin" với strategy "toàn quyền"
const admin = acl.define({
  role: 'admin',
  strategy: {
    actions: ['create', 'view', 'update', 'destroy', 'list'],
    allowConfigure: true,
  },
});

// Tạo role "editor" với quyền hạn chế
const editor = acl.define({
  role: 'editor',
  strategy: { actions: ['view', 'list'] },  // mặc định chỉ xem
  actions: {
    'posts:create': {},                       // gán thêm quyền tạo bài
    'posts:update': {
      filter: { status: { $ne: 'published' } }, // chỉ sửa bài chưa publish
    },
  },
  snippets: ['ui.*'],                         // cho phép tất cả action UI
});

Cách define() hoạt động bên trong

ACLRole API

MethodMô tả
grantAction(path, options?)Cấp quyền cho resource:action. options có thể chứa filter, fields, own.
revokeAction(path)Thu hồi quyền đã cấp.
revokeResource(name)Thu hồi toàn bộ quyền trên resource (kể cả association).
setStrategy(strategy)Đặt strategy - string (tên đã đăng ký) hoặc object { actions, allowConfigure }.
getStrategy()Trả về instance ACLAvailableStrategy tương ứng.
setSnippets(snippets)Gán mảng snippet name cho role.
snippetAllowed(actionPath)Kiểm tra action path có được snippet cho phép không. Trả true, false, hoặc null (không liên quan).
toJSON()Serialize role thành object { role, strategy, actions, snippets }.

2. Kiểm tra quyền - acl.can()

Đây là hàm cốt lõi - trả lời câu hỏi "Role X có được làm action Y trên resource Z không?"

typescript
const result = acl.can({
  role: 'editor',
  resource: 'posts',
  action: 'update',
});

if (result) {
  console.log('Được phép');
  console.log('Params bổ sung:', result.params);
  // { role: 'editor', resource: 'posts', action: 'update',
  //   params: { filter: { status: { $ne: 'published' } } } }
} else {
  console.log('Không có quyền');  // null
}

Thuật toán can() - từng bước

Chi tiết từng bước:

  1. Role root - đặc biệt, luôn trả về kết quả allow mà không kiểm tra gì thêm.

  2. Kiểm tra gán cụ thể - role có ACLResource cho resource này không? Nếu có, action đó đã được grantAction() chưa?

    • Nếu resource tồn tại nhưng action không → deny (đã config resource nhưng không cho action này).
  3. Kiểm tra snippet - gọi role.snippetAllowed('resource:action'):

    • Duyệt snippet đã gán → lấy danh sách action pattern → minimatch từng pattern.
    • Nếu match allow pattern → true.
    • Nếu match reject pattern (negation !) → false.
    • Không match gì → null.
  4. Kiểm tra strategy - gọi strategy.allow(resource, action):

    • Kiểm tra strategyResources - nếu đã set, chỉ áp dụng strategy cho resource nằm trong tập này.
    • strategy.matchAction() kiểm tra action có nằm trong danh sách cho phép.
  5. Merge fixedParams - luôn merge fixedParamsManager.getParams() vào kết quả, bất kể quyền đến từ đâu.

Multi-role - kiểm tra nhiều role cùng lúc

User có thể có nhiều role. Khi gọi can({ roles: ['editor', 'reviewer'], ... }):

Merge params giữa các role:

  • filter$or (nới rộng - union quyền truy cập).
  • fields / whitelist → gộp (concat + unique).
  • appends → union.
  • own → OR logic (một role không yêu cầu own thì bỏ).

Tại sao filter merge bằng $or?

Vì nhiều role = nới rộng quyền. Nếu role A cho xem bài viết "own" và role B cho xem bài viết "department", kết quả phải là xem được cả hai: { $or: [ownFilter, deptFilter] }.


3. Strategy - quy tắc mặc định

Strategy quyết định hành vi cho các action chưa được gán cụ thể trên role.

Đăng ký strategy mẫu

typescript
acl.setAvailableStrategy('full-access', {
  displayName: 'Toàn quyền',
  actions: ['create', 'view', 'update', 'destroy', 'list', 'export', 'import'],
  allowConfigure: true,
});

acl.setAvailableStrategy('read-only', {
  displayName: 'Chỉ xem',
  actions: ['view', 'list'],
  allowConfigure: false,
});

Strategy với predicate - own

Action có thể kèm predicate để giới hạn phạm vi:

typescript
acl.setAvailableStrategy('member', {
  actions: ['view', 'list', 'create', 'update:own', 'destroy:own'],
  // "update:own" = cho phép update nhưng chỉ record do mình tạo
});

Predicate có sẵn:

PredicateFilter được injectÝ nghĩa
own{ createdById: '{{ ctx.state.currentUser.id }}' }Chỉ record do user hiện tại tạo
all{}Không giới hạn

Khi matchAction('update') gặp update:own, kết quả trả về object filter thay vì true. Filter này được merge vào params của CanResult.

Luồng kiểm tra strategy

strategyResources - giới hạn phạm vi strategy

Mặc định, strategy áp dụng cho mọi resource. Nếu set strategyResources, strategy chỉ áp dụng cho resource nằm trong tập:

typescript
acl.setStrategyResources(new Set(['posts', 'comments', 'categories']));

// Strategy chỉ hoạt động trên 3 resource trên
// Request đến resource "users" → strategy không áp dụng → phải có gán cụ thể

plugin-acl sử dụng cơ chế này để chỉ áp dụng strategy trên các collection do plugin quản lý.


4. AllowManager - ngoại lệ public

AllowManager quản lý danh sách resource/action không cần kiểm tra quyền - giống "cửa mở tự do" trong tòa nhà.

Đăng ký ngoại lệ

typescript
// Không cần đăng nhập
acl.allow('auth', ['signIn', 'signUp']);     // mặc định condition = 'public'
acl.allow('app', 'getLang');

// Cần đăng nhập nhưng không cần role cụ thể
acl.allow('users', 'updateProfile', 'loggedIn');

// Chỉ role có allowConfigure
acl.allow('collections', 'list', 'allowConfigure');

// Custom condition
acl.allow('posts', 'list', (ctx) => {
  return ctx.headers['x-api-key'] === 'valid-key';
});

// Wildcard - tất cả action trên resource
acl.allow('health', '*');

// Wildcard - action này trên mọi resource
acl.allow('*', 'getInfo');

Condition có sẵn

ConditionKiểm traDùng cho
'public'Luôn trueLogin, health check, public API
'loggedIn'!!ctx.state.currentUserProfile, settings (cần đăng nhập nhưng role nào cũng được)
'allowConfigure'Role có strategy.allowConfigure === trueAdmin settings, collection management

Đăng ký condition tùy chỉnh

typescript
acl.allowManager.registerAllowCondition('hasApiKey', (ctx) => {
  const key = ctx.headers['x-api-key'];
  return key && isValidApiKey(key);
});

acl.allow('api', '*', 'hasApiKey');

Luồng kiểm tra AllowManager

isAllowed vs isPublic

  • isAllowed(resource, action, ctx) - kiểm tra bất kỳ condition nào match (public, loggedIn, custom...).
  • isPublic(resource, action, ctx) - chỉ kiểm tra condition public cụ thể (dùng để quyết định có cần auth không).

5. Snippet - nhóm quyền theo pattern

Snippet cho phép gán quyền hàng loạt bằng glob pattern, thay vì liệt kê từng resource:action.

Đăng ký snippet

typescript
acl.registerSnippet({
  name: 'ui',
  actions: [
    'uiSchemas:*',        // tất cả action trên uiSchemas
    'uiRoutes:*',
  ],
});

acl.registerSnippet({
  name: 'pm',
  actions: [
    'applicationPlugins:*',  // quản lý plugin
    'pm:*',
  ],
});

acl.registerSnippet({
  name: 'pm.users',
  actions: [
    'users:*',            // quản lý users
    'roles:*',
  ],
});

Gán snippet cho role

typescript
const admin = acl.define({ role: 'admin' });
admin.setSnippets(['ui.*', 'pm.*']);     // admin có tất cả snippet ui.* và pm.*

const editor = acl.define({ role: 'editor' });
editor.setSnippets(['ui.*', '!pm.*']);   // editor có ui.* nhưng KHÔNG có pm.*

Thuật toán matching - hai lớp

Snippet matching có hai lớp glob pattern:

Lớp 1 - Xác định snippet nào role được dùng:

  1. Với mỗi snippet rule trong role.snippets (ví dụ 'ui.*'):
    • Glob match rule này với tên các snippet đã đăng ký.
    • Rule bắt đầu ! → snippet matched bị rejected.
    • Rule thường → snippet matched được allowed.
  2. Kết quả: tập allowed snippets trừ rejected snippets.

Lớp 2 - Kiểm tra action path có match snippet actions:

  1. Thu thập tất cả action pattern từ allowed snippets → allowedActions.
  2. Thu thập tất cả action pattern từ rejected snippets → rejectedActions.
  3. Với action path (ví dụ 'uiSchemas:getSchema'):
    • Match bất kỳ allowed pattern → true.
    • Match bất kỳ rejected pattern → false.
    • Không match gì → null (snippet không liên quan, tiếp tục kiểm tra strategy).

Ví dụ pattern

Snippet rule trên roleSnippet đã đăng kýMatch?
ui.*ui✅ (minimatch('ui', 'ui.*'))
ui.*pm
pm.*pm
pm.*pm.users
!pm.userspm.users🚫 Rejected
Action pattern (trong snippet)Request action pathMatch?
posts:*posts:create
posts:*posts:destroy
*:vieworders:view
!users:*users:update🚫 Rejected

Negation luôn thắng

Nếu action path match cả allowed pattern và rejected pattern, kết quả là rejected. Negation có ưu tiên cao nhất.


6. Fixed Params - filter ẩn theo quyền

FixedParamsManager cho phép inject params tự động vào mọi request phù hợp. Phổ biến nhất: giới hạn dữ liệu theo user (data isolation).

Cách hoạt động

typescript
// User chỉ thấy record do mình tạo
acl.addFixedParams('posts', 'list', () => ({
  filter: { createdById: ctx.state.currentUser.id },
}));

// User chỉ thấy record trong department của mình
acl.addFixedParams('orders', 'list', () => ({
  filter: { departmentId: { $in: ctx.state.currentUser.departments } },
}));

// Giới hạn field trả về
acl.addFixedParams('users', 'list', () => ({
  fields: ['name', 'email', 'avatar'],
}));

General fixed params - áp dụng cho mọi resource

typescript
acl.addGeneralFixedParams((resource, action) => {
  if (action === 'list') {
    return { filter: { deletedAt: null } };  // soft delete
  }
  return {};
});

Chiến lược merge

Khi merge fixed params vào params đã có (từ can() hoặc user gửi lên), mỗi loại param dùng strategy khác nhau:

ParamStrategyLý do
filterand-mergeGiữ tất cả filter - cả bảo mật lẫn nghiệp vụ
fields / whitelistintersectChỉ trả field nằm trong tất cả danh sách
appends / exceptunionGộp relation cần eager load
sortoverwriteChỉ có một thứ tự sort cuối cùng

Không thể bypass

Fixed params được inject ở tầng ACL middleware - trước action handler. Client không thể gửi filter ghi đè filter bảo mật. Đây là cơ chế data isolation cốt lõi.


7. Available Action & Action Alias

Đăng ký action hợp lệ

Plugin đăng ký action để hệ thống biết action nào tồn tại:

typescript
acl.setAvailableAction('create', {
  type: 'new-data',
  displayName: 'Tạo mới',
  onNewRecord: true,
  allowConfigureFields: true,
});

acl.setAvailableAction('view', {
  type: 'old-data',
  displayName: 'Xem',
  aliases: ['get'],  // "get" là alias của "view"
});

acl.setAvailableAction('export', {
  type: 'old-data',
  displayName: 'Xuất dữ liệu',
});

Action alias

Alias cho phép nhiều tên chỉ cùng một action:

typescript
// Khi đăng ký action "view" với aliases: ['get']
// Thì acl.can({ role, resource, action: 'get' })
// sẽ tự động resolve thành action: 'view'

resolveActionAlias(action) được gọi trong can(), strategy.allow(), và beforeGrantAction. Nghĩa là dù gọi grantAction('posts:get') hay grantAction('posts:view'), kết quả tương đương.


8. Middleware - tích hợp request pipeline

ACL middleware được đăng ký vào data source pipeline, chạy sau Auth (xác thực) và trước action handler.

Luồng xử lý chi tiết

parseJsonTemplate - thay thế biến trong filter

Filter từ strategy predicate (ví dụ own) chứa template string:

json
{ "createdById": "{{ ctx.state.currentUser.id }}" }

parseJsonTemplate thay thế bằng giá trị thực từ ctx trước khi merge vào query.

checkFilterParams - bảo vệ filter không hợp lệ

Nếu filter chứa createdById nhưng collection không có field createdById, middleware throw NoPermissionError → 403. Điều này ngăn lỗi runtime khi strategy own được áp dụng lên collection không phù hợp.


9. Event hooks

ACL kế thừa EventEmitter, cung cấp hook cho plugin can thiệp vào quá trình phân quyền.

beforeGrantAction

Được emit mỗi khi grantAction() được gọi - cho phép biến đổi params trước khi lưu.

typescript
acl.beforeGrantAction((ctx) => {
  // ctx: { acl, role, path, actionName, resourceName, params }

  // Tùy chỉnh params trước khi lưu
  if (ctx.params.custom) {
    ctx.params.filter = buildCustomFilter(ctx.params.custom);
  }
});

Listener mặc định (đăng ký trong constructor):

  1. Own predicate - nếu params.own truthy, merge predicate.own (filter createdById) vào params.
  2. Fields → whitelist - nếu action là create hoặc updateparams.fields có giá trị, chuyển thành params.whitelist (whitelist phù hợp hơn cho write actions).

10. NoPermissionError

Khi middleware ACL phát hiện request không có quyền:

typescript
import { NoPermissionError } from '@digiforce-nc/acl';

try {
  // ACL middleware tự động throw
} catch (e) {
  if (e instanceof NoPermissionError) {
    // HTTP 403 Forbidden
    // e.message mô tả lý do
  }
}

Middleware bắt NoPermissionError và gọi ctx.throw(403, 'No permissions').


11. Ví dụ tổng hợp - từ đầu đến cuối

typescript
import { ACL } from '@digiforce-nc/acl';

const acl = new ACL();

// ═══════════════════════════════════════════
// 1. Đăng ký action hợp lệ trong hệ thống
// ═══════════════════════════════════════════
acl.setAvailableAction('create', {
  type: 'new-data',
  displayName: 'Tạo mới',
  onNewRecord: true,
});
acl.setAvailableAction('view', {
  type: 'old-data',
  displayName: 'Xem',
  aliases: ['get'],
});
acl.setAvailableAction('update', { type: 'old-data', displayName: 'Cập nhật' });
acl.setAvailableAction('destroy', { type: 'old-data', displayName: 'Xoá' });
acl.setAvailableAction('list', { type: 'old-data', displayName: 'Danh sách' });
acl.setAvailableAction('export', { type: 'old-data', displayName: 'Xuất' });

// ═══════════════════════════════════════════
// 2. Đăng ký strategy mẫu
// ═══════════════════════════════════════════
acl.setAvailableStrategy('full', {
  displayName: 'Toàn quyền',
  actions: ['create', 'view', 'update', 'destroy', 'list', 'export'],
  allowConfigure: true,
});

acl.setAvailableStrategy('member', {
  displayName: 'Thành viên',
  actions: ['view', 'list', 'create', 'update:own', 'destroy:own'],
  allowConfigure: false,
});

// ═══════════════════════════════════════════
// 3. Đăng ký snippet
// ═══════════════════════════════════════════
acl.registerSnippet({
  name: 'ui',
  actions: ['uiSchemas:*', 'uiRoutes:*'],
});

acl.registerSnippet({
  name: 'pm',
  actions: ['applicationPlugins:*', 'pm:*'],
});

acl.registerSnippet({
  name: 'pm.users',
  actions: ['users:*', 'roles:*'],
});

// ═══════════════════════════════════════════
// 4. Định nghĩa role
// ═══════════════════════════════════════════
acl.define({
  role: 'root',
  // root luôn được phép mọi thứ - không cần config
});

acl.define({
  role: 'admin',
  strategy: 'full',
  snippets: ['ui.*', 'pm.*'],
});

const editor = acl.define({
  role: 'editor',
  strategy: 'member',
  snippets: ['ui.*'],
});
editor.grantAction('posts:export');  // thêm quyền export riêng cho posts

const viewer = acl.define({
  role: 'viewer',
  strategy: { actions: ['view', 'list'] },
});

// ═══════════════════════════════════════════
// 5. Public access - không cần đăng nhập
// ═══════════════════════════════════════════
acl.allow('auth', ['signIn', 'signUp']);
acl.allow('app', 'getLang');
acl.allow('posts', 'list', 'loggedIn');  // list cần đăng nhập

// ═══════════════════════════════════════════
// 6. Fixed params - data isolation
// ═══════════════════════════════════════════
acl.addFixedParams('posts', 'list', () => ({
  filter: { status: 'published' },  // viewer chỉ thấy bài đã publish
}));

// ═══════════════════════════════════════════
// 7. Kiểm tra quyền
// ═══════════════════════════════════════════

// root → luôn allow
acl.can({ role: 'root', resource: 'anything', action: 'anything' });
// → { role: 'root', resource: 'anything', action: 'anything' }

// admin → full strategy
acl.can({ role: 'admin', resource: 'posts', action: 'destroy' });
// → { role: 'admin', resource: 'posts', action: 'destroy', params: {} }

// editor → update:own (strategy "member")
acl.can({ role: 'editor', resource: 'posts', action: 'update' });
// → { ..., params: { filter: { createdById: '<<template>>' } } }

// editor → export (gán cụ thể)
acl.can({ role: 'editor', resource: 'posts', action: 'export' });
// → { ..., params: {} }

// editor → destroy posts (member strategy)
acl.can({ role: 'editor', resource: 'posts', action: 'destroy' });
// → { ..., params: { filter: { createdById: ... } } } (destroy:own)

// viewer → destroy posts (không có trong strategy)
acl.can({ role: 'viewer', resource: 'posts', action: 'destroy' });
// → null (không có quyền)

// admin → snippet match (ui.*)
acl.can({ role: 'admin', resource: 'uiSchemas', action: 'getSchema' });
// → { ..., params: {} } (match snippet "ui" → action "uiSchemas:*")

12. Debug phân quyền

Checklist khi gặp 403

BướcKiểm traCách
1Role đúng chưa?Log ctx.state.currentRole - có thể là anonymous
2AllowManager?acl.allowManager.isAllowed(resource, action, ctx)
3Gán cụ thể?acl.getRole(role).getResource(resource)?.getAction(action)
4Snippet?acl.getRole(role).snippetAllowed('resource:action')
5Strategy?acl.getRole(role).getStrategy()?.matchAction(action)
6strategyResources?acl.getStrategyResources() - resource có trong tập?
7Fixed params?Dữ liệu có nhưng bị filter hết (không phải 403 nhưng 0 records)

Bật debug log

bash
DEBUG=acl* bun dev

Serialze role để kiểm tra

typescript
const roleConfig = acl.getRole('editor').toJSON();
console.log(JSON.stringify(roleConfig, null, 2));
// {
//   role: 'editor',
//   strategy: { actions: ['view', 'list', 'create', 'update:own', 'destroy:own'] },
//   actions: { 'posts:export': {} },
//   snippets: ['ui.*']
// }

Đọc thêm