User, Role, Permission, phân quyền API và dữ liệuUsers, roles, permissions for APIs and data
Gateway chỉ xác thực JWT. Article Service làm hai lớp phân quyền: lớp chức năng (user có permission article:update không) và lớp dữ liệu (bản ghi này có thuộc tenant và phạm vi của user không). Permission được tra từ PostgreSQL và cache ở Redis. Chọn “Viewer bị chặn” để xem 403, chọn “IDOR” để xem lớp dữ liệu trả 404.The gateway only authenticates the JWT. The Article Service authorizes in two layers: the functional layer (does the user hold article:update) and the data layer (does this record belong to the user's tenant and scope). Permissions are looked up in PostgreSQL and cached in Redis. Pick “Viewer denied” to see a 403, pick “IDOR” to see the data layer answer 404.
1Editor u-12 của tenant acme sửa bài 77 trong chuyên mục Kinh tế.Editor u-12 of tenant acme edits article 77 in the Business section.
Kiểm tra role không đủ. Mọi truy vấn phải lọc theo tenant_id và phạm vi lấy từ token (owner, section), gói thành Specification dùng lại cho cả findById lẫn danh sách.A role check is not enough. Every query filters by tenant_id and scope taken from the token (owner, section), packaged as a Specification reused by findById and by listings.
Kiểm tra quyền rải rác trong từng handler sẽ có ngày bị quên. Đặt ở một lớp chặn chung: @PreAuthorize chạy qua proxy AOP của Spring, middleware trong Go; endpoint không khai báo quyền thì mặc định denyAll.Checks scattered across handlers get forgotten eventually. Put them in one interception layer: @PreAuthorize runs through a Spring AOP proxy, middleware in Go; an endpoint with no declared permission defaults to denyAll.
Khi luật phụ thuộc thuộc tính (chuyên mục, giờ làm việc, trạng thái bài) thì tạo thêm role cho mỗi tổ hợp là sai hướng. Giữ RBAC cho quyền thô, thêm ABAC cho luật tinh: mỗi luật là một Specification ghép bằng and/or.When rules depend on attributes (section, working hours, article status), adding a role per combination is the wrong direction. Keep RBAC for coarse permissions and add ABAC for fine rules: each rule is a Specification combined with and/or.
Nhét permission vào JWT thì nhanh nhưng thu hồi phải chờ token hết hạn. Giữ token ngắn (10–15 phút), chỉ chứa sub và tid; tra permission từ Redis TTL 5 phút và xoá key khi role đổi.Putting permissions in the JWT is fast, but revocation waits for the token to expire. Keep tokens short (10–15 minutes) with only sub and tid; look permissions up from Redis with a 5-minute TTL and delete the key when roles change.
Lần bị từ chối là tín hiệu tấn công sớm nhất. AccessDeniedHandler (Spring) hoặc middleware (Go) ghi sự kiện có uid, permission, tài nguyên, IP vào topic audit; SIEM cảnh báo theo ngưỡng.Refusals are the earliest attack signal. An AccessDeniedHandler (Spring) or middleware (Go) writes uid, permission, resource and IP to an audit topic; the SIEM alerts on a threshold.
Least privilege: permission dạng resource:action, không có wildcard cho role thường, quyền :any (sửa mọi bài) chỉ cho admin tenant. Rà soát định kỳ các role không dùng trong 90 ngày.Least privilege: resource:action permissions, no wildcards for ordinary roles, the :any permission (edit every article) only for tenant admins. Review roles unused for 90 days on a schedule.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
app_user | id, tenant_id, email, status, created_at | id trùng sub của Keycloak; status=DISABLED thì mọi quyền mất hiệu lựcid equals the Keycloak sub; status=DISABLED voids every permission |
role | id, tenant_id, code, name, is_system | unique(tenant_id, code); role hệ thống dùng chung, role tuỳ chỉnh theo tenantunique(tenant_id, code); system roles are shared, custom roles belong to a tenant |
permission / role_permission | permission(code, description) · role_permission(role_id, permission_code) | code dạng resource:action, ví dụ article:update, article:update:anyCodes look like resource:action, e.g. article:update, article:update:any |
user_role | user_id, role_id, tenant_id, scope (section ids), granted_by, granted_at | Đổi bảng này thì xoá key Redis perm:{tid}:{uid}Any change here deletes the Redis key perm:{tid}:{uid} |
access_denied_log | id, tenant_id, user_id, permission, resource, reason, ip, created_at | reason: missing_permission · out_of_scope; giữ 1 nămreason: missing_permission · out_of_scope; kept for one year |
Hai lớp kiểm tra cho cùng một thao tác sửa bài: permission ở cổng vào, rồi Specification theo tenant và policy theo phạm vi ở tầng dữ liệu.Two layers of checks for one article update: the permission at the entry point, then a tenant Specification and a scope policy at the data layer.
@RestController
@RequestMapping("/articles")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService service;
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('article:update')")
public ArticleDto update(@PathVariable long id,
@Valid @RequestBody UpdateArticle cmd,
@AuthenticationPrincipal Actor actor) {
return service.update(id, cmd, actor);
}
}
@Service
@RequiredArgsConstructor
class ArticleService {
private final ArticleRepository repo; // JpaSpecificationExecutor
private final ArticlePolicy policy;
@Transactional
public ArticleDto update(long id, UpdateArticle cmd, Actor actor) {
Article a = repo.findOne(ArticleSpecs.inTenant(actor.tenantId())
.and(ArticleSpecs.hasId(id)))
.orElseThrow(() -> new NotFoundException("article")); // 404
if (!policy.canEdit(actor, a)) {
throw new AccessDeniedException("article:update out_of_scope");
}
a.apply(cmd, actor.userId());
return ArticleDto.of(a);
}
}
final class ArticleSpecs {
static Specification<Article> inTenant(String tid) {
return (root, q, cb) -> cb.equal(root.get("tenantId"), tid);
}
static Specification<Article> hasId(long id) {
return (root, q, cb) -> cb.equal(root.get("id"), id);
}
}
@Component
class ArticlePolicy {
boolean canEdit(Actor actor, Article a) {
return actor.has("article:update:any")
|| a.authorId().equals(actor.userId())
|| actor.sections().contains(a.sectionId());
}
}// RequirePermission is the functional layer, wrapped around each route.
func RequirePermission(perm string, audit Auditor) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
actor, ok := ActorFrom(r.Context()) // set by the JWT middleware
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if !actor.Has(perm) {
audit.Denied(r.Context(), actor, perm, r.URL.Path, "missing_permission")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Update is the data layer: the tenant filter lives in the query itself.
func (h *ArticleHandler) Update(w http.ResponseWriter, r *http.Request) {
actor, _ := ActorFrom(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
a, err := h.repo.FindInTenant(r.Context(), actor.TenantID, id)
if errors.Is(err, ErrNotFound) {
h.audit.Denied(r.Context(), actor, "article:update", r.URL.Path, "out_of_scope")
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil || !CanEdit(actor, a) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// … decode body, apply, save
}
// routes: deny by default, every route names its permission
// mux.Handle("PUT /articles/{id}", RequirePermission("article:update", aud)(http.HandlerFunc(h.Update)))| Nhúng vào JWTEmbedded in the JWT | Tra cứu + cache RedisLookup with a Redis cache | |
|---|---|---|
| Độ trễLatency | Không gọi thêm gì, đọc claim là xongNo extra call, read the claim and go | ~0,5 ms khi cache hit, vài ms khi miss~0.5 ms on a hit, a few ms on a miss |
| Thu hồi quyềnRevocation | Chờ token hết hạn, hoặc phải có danh sách chặnWait for expiry, or maintain a deny list | Xoá key Redis, hiệu lực ngay hoặc sau TTLDelete the Redis key, effective at once or after the TTL |
| Kích thước tokenToken size | Phình to khi có hàng chục quyền và scope, vượt giới hạn headerGrows with dozens of permissions and scopes, can exceed header limits | Gọn, chỉ có sub và tidSmall, just sub and tid |
| Chọn khiPick it when | Ít role, ít thay đổi, token sống 5–10 phútFew roles, rare changes, tokens live 5–10 minutes | Role tuỳ chỉnh theo tenant, cần thu hồi nhanhCustom roles per tenant, fast revocation required |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
RBAC gán quyền qua role, dễ quản trị và audit. ABAC quyết định dựa trên thuộc tính của user, tài nguyên và ngữ cảnh. Khi luật kiểu “sửa bài trong chuyên mục mình, lúc bài còn DRAFT” xuất hiện, thêm role cho từng tổ hợp sẽ bùng nổ; giữ RBAC cho quyền thô và viết luật ABAC như Specification.RBAC grants permissions through roles and is easy to administer and audit. ABAC decides on attributes of the user, the resource and the context. Once rules like “edit articles in my section while they are DRAFT” appear, a role per combination explodes; keep RBAC for coarse permissions and write ABAC rules as Specifications.
Với bản ghi ngoài phạm vi, trả 404 để không xác nhận nó tồn tại; id tuần tự càng cần điều này. Trả 403 khi user thiếu quyền chức năng, vì endpoint đã công khai. Dù trả gì, vẫn ghi sự kiện từ chối.For a record outside the user's scope, answer 404 so its existence is not confirmed; sequential ids make this more important. Answer 403 when the functional permission is missing, since the endpoint is public anyway. Either way, log the refusal.
Nếu quyền nằm trong JWT thì phải chờ token hết hạn. Thiết kế tốt hơn: token ngắn chỉ chứa định danh, permission tra từ Redis có TTL, và việc đổi user_role xoá key ngay. Với quyền nhạy cảm, thêm cột permission_version so với claim để buộc làm mới.With permissions in the JWT, you wait for the token to expire. A better design: short tokens carrying identity only, permissions from Redis with a TTL, and any user_role change deletes the key at once. For sensitive permissions, add a permission_version compared with a claim to force a refresh.
Không. Gateway biết route và token, không biết bản ghi thuộc ai. Luật dữ liệu phải ở service, sát truy vấn. Service cũng có thể bị gọi nội bộ không qua gateway, nên vẫn phải tự kiểm tra token.No. The gateway knows the route and the token, not who owns the record. Data rules belong in the service, next to the query. Services can also be called internally without the gateway, so they still verify the token themselves.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.