Authorization
บทเรียนก่อนหน้าตอบคำถาม ใครเป็นคนถาม? และจอดผลลัพธ์ไว้บน context บทเรียนนี้ตอบคำถามข้อที่สอง: เขาได้รับอนุญาตหรือไม่? นั่นคือ authorization — การตัดสินทีละ field และ type ว่า identity ปัจจุบันอาจเห็นข้อมูลนี้ได้หรือไม่ Authentication คือ yes/no เดียวที่ขอบ; authorization คือการตัดสินเล็ก ๆ นับพันที่ถักทอผ่านทั้ง graph
Authorization เป็นรายต่อ field ไม่ใช่ต่อคำขอ
หัวข้อที่มีชื่อว่า “Authorization เป็นรายต่อ field ไม่ใช่ต่อคำขอ”REST endpoint มักเป็นแบบทั้งหมดหรือไม่มีเลย: คุณอาจเรียก GET /admin/users ได้หรือไม่ได้ GraphQL ละเอียดกว่านั้น query เดียวสามารถแตะ field สาธารณะ, field ที่มีแต่เจ้าของอ่านได้, และ field ที่สงวนไว้สำหรับ admin — ทั้งหมดในคราวเดียว ดังนั้นที่โดยธรรมชาติสำหรับ authorization คือทุกที่ที่ข้อมูลถูกผลิตขึ้น: resolver
const resolvers = { Query: { // Public: anyone may read it. publicProfile: (_p, { id }) => loadProfile(id), }, User: { // Owner-only: compare the requested record to the caller. email: (user, _a, context) => { if (context.user?.id !== user.id) return null; // hide, don't shout return user.email; }, // Admin-only: refuse loudly. auditLog: (_user, _a, context) => { if (context.user?.role !== 'admin') { throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' }, }); } return loadAuditLog(); }, },};สังเกตว่าสอง field จัดการการปฏิเสธ ต่างกัน ทางเลือกนั้น — null กับการ throw error — คือการตัดสินใจด้านการออกแบบที่เป็นแก่นกลางของ authorization
คืน null หรือ throw forbidden?
หัวข้อที่มีชื่อว่า “คืน null หรือ throw forbidden?”เมื่อผู้เรียกไม่ได้รับอนุญาตให้เห็น field คุณมีสองทางเลือกที่ตรงไปตรงมา:
- คืน
nullfield ต้องเป็น nullable ใน schema query ยังสำเร็จอยู่ แค่ไม่มีข้อมูลตรงนั้น เหมาะที่สุดเมื่อ การมีอยู่ ของค่านั้นเป็นข้อมูลอ่อนไหวในตัวเอง หรือเมื่อผลลัพธ์บางส่วนยังมีประโยชน์ ผู้เรียกจะแยก “คุณดูสิ่งนี้ไม่ได้” กับ “ไม่มีอะไรอยู่ตรงนี้” ไม่ออก ซึ่งบ่อยครั้งคือคุณสมบัติด้าน privacy ที่เราต้องการพอดี - throw forbidden error resolver throw
GraphQLErrorพร้อม extension code อย่างFORBIDDENGraphQL จะบันทึก error ไว้ใต้errorsตั้ง field นั้นเป็นnullและที่สำคัญคือ เก็บ ส่วนที่เหลือของ response ไว้ เหมาะที่สุดเมื่อคุณอยากบอก client ที่ซื่อตรงไปตรง ๆ ว่าล้ำเส้นแล้ว เช่น เครื่องมือ admin ที่ควรแสดง “access denied”
หลักคร่าว ๆ ที่ใช้ได้ผลคือ throw เมื่อ client ทำผิดและควรรู้ตัว แต่คืน null เมื่อการบอกว่าถูกปฏิเสธเองก็เป็นการรั่วข้อมูล ไม่ว่าทางไหน อย่าฝากการบังคับใช้ permission ไว้กับ client เพราะการตรวจสอบต้องอยู่ที่ server ใน resolver
flowchart TD
F["Resolver for a field"] --> Q{"Is the caller allowed?"}
Q -->|"Yes"| D["Return the real value"]
Q -->|"No, and existence is sensitive"| N["Return null (hide silently)"]
Q -->|"No, and client overstepped"| E["throw GraphQLError code: FORBIDDEN"]
E --> P["Field set to null, rest of response preserved"] รวมศูนย์การตรวจสอบ: directive และ middleware
หัวข้อที่มีชื่อว่า “รวมศูนย์การตรวจสอบ: directive และ middleware”การกระจาย if (context.user?.role !== 'admin') ไปทั่ว resolver หลายสิบตัวทั้งน่าเบื่อและลืมง่าย มีสองรูปแบบที่ช่วยให้ DRY ขึ้น:
- Schema directive — ใส่ annotation ให้ field ใน SDL เช่น
auditLog: String @auth(requires: ADMIN)และ directive transformer ก็จะห่อ resolver ด้วยการตรวจสอบนั้น permission กลายเป็นส่วนหนึ่งของสัญญา มองเห็นได้สำหรับใครก็ตามที่อ่าน schema - Resolver middleware — ชั้นหนึ่ง (เช่น
graphql-middlewareหรือ utility ที่ห่อไว้) รันก่อน resolver และใช้กฎตามชื่อ type และ field business logic ยังคงสะอาด; policy อยู่ที่เดียว
ทั้งสองคอมไพล์ลงมาเหลือสิ่งเดียวกัน — การตรวจสอบที่รันรอบ ๆ resolver — ดังนั้น demo ที่รันได้ด้านล่างจึงแสดงกลไกเบื้องลึกโดยตรง
field ที่ปฏิเสธผู้ใช้ที่ไม่ได้รับอนุญาต
หัวข้อที่มีชื่อว่า “field ที่ปฏิเสธผู้ใช้ที่ไม่ได้รับอนุญาต”ตัวอย่างนิยาม me (user ที่ล็อกอินคนไหนก็ได้), email (เจ้าของเท่านั้น คืน null เมื่อไม่ใช่เจ้าของ) และ auditLog (admin เท่านั้น throw FORBIDDEN) แล้วรัน query เดียวกัน ทั้งในฐานะ user ปกติและในฐานะ admin เพื่อให้เห็น field หนึ่งพลิกจาก forbidden error ไปเป็นข้อมูลจริง กด Run
อ่าน output ทั้งสองคู่กัน ในฐานะ user ปกติ (ผู้ ที่เป็น เจ้าของ) email คืนที่อยู่ แต่ auditLog ทำให้เกิด FORBIDDEN error และค่าเป็น null ขณะที่ me กับ email ยังกลับมาครบ เพราะ field error ที่ถูก throw ทำให้เป็น null เฉพาะ field นั้นและคงส่วนที่เหลือไว้ ส่วนในฐานะ admin (ผู้ที่ ไม่ใช่ เจ้าของ) auditLog คืนข้อมูล แต่ email เป็น null เงียบ ๆ สไตล์การปฏิเสธจึงเป็นทางเลือกราย field ที่ตั้งใจ — null เงียบ ๆ สำหรับ email ที่เป็นความลับของเจ้าของ และ error ดัง ๆ สำหรับ log ที่มีแต่ admin เข้าถึงได้
| Approach | ข้อดี | ข้อเสีย |
|---|---|---|
| Check ใน Resolver | ง่าย implement | duplicate, ลืมง่าย |
Directive (@auth) | declarative, visible ใน schema | ต้อง implement directive transformer |
| Shield / Rule Layer | centralized, composable | เพิ่ม dependency |
| Service Layer | auth logic อยู่ใกล้ business logic | resolver ต้อง call service |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Authorization ใน Schema แต่ไม่ใน Resolver อาการ:
- schema บอกว่า field เป็น public แต่ resolver ยังต้อง check permission เอง
- inconsistent — introspection อาจ expose field ที่ client เข้าถึงไม่ได้จริง
- auth layer ควร enforce จริง ไม่ใช่แค่ document
Field-level Auth ที่ Incomplete อาการ:
- check auth ที่ Query root แต่ไม่ check ที่ nested resolver
adminStatsในUsertype ไม่ได้ protect — ใครก็ query ได้ผ่าน user query- protect ทุก field ที่ sensitive ไม่ว่าจะ access ผ่านทางไหน
💡 ตัวอย่างจากของจริง
GraphQL Shield:
- rule-based authorization layer สำหรับ GraphQL
- compose rule:
and(isAuthenticated, isAdmin)สำหรับ admin-only fieldGitHub:
- field บาง field จะ return
nullแทน error เมื่อไม่มี permission- ทำให้ partial result แสดงข้อมูลที่ได้รับอนุญาต โดยซ่อน field ที่ไม่ได้รับ