Skip to content

บทที่ 3 — Authorization (RBAC, ABAC, ReBAC, Policy)

การให้สิทธิ์ (Authorization) — RBAC (อ่าน อาร์-แบค), ABAC (เอ-แบค), ReBAC (รี-แบค), Policy (พอ-ลิ-ซี)

← บทที่ 2 | สารบัญ | บทที่ 4 →

หลังจบบท คุณจะ:

  • เข้าใจความต่างของ 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 (รี-แบค)ความสัมพันธ์เป็น graphfile 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:posts

Schema

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) { ... }
}

💡 hasRole vs hasAuthority vs hasAnyRole — สับสนได้ง่าย จำให้แม่น:

  • hasRole('ADMIN') = role (กลุ่ม) — Spring เติม prefix ROLE_ ให้อัตโนมัติ (จริง ๆ ใน 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 denied

Zanzibar (อ่าน แซน-ซิ-บาร์ / 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?   → no

Tool

  • 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:

CedarRego (OPA)
Type systemstrict (compile-time check)dynamic
Decidabilityanalyzable (ตอบได้ว่า policy 2 ตัวขัดกันไหม)undecidable (Turing-complete)
Performancesub-ms p99~1ms p99
EcosystemAWS-native (AVP, IAM Identity Center)cloud-agnostic, K8s-native
Use caseSaaS authz บน AWS, fine-grained AWS resourcemulti-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 กลาง

PatternLatencyUpdateเหมาะกับ
Sidecar (Daemon) — OPA รัน process ข้าง app~1ms (localhost)bundle pull every 30s-5mmicroservices ส่วนใหญ่
Library (embed)opa-go ใน app<0.1ms (in-process)restart หรือ hot reloadlatency-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();
}

ทำไมห้ามใช้:

  1. Timing leak → ID enumeration:

    • ถ้า ID ไม่มีในระบบเลย → orElseThrow → 404 (response เร็ว)
    • ถ้า ID มี แต่ของคนอื่น → query DB สำเร็จ → @PostAuthorize reject → 403 (response ช้ากว่า)
    • attacker วน loop ใส่ ID ทีละหมายเลข (/orders/1, /orders/2, …) แล้ว วัดเวลา response — เร็ว = ID ไม่มี, ช้า = ID มีอยู่ แต่ไม่ใช่ของตัว → enumerate ID ที่ใช้งานอยู่ได้ทั้งหมด แม้จะดูข้อมูลข้างในไม่ได้ก็ตาม (ข้อมูลรั่วระดับ metadata)
  2. Write methods commit ก่อน reject: ถ้าใช้กับ @Transactional UPDATE/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 (@Email, @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 ที่ inject WHERE clause เพิ่มเติม อัตโนมัติ ทุก 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 ใน codePermission-based check
ลืม check ownershipDTO + filter + @PreAuthorize
BOLA — query โดยไม่ filter userquery with user_id
Mass assignmentDTO ที่มี field จำกัด
Same tenant filter ในทุก query (forget)Hibernate filter / RLS
Permission cache foreverReasonable TTL
ไม่ audit authorization decisionlog allow + deny
Generic 500 บน auth fail401 / 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 ตารางนี้ช่วยเลือกให้เหมาะกับงาน:

ToolWhen
Spring Security @PreAuthorizeSimple 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 / AuthZedReBAC at scale, Zanzibar-faithful (file sharing, social)
CasbinLightweight policy engine embedded in-app (Go/Java/Python/Node)
AWS IAM / GCP IAMCloud 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 อื่น

← บทที่ 2 | บทที่ 4 → Cryptography for Devs