โหมดมืด
บทที่ 3 — Authorization (RBAC, ABAC, ReBAC, Policy)
การให้สิทธิ์ (Authorization) — RBAC (อ่าน อาร์-แบค), ABAC (เอ-แบค), ReBAC (รี-แบค), Policy (พอ-ลิ-ซี)
หลังจบบท คุณจะ:
- เข้าใจความต่างของ AuthN กับ AuthZ
- เลือก model ที่เหมาะ — RBAC vs ABAC vs ReBAC
- ใช้ Spring Security ทำ authorization
- เข้าใจ policy-based access control (OPA, Casbin)
- ป้องกัน BOLA (OWASP API #1)
1. AuthN vs AuthZ — ทบทวน
Authentication (AuthN) "คุณเป็นใคร?" ← บทที่ 2
Authorization (AuthZ) "คุณทำอะไรได้?" ← บทนี้เช่น user คนหนึ่ง login สำเร็จ (auth ผ่าน) แต่ "ลบ user คนอื่นได้ไหม?" — นี่คือคำถามของ authz
2. Access Control Models
DAC (อ่าน แดค / Discretionary) — เจ้าของไฟล์กำหนดเอง (เจอใน Linux file permission, NTFS)
MAC (อ่าน แม็ค / Mandatory) — system กำหนด ใครก็เปลี่ยนไม่ได้ (เจอใน SELinux, AppArmor, ระบบทหาร)
RBAC (อ่าน อาร์-แบค / Role-Based) — กลุ่ม role → permission ⭐ ใช้ทั่วไปสุดในแอปธุรกิจ
ABAC (อ่าน เอ-แบค / Attribute-Based) — rule ที่ดู attribute (role+region+เวลา) ⭐ ยืดหยุ่นสูง
ReBAC (อ่าน รี-แบค / Relationship-Based) — ดูความสัมพันธ์ user↔resource (Google Drive)
PBAC (อ่าน พี-แบค / Policy-Based) — เขียน rule เป็น policy file (OPA, Cedar)
ZBAC (อ่าน ซี-แบค / Zanzibar-style) — ReBAC at scale แบบที่ Google Zanzibar paper เสนอ
Cedar (อ่าน ซี-ดาร์) — policy language ของ AWS (open-sourced 2023) ใช้ใน Amazon Verified Permissions💡 มือใหม่โฟกัส RBAC พอ — ที่เหลือ (DAC/MAC/ABAC/ReBAC/PBAC/ZBAC/Cedar) อ่านผ่าน ๆ ให้รู้ว่ามีอยู่ก็พอ ค่อยกลับมาลึกตอนเจอเคสจริงที่ RBAC เอาไม่อยู่
ตารางเปรียบเทียบ RBAC vs ABAC vs ReBAC (จำให้ได้ 3 ตัวนี้)
| Model | ตัดสินจาก | เหมาะกับ | ตัวอย่างจริง | ปวดหัวเมื่อ |
|---|---|---|---|---|
| RBAC (อาร์-แบค) | role ของ user | แอปทั่วไป, internal tool, admin panel | "admin ลบ post ได้" | role explosion เมื่อ permission combo เยอะ |
| ABAC (เอ-แบค) | attribute หลายตัว (user, resource, env) | rule ซับซ้อน, compliance, geo-fencing | "editor ของ region TH แก้ได้เวลา 9–17" | rule กระจาย, test combinatorial |
| ReBAC (รี-แบค) | ความสัมพันธ์เป็น graph | file sharing, social, multi-tenant SaaS | "Bob มีสิทธิ์ edit เพราะ Alice แชร์ให้" | ต้องมี relation store แยก (SpiceDB/OpenFGA) |
→ App ส่วนใหญ่ใช้ RBAC เป็น base + บางครั้งเพิ่ม ABAC สำหรับ rule เฉพาะ + ReBAC ถ้าโดเมนเป็น sharing/collaboration
3. RBAC — Role-Based Access Control
RBAC เป็นโมเดล authorization ที่นิยมที่สุด — แทนที่จะให้ permission กับ user โดยตรง เราจัด user เข้า "role" (admin/editor/viewer) แล้วผูก permission กับ role ทำให้จัดการสิทธิ์ของคนจำนวนมากง่ายขึ้น ส่วนนี้แสดงทั้ง schema และการตั้งค่าใน Spring Security:
User → Role → Permission
───── ───── ──────────
alice → admin → read:*, write:*, delete:*
bob → editor → read:posts, write:posts
carol → viewer → read:postsSchema
sql
CREATE TABLE users (id, email, ...);
CREATE TABLE roles (id, name); -- ADMIN, EDITOR, VIEWER
CREATE TABLE permissions (id, name); -- "user:create", "post:delete"
CREATE TABLE user_roles (user_id, role_id);
CREATE TABLE role_permissions (role_id, permission_id);Spring Security Setup
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // ⭐ ต้องเปิดบรรทัดนี้ก่อน @PreAuthorize ใน controller ถึงจะทำงาน
// (ถ้าลืมใส่ — annotation จะถูก ignore เงียบ ๆ ไม่มี error!)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/posts/**").hasAnyRole("ADMIN", "EDITOR")
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}Method-level
java
@RestController
public class PostController {
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/posts/{id}")
public void delete(@PathVariable Long id) { ... }
@PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')")
@PostMapping("/posts")
public Post create(@RequestBody Post post) { ... }
@PreAuthorize("hasAuthority('post:read')") // permission แทน role
@GetMapping("/posts/{id}")
public Post get(@PathVariable Long id) { ... }
}💡
hasRolevshasAuthorityvshasAnyRole— สับสนได้ง่าย จำให้แม่น:
hasRole('ADMIN')= role (กลุ่ม) — Spring เติม prefixROLE_ให้อัตโนมัติ (จริง ๆ ใน DB เก็บเป็นROLE_ADMIN)hasAuthority('post:read')= permission เฉพาะเจาะจง — ไม่มี prefix อัตโนมัติ ต้องเขียนเต็มตามที่ store ไว้hasAnyRole('ADMIN','EDITOR')= ผ่านถ้ามี role ใดใน list (OR)rule of thumb: เก็บใน DB เป็น authority/permission granular (
post:read) — ใช้ role เป็นแค่ "ป้ายแปะ" สำหรับ admin UI
Role vs Permission
Role = กลุ่มของ permission (สำหรับ assign ให้ user)
Permission = action เฉพาะ (สำหรับ check)Role "ADMIN" = permission [ user:create, user:read, user:update, user:delete, ... ]
Role "VIEWER" = permission [ user:read ]ในโค้ดควร check permission (granular) — ไม่ใช่ role (coarse):
java
// ❌ Coupling กับ role name
@PreAuthorize("hasRole('ADMIN') or hasRole('SUPER_ADMIN') or hasRole('OWNER')")
// ✅ Permission-based — เพิ่ม role ใหม่ไม่ต้องแก้
@PreAuthorize("hasAuthority('user:delete')")4. ปัญหาของ RBAC ตอน scale
RBAC ง่ายตอนเริ่มแต่พังตอนโต — เกิด "role explosion" ที่ต้องสร้าง role ใหม่ทุกครั้งที่ต้องการ permission combo ใหม่ และมีเคสที่ RBAC ทำไม่ได้เลย (เช่น "แก้ได้เฉพาะ region ตัวเอง" หรือ "เฉพาะเจ้าของ post") นี่คือเหตุผลที่ต้องมี ABAC/ReBAC มาเสริม:
เริ่ม:
ADMIN, EDITOR, VIEWER → ง่าย
โต:
SUPER_ADMIN, ADMIN, BILLING_ADMIN, REGIONAL_ADMIN,
EDITOR, SENIOR_EDITOR, REGIONAL_EDITOR,
VIEWER, BILLING_VIEWER, ANALYTICS_VIEWER, ...
→ "role explosion" — เพิ่ม role ทุกครั้งที่ต้องการ permission combo ใหม่และมี case ที่ RBAC ทำไม่ได้:
- "Editor ของ region ตัวเองเท่านั้น"
- "Post นี้แชร์กับ user A และ B เท่านั้น"
- "Admin หลัง 5 โมงเย็นแก้ไม่ได้"→ ใช้ ABAC หรือ ReBAC เสริม
5. ABAC — Attribute-Based Access Control
Allow IF user.role == "EDITOR"
AND user.region == resource.region
AND now() between 9-17
AND device.ip in office_networkตัดสินใจจาก attribute ของ:
- Subject (user) — role, department, region, age
- Resource — owner, classification, tags
- Action — read, write, delete
- Environment — time, location, IP
java
@PreAuthorize("hasRole('EDITOR') and #post.region == authentication.principal.region")
public Post update(@PathVariable Long id, @RequestBody Post post) { ... }java
// Custom expression
@PreAuthorize("@postSecurity.canEdit(authentication, #id)")
public Post update(@PathVariable Long id) { ... }
@Component("postSecurity")
public class PostSecurity {
public boolean canEdit(Authentication auth, Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
User user = (User) auth.getPrincipal();
return post.getOwnerId().equals(user.getId())
|| user.hasRole("ADMIN")
|| (user.hasRole("EDITOR") && post.getRegion().equals(user.getRegion()));
}
}ข้อดี:
- ✅ ยืดหยุ่นมาก (express rule ใด ๆ ก็ได้)
- ✅ ไม่ต้องสร้าง role ใหม่ทุกครั้ง
ข้อเสีย:
- ❌ Policy กระจาย — debug ลำบาก
- ❌ Performance — evaluate rule ทุก request
- ❌ Test ยาก (combinatorial explosion)
6. ReBAC — Relationship-Based
ReBAC ตัดสินสิทธิ์จาก "ความสัมพันธ์" ระหว่าง user กับ resource — เหมาะกับงานแบบ Google Drive ที่ "ใครเป็นเจ้าของ/ถูกแชร์ด้วย" Google สร้างระบบนี้ชื่อ Zanzibar (มี open source ชื่อ OpenFGA) เก็บความสัมพันธ์เป็น graph แล้ว query ว่า user เข้าถึง resource ได้ไหม:
Google Drive style:
Alice owns Document X
Alice shared "edit" with Bob
Alice shared "view" with team Marketing
Marketing team has 50 members
→ Bob can edit
→ All 50 marketing members can view
→ ใครที่ไม่อยู่ใน relationship → access deniedZanzibar (อ่าน แซน-ซิ-บาร์ / Google's system) + OpenFGA (อ่าน โอ-เพ่น-เอฟ-จี-เอ)
Zanzibar เป็น paper ของ Google (2019) ที่อธิบายระบบเก็บ "ความสัมพันธ์" สำหรับตัดสินสิทธิ์ใน Google Drive / Photos / YouTube — เป็นต้นแบบของ OpenFGA, SpiceDB ฯลฯ
รูปแบบ tuple: object_type:object_id#relation@user
#แยก object กับ relation (เช่น "ใครเป็น owner ของ doc1")@แยก relation กับ user/subject (เช่น "alice เป็น owner")
# Relation graph (ตัวอย่าง tuple — อ่านว่า "doc1 มี owner คือ alice" ฯลฯ)
document:doc1#owner@alice
document:doc1#editor@bob
document:doc1#viewer@team:marketing
team:marketing#member@carol
# Check
ASK: can alice edit document:doc1? → yes (owner)
ASK: can bob edit document:doc1? → yes (editor)
ASK: can carol view document:doc1? → yes (team member)
ASK: can dave edit document:doc1? → noTool
- OpenFGA (Auth0/Okta) — open source Zanzibar implementation; popular ใน SaaS
- SpiceDB (AuthZed) — Zanzibar-faithful Go implementation; มี AuthZed Cloud (managed)
- Ory Keto — Zanzibar-inspired, integrates กับ Ory stack
- Permify — open source Zanzibar-inspired (Go), built-in UI สำหรับ debug
- Warrant (acquired by WorkOS, 2023) — fine-grained authz as a service
- AuthZed Cloud — managed SpiceDB ถ้าไม่อยาก self-host
ใช้สำหรับ:
- File sharing (Google Drive, Dropbox)
- Social network (friend-of-friend)
- Multi-tenant SaaS (workspace, project)
7. PBAC — Policy-Based (OPA, Cedar, Casbin)
Open Policy Agent (OPA) — อ่าน "โอ-ป้า" (ไม่ใช่ โอ-พี-เอ) — engine กลางที่รับ "policy + input" แล้วตอบ allow/deny — เขียน policy ในภาษาชื่อ Rego (อ่าน "เร-โก" หรือ "รี-โก")
📖 Rego คืออะไร? — เป็นภาษา DSL (domain-specific language) สำหรับเขียน policy ของ OPA โดยเฉพาะ syntax คล้าย Datalog/Prolog (declarative — บอก "what" ไม่ใช่ "how") ไม่ใช่ภาษาทั่วไปแบบ Python/Java จะแปลกตาตอนแรก แต่เขียน rule authz ได้กระชับ
rego
# policy.rego
package authz
default allow = false
# admin ทำได้ทุกอย่าง
allow {
input.user.role == "admin"
}
# editor แก้ post ใน region ของตัวเองได้
allow {
input.user.role == "editor"
input.action == "update"
input.resource.type == "post"
input.resource.region == input.user.region
}
# user ดู post ของตัวเองได้
allow {
input.action == "read"
input.resource.owner == input.user.id
}App ถาม OPA:
java
boolean allowed = opa.check(Map.of(
"user", currentUser,
"action", "update",
"resource", post
));Cedar (อ่าน ซี-ดาร์) — alternative ของ Rego ที่ AWS ผลักดัน
AWS open-sourced Cedar ปี 2023 — เป็น policy language ที่ powers Amazon Verified Permissions (AVP) จุดต่างจาก Rego:
| Cedar | Rego (OPA) | |
|---|---|---|
| Type system | strict (compile-time check) | dynamic |
| Decidability | analyzable (ตอบได้ว่า policy 2 ตัวขัดกันไหม) | undecidable (Turing-complete) |
| Performance | sub-ms p99 | ~1ms p99 |
| Ecosystem | AWS-native (AVP, IAM Identity Center) | cloud-agnostic, K8s-native |
| Use case | SaaS authz บน AWS, fine-grained AWS resource | multi-service / K8s / generic policy |
cedar
// ตัวอย่าง policy ใน Cedar
permit (
principal in Group::"editors",
action == Action::"updatePost",
resource
) when {
resource.region == principal.region
};Casbin (อ่าน แคส-บิน) — embeddable policy engine
Casbin = library (Go/Java/Python/Node) สำหรับ embed policy engine ในแอป — รองรับ RBAC/ABAC/ACL ผ่าน "model" file (PERM: Policy/Effect/Request/Matcher) จุดต่างจาก OPA:
- OPA = process แยก (sidecar/service) — เหมาะกับ multi-service
- Casbin = library ใน process เดียวกับ app — เหมาะกับ monolith หรือ single-service ที่อยาก hot-reload policy โดยไม่ต้อง deploy sidecar
OAuth scopes vs Policy engines — เลือกอันไหน?
- OAuth scopes (
read:posts,write:billing) = coarse-grained ทำงานที่ระดับ token — เหมาะกับ "API นี้ให้ทำอะไรได้บ้าง" (machine-to-machine, 3rd party app) - Policy engines (OPA/Cedar/Casbin) = fine-grained ตัดสินรายเคส — เหมาะกับ "user คนนี้ทำกับ resource นี้ได้ไหม"
- ใช้ร่วมกัน: scope กรอง "API ให้เข้า" → policy engine กรอง "ทำกับ resource นี้ได้จริงไหม"
ข้อดี:
- ✅ Policy แยกจาก code (versioned, reviewable)
- ✅ Policy-as-code (Git history)
- ✅ Hot reload (เปลี่ยน policy ไม่ต้อง redeploy app)
- ✅ Same policy across services
ใช้กับ: enterprise, multi-service, complex policy
OPA ใน production — 3 patterns + trade-off
🟡 ขั้นสูง — ข้ามได้ถ้ายังไม่ใช้ microservices/K8s
Section นี้พูดถึง deployment topology ของ OPA ใน production ระดับ microservices บน Kubernetes basic ที่ใช้ Spring Boot service เดียว: ข้ามได้ — Spring Security
@PreAuthorize+ RBAC ก็พอ กลับมาอ่านเมื่อขยับเป็น microservices หลาย service ที่ต้องการ policy กลาง
| Pattern | Latency | Update | เหมาะกับ |
|---|---|---|---|
| Sidecar (Daemon) — OPA รัน process ข้าง app | ~1ms (localhost) | bundle pull every 30s-5m | microservices ส่วนใหญ่ |
Library (embed) — opa-go ใน app | <0.1ms (in-process) | restart หรือ hot reload | latency-critical |
| Central service — OPA cluster shared | ~5-50ms (network) | บังคับ consistency ได้ | ทีมเล็ก ต้องการ central audit |
Bundle distribution: OPA pull policy bundle (.tar.gz) จาก HTTP/S3/OCI registry ทุก N วินาที — git tag ใหม่ → CI build → push → OPA pull
Decision log: OPA log ทุก decision (allow/deny + input + policy used) → ปกติส่งผ่าน HTTP ไปยัง collector (Loki, Datadog, custom service) แล้วค่อย forward เข้า Kafka/SIEM ได้ตามต้องการ
yaml
# opa-config.yaml
bundles:
authz:
service: s3
resource: policies/bundle.tar.gz
polling: { min_delay_seconds: 30 }
decision_logs:
console: true # dev: log ออก stdout
service: log_collector # prod: HTTP service ที่ OPA POST log ไป
services:
log_collector:
url: https://logs.example.com
# หมายเหตุ: ถ้าต้องการส่งเข้า Kafka ตรง ๆ ต้องใช้ sidecar (เช่น Fluent Bit, Vector)
# มา consume HTTP จาก OPA แล้ว produce ไป Kafka ต่อ — OPA ไม่มี Kafka plugin ในตัว8. ป้องกัน BOLA — เข้าถึงข้อมูลคนอื่น (OWASP API Security Top 10 อันดับ #1)
BOLA (อ่าน "โบ-ล่า" / Broken Object Level Authorization) = user A เข้าถึง resource ของ user B ได้เพราะ backend "ไม่เช็คเจ้าของ" แค่ดู ID ที่ส่งมาแล้วคืนข้อมูล — เป็นช่องโหว่ อันดับ 1 ของ OWASP API Security Top 10 ทั้งปี 2019 และ 2023
Pattern ผิดที่เจอบ่อย
java
// ❌ ไม่ check ownership
@GetMapping("/orders/{id}")
public Order get(@PathVariable Long id) {
return orderRepo.findById(id).orElseThrow();
}
// Attacker: GET /orders/2 → ได้ order ของคนอื่นทางแก้
Pattern 1: Query ด้วย user id
java
@GetMapping("/orders/{id}")
public Order get(@PathVariable Long id, @AuthenticationPrincipal User user) {
return orderRepo.findByIdAndUserId(id, user.getId())
.orElseThrow(() -> new NotFoundException());
}Pattern 2: Explicit ownership check
java
@GetMapping("/orders/{id}")
@PreAuthorize("@orderSecurity.canView(authentication, #id)")
public Order get(@PathVariable Long id) {
return orderRepo.findById(id).orElseThrow();
}
@Component
public class OrderSecurity {
public boolean canView(Authentication auth, Long orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
Long userId = ((CustomUserDetails) auth.getPrincipal()).getId();
return order.getUserId().equals(userId);
}
}Pattern 3 (❌ อย่าใช้): @PostAuthorize ตรวจหลัง fetch
java
// ❌ Anti-pattern — ตัวอย่างที่ดูดี แต่มี 2 ปัญหาใหญ่
@PostAuthorize("returnObject.userId == authentication.principal.id")
public Order get(@PathVariable Long id) {
return orderRepo.findById(id).orElseThrow();
}ทำไมห้ามใช้:
Timing leak → ID enumeration:
- ถ้า ID ไม่มีในระบบเลย →
orElseThrow→ 404 (response เร็ว) - ถ้า ID มี แต่ของคนอื่น → query DB สำเร็จ →
@PostAuthorizereject → 403 (response ช้ากว่า) - attacker วน loop ใส่ ID ทีละหมายเลข (
/orders/1,/orders/2, …) แล้ว วัดเวลา response — เร็ว = ID ไม่มี, ช้า = ID มีอยู่ แต่ไม่ใช่ของตัว → enumerate ID ที่ใช้งานอยู่ได้ทั้งหมด แม้จะดูข้อมูลข้างในไม่ได้ก็ตาม (ข้อมูลรั่วระดับ metadata)
- ถ้า ID ไม่มีในระบบเลย →
Write methods commit ก่อน reject: ถ้าใช้กับ
@TransactionalUPDATE/DELETE — method ทำงานจบและ flush ก่อน@PostAuthorizeถึงจะ throw — ต้องอาศัย transaction rollback ที่ทำงานพอดี ถ้าเขียนพลาดนิดเดียวคือข้อมูลถูกแก้ไปแล้ว
Pattern 3 (✅ ที่ถูก): Check-before-fetch ใน security component
java
// ✅ Load → check ownership → ถ้าไม่ใช่เจ้าของ "ตอบ 404 เหมือนของไม่มี"
// → ไม่มี timing difference, ไม่ leak existence
@GetMapping("/orders/{id}")
public Order get(@PathVariable Long id, @AuthenticationPrincipal User user) {
Order order = orderRepo.findById(id)
.orElseThrow(() -> new NotFoundException());
if (!order.getUserId().equals(user.getId())) {
// ⭐ สำคัญ — ตอบ 404 เหมือนของไม่มีเลย (อย่าตอบ 403)
// ไม่งั้น attacker ยังแยก "มี vs ไม่มี" ได้
throw new NotFoundException();
}
return order;
}หรือใช้ Pattern 1/2 (query ด้วย userId ตั้งแต่แรก) ที่ปลอดภัยกว่าโดยธรรมชาติ — DB คืนผลก็คือของเรา ถ้าไม่คืนก็คือไม่มี/ไม่ใช่ของเรา (เวลาเท่ากัน)
⚠️ Test ครบทุก endpoint — เผลอลืม 1 ที่ = BOLA
9. ปกป้องการ "เผลอ" Mass Assignment
Mass Assignment (อ่าน "แมส-แอ็ส-ไซน์-เม้นท์") = การให้ค่า field พร้อมกันทีเดียวจาก request body — รวมถึง field ที่ไม่ควรให้ผู้ใช้เปลี่ยน (เช่น role, isAdmin, tenantId, accountBalance)
เป็นช่องโหว่ที่เกิดจากการ bind request body เข้า entity ตรง ๆ — attacker แค่ส่ง field พิเศษ ("isAdmin": true) ก็ยกระดับสิทธิ์ตัวเองได้ ทางแก้คือใช้ DTO ที่รับเฉพาะ field ที่อนุญาต และบังคับค่า sensitive (เช่น role) จากฝั่ง server เสมอ:
java
// ❌ Bind ทุก field
@PostMapping("/users")
public User create(@RequestBody User user) {
return userRepo.save(user);
}
// Attacker: POST /users { "email": "x@x.com", "isAdmin": true }
// → user ใหม่กลายเป็น admin!java
// ✅ ใช้ DTO ที่จำกัด field
@PostMapping("/users")
public User create(@RequestBody @Valid CreateUserRequest req) {
User u = new User();
u.setEmail(req.email());
u.setName(req.name());
u.setPassword(passwordEncoder.encode(req.password()));
u.setRole(Role.USER); // ⭐ บังคับ — ไม่รับจาก request
return userRepo.save(u);
}
record CreateUserRequest(
@Email String email,
@NotBlank String name,
@NotBlank @Size(min=8) String password
// ⭐ ไม่มี isAdmin ใน DTO
) {}💡 หมายเหตุ Java records + Spring Boot 3+ / Jackson:
- Spring Boot 3.x + Jackson 2.12+ รองรับ
@RequestBodyกับ records โดยอัตโนมัติ (ใช้ canonical constructor) — ไม่ต้องตั้งค่าเพิ่ม- ถ้าใช้ Spring Boot 2.x หรือ Jackson เวอร์ชันเก่า อาจต้องใส่
jackson-module-parameter-namesหรือ compile ด้วย-parametersเพื่อให้ Jackson เห็นชื่อ parameter ของ record- Bean Validation (
@NotBlankฯลฯ) ทำงานบน record components ปกติ แต่ต้องการjakarta.validation(Jakarta EE 9+) ใน Spring Boot 3
10. Multi-Tenancy — แต่ละ Tenant ต้องแยก
SaaS หลาย tenant (workspace) — ห้ามให้ tenant A เห็นข้อมูลของ tenant B
Schema design
sql
-- ทุก table มี tenant_id
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title TEXT,
...
);
CREATE INDEX idx_posts_tenant ON posts(tenant_id);Filter อัตโนมัติ (Hibernate @Filter)
💡 Hibernate
@Filterคืออะไร? — feature ของ Hibernate ORM ที่ injectWHEREclause เพิ่มเติม อัตโนมัติ ทุก query ของ entity ที่ติด annotation นี้ — เปิด/ปิดต่อ session ได้ คล้าย "global filter" สำหรับ multi-tenancy โดยไม่ต้องเขียน WHERE ใน repository ทุกที่
java
// ✅ Hibernate 6+ syntax (Spring Boot 3.x)
// ⚠️ Hibernate 5 ใช้ type = "long" (string); Hibernate 6+ ใช้ type = Long.class (Class<?>)
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = Long.class) // ⭐ Hibernate 6+
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Entity
public class Post { ... }
// ใส่ filter ทุก request — เปิด filter ตอนต้นของแต่ละ HTTP request
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(...) {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", currentTenantId());
chain.doFilter(req, res);
}
}→ ทุก query ของ Post จะมี WHERE tenant_id = ? อัตโนมัติ — ไม่ต้องเขียน WHERE ใน repository ทุกที่ ลืม = data leak ข้าม tenant!
หรือใช้ Row-Level Security (อ่าน RLS = อาร์-แอล-เอส) — Postgres
sql
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Version แบบ "ปลอดภัย" — กันกรณีไม่ได้ SET ก่อน query
-- current_setting(name, true) คืน NULL แทน error ถ้าไม่ได้ SET
-- NULLIF(...,'') กัน empty string cast เป็น bigint ไม่ได้
CREATE POLICY tenant_isolation ON posts
USING (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint
);
-- App set ก่อน query (ทุก session/transaction):
-- 'app.tenant_id' = custom session variable ที่เรากำหนดเอง — แอป SET ก่อน query
-- current_setting() อ่านค่าออกมา; ถ้าไม่เคย SET → คืน NULL (เพราะใส่ true เป็น argument 2)
SET app.tenant_id = '42';
SELECT * FROM posts; -- จะเห็นเฉพาะของ tenant 42
-- หมายเหตุ: ถ้าใช้ current_setting('app.tenant_id')::bigint (ไม่ใส่ true)
-- แล้วลืม SET — query จะ error "missing setting" ทำให้ app crash→ Database-level enforcement — แม้ app bug ก็ยังกัน (defense in depth)
11. Audit Authorization Decisions
นอกจากบังคับสิทธิ์แล้ว ควร "บันทึก" ทุกการตัดสินใจ authz (อนุญาต/ปฏิเสธ) ไว้ด้วย — เพื่อ debug เวลา user บ่นว่าเข้าไม่ได้ และเพื่อ forensic เวลาสืบสวนการโจมตี
✅ วิธีที่ถูก (Spring Security 5.7+) — ใช้ AuthorizationEventPublisher
Spring Security ตั้งแต่ 5.7 publish AuthorizationGrantedEvent / AuthorizationDeniedEvent ทุกครั้งที่ตัดสินสิทธิ์ — ใช้ @EventListener รับมา log ได้เลย:
java
@Component
@Slf4j
public class AuthzAuditListener {
@EventListener
public void onGranted(AuthorizationGrantedEvent<?> event) {
auditLog.record("AUTHZ_ALLOW",
event.getAuthentication().get().getName(),
event.getObject().toString(),
event.getAuthorizationDecision().toString());
}
@EventListener
public void onDenied(AuthorizationDeniedEvent<?> event) {
auditLog.record("AUTHZ_DENY",
event.getAuthentication().get().getName(),
event.getObject().toString(),
event.getAuthorizationDecision().toString());
}
}
// เปิด event publisher ใน SecurityConfig:
@Bean
AuthorizationEventPublisher authorizationEventPublisher(ApplicationEventPublisher publisher) {
return new SpringAuthorizationEventPublisher(publisher);
}❌ วิธีที่ดูใช้ได้แต่ "ไม่ work" — AOP @AfterReturning บน @PreAuthorize
java
// ❌ Anti-pattern — ดูเหมือนใช้ได้แต่จับ deny ไม่ได้
@Component
public class BrokenAuthzAspect {
@AfterReturning("@annotation(preAuthorize)")
public void logSuccess(JoinPoint jp, PreAuthorize preAuthorize) { ... }
@AfterThrowing(value = "@annotation(preAuthorize)", throwing = "ex")
public void logDenied(...) { ... } // ⚠️ จะไม่ fire เพราะ...
}ทำไมไม่ work? — @PreAuthorize ถูกบังคับโดย Spring Security's AuthorizationManagerBeforeMethodInterceptor ที่รัน ก่อน AspectJ join point ของเรา — ถ้า deny, interceptor throw AccessDeniedException ก่อน method ถูกเรียก → AOP @AfterReturning/@AfterThrowing บน method นั้นจะ ไม่ fire (ขึ้นกับลำดับ interceptor และจะต้องตั้ง @Order ระมัดระวัง)
📖 AOP (อ่าน "เอ-โอ-พี" / Aspect-Oriented Programming) = paradigm ที่แยก cross-cutting concern (logging, security, transaction) ออกจาก business logic — แต่ใน Spring Security ใหม่ ๆ ใช้ event-based ดีกว่า AspectJ เพราะรับ deny ที่ interceptor ปฏิเสธมาแล้วได้ครบ
→ Track ทุกการตัดสินใจ → debug + forensic
12. Privilege Escalation — ระวังการ "ยกระดับสิทธิ์"
Privilege Escalation (อ่าน "พริฟ-วิ-เลจ เอส-คา-เล-ชั่น") = การที่ผู้ใช้ได้สิทธิ์เกินที่ควร — มี 2 แบบ จำง่าย ๆ ด้วยภาพแกน:
Admin ▲ vertical = ขยับขึ้นในแนวตั้ง
│ user → admin (สูงขึ้น)
User │ ●←● vertical (เปลี่ยน role ตัวเอง / รั่วจาก endpoint admin)
─────────┼──────────────►
│ horizontal = ขยับข้างในระดับเดียวกัน
User A ●━━━━● User B user A → ข้อมูล user B (ระดับเดียว = BOLA)ส่วนนี้แสดงโค้ดที่เปิดช่องและวิธีแก้ที่ถูกต้อง (แยก endpoint สำหรับเปลี่ยน role + ป้องกันด้วย role check):
Vertical (user → admin)
java
// ❌ User เปลี่ยน role ตัวเอง
@PatchMapping("/users/{id}")
public User update(@PathVariable Long id, @RequestBody User update) {
User existing = userRepo.findById(id).orElseThrow();
BeanUtils.copyProperties(update, existing); // 😱 รวม role
return userRepo.save(existing);
}java
// ✅ DTO ไม่มี role + endpoint แยกสำหรับ admin
@PatchMapping("/users/{id}")
public User updateProfile(@PathVariable Long id, @RequestBody @Valid UpdateProfileRequest req) {
// update เฉพาะ name, bio, avatar
}
@PatchMapping("/users/{id}/role")
@PreAuthorize("hasRole('ADMIN')")
public User updateRole(@PathVariable Long id, @RequestBody RoleUpdateRequest req) { ... }Horizontal (user A → user B's data)
= BOLA (ดูข้างต้น)
13. Time-bound Permission
java
// "ให้สิทธิ์ admin 24 ชั่วโมง"
@Entity
class TemporaryRole {
Long userId;
String role;
Instant expiresAt;
}
@Component
public class RoleResolver {
public List<String> currentRoles(User user) {
List<String> roles = new ArrayList<>(user.getRoles());
tempRoleRepo.findByUserIdAndExpiresAtAfter(user.getId(), Instant.now())
.forEach(tr -> roles.add(tr.getRole()));
return roles;
}
}ใช้กับ:
- Just-in-Time access (admin access ตอนต้องการเท่านั้น)
- Demo / trial period
- Incident response (กรณีฉุกเฉิน)
14. การมอบสิทธิ์ / ปลอมเป็นผู้อื่น (Delegation / Impersonation)
📖 Delegation (อ่าน "เดล-ลี-เก-ชั่น") = การมอบสิทธิ์ของเราให้คนอื่นทำแทนชั่วคราว Impersonation (อ่าน "อิม-เพอ-โซ-เน-ชั่น") = การ "สวมรอย" เข้า session ของผู้ใช้คนอื่น (มักใช้โดย support admin)
บางครั้ง support admin ต้อง "login เป็น user" เพื่อ debug ปัญหา — แต่ถ้าทำหละหลวมจะกลายเป็นช่องโหว่ร้ายแรง ต้องจำกัดด้วย role พิเศษ, บันทึก audit ว่าใคร impersonate ใคร และจำกัดสิ่งที่ทำได้ระหว่างนั้น:
"Admin login เป็น user เพื่อ debug"
java
@PostMapping("/admin/impersonate/{userId}")
@PreAuthorize("hasRole('SUPPORT_ADMIN')")
public LoginResponse impersonate(@PathVariable Long userId,
@AuthenticationPrincipal CustomUserDetails admin) {
User user = userRepo.findById(userId).orElseThrow();
// Generate token ที่ระบุชัดว่า impersonate
String token = jwtService.generateImpersonation(user, admin.getId());
auditLog.record("IMPERSONATE", admin.getId(), userId);
return new LoginResponse(token, user);
}java
// JWT มี extra claim
{
"sub": "42", // user being impersonated
"act": { "sub": "1" }, // actor (admin doing impersonation)
...
}⚠️ ต้อง audit log + notify user ว่าโดน impersonate
15. ⚠️ ข้อผิดพลาดที่พบบ่อย
| ❌ | ✅ |
|---|---|
| Check authorization ที่ frontend อย่างเดียว | บังคับฝั่ง server เสมอ |
| Role hardcoded ใน code | Permission-based check |
| ลืม check ownership | DTO + filter + @PreAuthorize |
| BOLA — query โดยไม่ filter user | query with user_id |
| Mass assignment | DTO ที่มี field จำกัด |
| Same tenant filter ในทุก query (forget) | Hibernate filter / RLS |
| Permission cache forever | Reasonable TTL |
| ไม่ audit authorization decision | log allow + deny |
| Generic 500 บน auth fail | 401 / 403 ที่ถูก |
16. Spring Security Authorization API
Spring Security มีหลายระดับให้บังคับสิทธิ์ — ที่ระดับ URL (requestMatchers), ระดับ method (@PreAuthorize/@PostAuthorize) และระดับ collection (@PostFilter) ส่วนนี้รวม syntax ที่ใช้บ่อยทั้งแบบ role, authority และ SpEL expression ที่เช็คความเป็นเจ้าของ:
java
// On URL pattern
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/posts").hasAuthority("post:create")
// On method
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')")
@PreAuthorize("hasAuthority('user:delete')")
@PreAuthorize("#userId == authentication.principal.id")
@PreAuthorize("@security.canEditPost(authentication, #postId)")
@PostAuthorize("returnObject.ownerId == authentication.principal.id")
// Filter list
@PostFilter("filterObject.public or filterObject.ownerId == authentication.principal.id")
public List<Post> listAll() { ... }
// → กรอง list ตาม condition หลัง fetch จาก DB
// ⚠️ อันตราย 2 ประการ:
// 1. Performance — query DB ก่อน filter ใน memory → ดึงทุกแถวออกมาเสียก่อน
// 2. Pagination broken — ถ้าใช้กับ LIMIT/Pageable เช่นขอ 10 รายการ
// DB คืน 10 แถวแรก → filter ใน memory เหลือ 3 แถว → response ได้ < 10
// → user เห็นหน้าไม่ครบ, "next page" คำนวณผิด, counting ไม่ตรง
// ทางที่ดีกว่า: ใส่เงื่อนไขใน query (WHERE owner_id = ? OR public = true) ตรง ๆ17. Compare Tools
มีเครื่องมือ authorization หลายตัวให้เลือกตามความซับซ้อนของ policy — ตั้งแต่ Spring Security ในแอป (RBAC ง่าย ๆ), OPA (policy ซับซ้อนข้าม service), OpenFGA/SpiceDB (ReBAC) ไปจนถึง cloud IAM ตารางนี้ช่วยเลือกให้เหมาะกับงาน:
| Tool | When |
|---|---|
Spring Security @PreAuthorize | Simple RBAC + light ABAC, in-app (monolith) |
| OPA (Open Policy Agent) | Complex policy, multi-service, K8s-native, cloud-agnostic |
| Cedar / Amazon Verified Permissions (AVP) | AWS-native SaaS authz, decidable policy, sub-ms latency |
| OpenFGA (Auth0/Okta) | ReBAC for SaaS multi-tenant, open source Zanzibar |
| SpiceDB / AuthZed | ReBAC at scale, Zanzibar-faithful (file sharing, social) |
| Casbin | Lightweight policy engine embedded in-app (Go/Java/Python/Node) |
| AWS IAM / GCP IAM | Cloud resource authorization (S3 bucket, GCS) — not for app users |
18. ตัวอย่างจริง — Multi-Tenant SaaS
ปิดท้ายด้วยการรวมทุกแนวคิดเป็นเคสจริงที่พบบ่อย — SaaS แบบ multi-tenant ที่ user เป็นสมาชิกของ workspace และเข้าถึงได้เฉพาะข้อมูลใน workspace ตัวเอง ตัวอย่างนี้แสดงทั้ง schema และ permission check ที่ป้องกันการรั่วข้อมูลข้าม tenant:
java
// Schema
- users(id, email, ...)
- workspaces(id, name)
- workspace_members(user_id, workspace_id, role)
- posts(id, workspace_id, author_id, title)
// Permission check
@Component
public class WorkspaceSecurity {
public boolean canRead(Authentication auth, Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
Long userId = currentUserId(auth);
return isWorkspaceMember(userId, post.getWorkspaceId());
}
public boolean canEdit(Authentication auth, Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
Long userId = currentUserId(auth);
WorkspaceMember member = memberRepo.findByUserIdAndWorkspaceId(userId, post.getWorkspaceId())
.orElseThrow(() -> new AccessDeniedException());
// เจ้าของ post หรือ admin ของ workspace
return post.getAuthorId().equals(userId)
|| member.getRole() == WorkspaceRole.ADMIN;
}
public boolean canDelete(Authentication auth, Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
Long userId = currentUserId(auth);
WorkspaceMember member = memberRepo.findByUserIdAndWorkspaceId(userId, post.getWorkspaceId())
.orElseThrow(() -> new AccessDeniedException());
return member.getRole() == WorkspaceRole.OWNER
|| member.getRole() == WorkspaceRole.ADMIN;
}
}
// Controller
@RestController
public class PostController {
@GetMapping("/posts/{id}")
@PreAuthorize("@workspaceSecurity.canRead(authentication, #id)")
public Post get(@PathVariable Long id) { ... }
@PatchMapping("/posts/{id}")
@PreAuthorize("@workspaceSecurity.canEdit(authentication, #id)")
public Post update(@PathVariable Long id, @RequestBody @Valid UpdatePostRequest req) { ... }
@DeleteMapping("/posts/{id}")
@PreAuthorize("@workspaceSecurity.canDelete(authentication, #id)")
public void delete(@PathVariable Long id) { ... }
}→ Centralized security logic + Spring expression — readable + testable
19. Checkpoint
🛠️ Checkpoint 3.1 — RBAC
สร้าง:
- 3 role: ADMIN, EDITOR, VIEWER
- 5+ permission
- Endpoint protect ด้วย role + permission
- Test ครบทุก case (allowed + denied)
🛠️ Checkpoint 3.2 — Ownership Check
มี /posts/{id} 3 method:
- GET (ทุกคนใน workspace อ่านได้)
- PATCH (author หรือ admin)
- DELETE (admin หรือ owner เท่านั้น)
🛠️ Checkpoint 3.3 — Multi-Tenant
SaaS app — workspace แยก data:
- ใช้ Hibernate filter หรือ Postgres RLS
- Test ว่า user A เห็น data ของ workspace B ไม่ได้
🛠️ Checkpoint 3.4 — OPA
ลง OPA local + เขียน policy แบบ Rego
- "user edit ได้ post ของตัวเอง"
- "admin edit ได้ทุก post"
- "viewer อ่านได้แต่ใน region ตัวเอง"
🛠️ Checkpoint 3.5 — BOLA Test
ใช้ Burp Suite — แก้ ID ใน URL → ดูว่า block หรือไม่
20. สรุปบท
✅ AuthZ ≠ AuthN — สองสิ่งคนละเรื่อง
✅ RBAC = role → permission — ใช้ทั่วไป
✅ ABAC = rule ดู attribute — ยืดหยุ่นกว่า
✅ ReBAC = relationship-based — Google Drive, social
✅ PBAC / OPA = policy เป็น code — multi-service
✅ Check permission > role (granular, decouple)
✅ BOLA #1 — ป้องกันโดย filter ตาม user / @PreAuthorize
✅ ป้องกัน mass assignment ด้วย DTO
✅ Multi-tenant — Hibernate filter / RLS
✅ Authorization decision = ต้อง audit log
✅ บังคับฝั่ง server เสมอ — frontend แค่ UX
21. Glossary — คำศัพท์ + คำอ่าน
| Term | คำอ่าน | ความหมายสั้น ๆ |
|---|---|---|
| AuthN | ออธ-เอ็น | Authentication = "คุณเป็นใคร?" |
| AuthZ | ออธ-แซด | Authorization = "คุณทำอะไรได้?" |
| DAC | แดค | Discretionary AC — เจ้าของกำหนดเอง (Linux file perm) |
| MAC | แม็ค | Mandatory AC — system บังคับ (SELinux) |
| RBAC | อาร์-แบค | Role-Based AC — ใช้ทั่วไป |
| ABAC | เอ-แบค | Attribute-Based AC — rule ดู attribute |
| ReBAC | รี-แบค | Relationship-Based AC — graph ของความสัมพันธ์ |
| PBAC | พี-แบค | Policy-Based AC — policy เป็น code |
| ZBAC | ซี-แบค | Zanzibar-style ReBAC at scale |
| BOLA | โบ-ล่า | Broken Object Level Auth — OWASP API #1 |
| OPA | โอ-ป้า | Open Policy Agent — engine policy กลาง |
| Rego | เร-โก / รี-โก | DSL ของ OPA (คล้าย Datalog/Prolog) |
| Cedar | ซี-ดาร์ | Policy language ของ AWS (powers AVP) |
| AVP | เอ-วี-พี | Amazon Verified Permissions |
| Casbin | แคส-บิน | Embeddable policy engine (Go/Java/Python) |
| Zanzibar | แซน-ซิ-บาร์ | Paper ของ Google เรื่อง ReBAC at scale |
| OpenFGA | โอ-เพ่น-เอฟ-จี-เอ | open source Zanzibar (Auth0/Okta) |
| SpiceDB | สไปซ์-ดี-บี | Zanzibar-faithful Go impl (AuthZed) |
| Permify | เพอ-มิ-ฟาย | open source ReBAC (Go) |
| RLS | อาร์-แอล-เอส | Row-Level Security (Postgres) |
| DTO | ดี-ที-โอ | Data Transfer Object |
| AOP | เอ-โอ-พี | Aspect-Oriented Programming |
| SpEL | สเป้ล | Spring Expression Language |
| BOLA | โบ-ล่า | Broken Object Level Authorization |
| Mass Assignment | แมส-แอ็ส-ไซน์-เม้นท์ | bind request body เข้า entity ทั้งก้อน |
| Privilege Escalation | พริฟ-วิ-เลจ เอส-คา-เล-ชั่น | ยกระดับสิทธิ์เกินที่ควร |
| Delegation | เดล-ลี-เก-ชั่น | มอบสิทธิ์ให้คนอื่นทำแทน |
| Impersonation | อิม-เพอ-โซ-เน-ชั่น | สวมรอยเป็น user อื่น |