Giao diện
@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 Engine | plugin-acl | |
|---|---|---|
| Vị trí | @digiforce-nc/acl | @digiforce-nc/plugin-acl |
| Dữ liệu | In-memory (Map, Set) | Database (collections roles, rolesResources…) |
| Trách nhiệm | Kiểm tra quyền runtime | CRUD cấu hình quyền, đồng bộ vào engine |
| API | acl.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
| Method | Mô 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:
Role
root- đặc biệt, luôn trả về kết quả allow mà không kiểm tra gì thêm.Kiểm tra gán cụ thể - role có
ACLResourcecho resource này không? Nếu có, action đó đã đượcgrantAction()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).
Kiểm tra snippet - gọi
role.snippetAllowed('resource:action'):- Duyệt snippet đã gán → lấy danh sách action pattern →
minimatchtừng pattern. - Nếu match allow pattern →
true. - Nếu match reject pattern (negation
!) →false. - Không match gì →
null.
- Duyệt snippet đã gán → lấy danh sách action pattern →
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.
- Kiểm tra
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ầuownthì 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:
| Predicate | Filter đượ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
| Condition | Kiểm tra | Dùng cho |
|---|---|---|
'public' | Luôn true | Login, health check, public API |
'loggedIn' | !!ctx.state.currentUser | Profile, settings (cần đăng nhập nhưng role nào cũng được) |
'allowConfigure' | Role có strategy.allowConfigure === true | Admin 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 conditionpubliccụ 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:
- 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.
- Kết quả: tập allowed snippets trừ rejected snippets.
Lớp 2 - Kiểm tra action path có match snippet actions:
- Thu thập tất cả action pattern từ allowed snippets →
allowedActions. - Thu thập tất cả action pattern từ rejected snippets →
rejectedActions. - 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).
- Match bất kỳ allowed pattern →
Ví dụ pattern
| Snippet rule trên role | Snippet đã đăng ký | Match? |
|---|---|---|
ui.* | ui | ✅ (minimatch('ui', 'ui.*')) |
ui.* | pm | ❌ |
pm.* | pm | ✅ |
pm.* | pm.users | ✅ |
!pm.users | pm.users | 🚫 Rejected |
| Action pattern (trong snippet) | Request action path | Match? |
|---|---|---|
posts:* | posts:create | ✅ |
posts:* | posts:destroy | ✅ |
*:view | orders: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:
| Param | Strategy | Lý do |
|---|---|---|
filter | and-merge | Giữ tất cả filter - cả bảo mật lẫn nghiệp vụ |
fields / whitelist | intersect | Chỉ trả field nằm trong tất cả danh sách |
appends / except | union | Gộp relation cần eager load |
sort | overwrite | Chỉ 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):
- Own predicate - nếu
params.owntruthy, mergepredicate.own(filtercreatedById) vào params. - Fields → whitelist - nếu action là
createhoặcupdatevàparams.fieldscó giá trị, chuyển thànhparams.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ước | Kiểm tra | Cách |
|---|---|---|
| 1 | Role đúng chưa? | Log ctx.state.currentRole - có thể là anonymous |
| 2 | AllowManager? | acl.allowManager.isAllowed(resource, action, ctx) |
| 3 | Gán cụ thể? | acl.getRole(role).getResource(resource)?.getAction(action) |
| 4 | Snippet? | acl.getRole(role).snippetAllowed('resource:action') |
| 5 | Strategy? | acl.getRole(role).getStrategy()?.matchAction(action) |
| 6 | strategyResources? | acl.getStrategyResources() - resource có trong tập? |
| 7 | Fixed 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 devSerialze 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
- Engine ACL - phân tích source code chi tiết
- Auth & AuthManager - hệ thống xác thực tích hợp với ACL
- Resourcer & Actions - resource/action mà ACL bảo vệ
- DataSourceManager - mỗi data source có ACL riêng
- FAQ - ACL & Auth
- Mã nguồn