ข้ามไปยังเนื้อหา

Authorization

บทเรียนก่อนหน้าตอบคำถาม ใครเป็นคนถาม? และจอดผลลัพธ์ไว้บน context บทเรียนนี้ตอบคำถามข้อที่สอง: เขาได้รับอนุญาตหรือไม่? นั่นคือ authorization — การตัดสินทีละ field และ type ว่า identity ปัจจุบันอาจเห็นข้อมูลนี้ได้หรือไม่ Authentication คือ yes/no เดียวที่ขอบ; authorization คือการตัดสินเล็ก ๆ นับพันที่ถักทอผ่านทั้ง graph

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

เมื่อผู้เรียกไม่ได้รับอนุญาตให้เห็น field คุณมีสองทางเลือกที่ตรงไปตรงมา:

  • คืน null field ต้องเป็น nullable ใน schema query ยังสำเร็จอยู่ แค่ไม่มีข้อมูลตรงนั้น เหมาะที่สุดเมื่อ การมีอยู่ ของค่านั้นเป็นข้อมูลอ่อนไหวในตัวเอง หรือเมื่อผลลัพธ์บางส่วนยังมีประโยชน์ ผู้เรียกจะแยก “คุณดูสิ่งนี้ไม่ได้” กับ “ไม่มีอะไรอยู่ตรงนี้” ไม่ออก ซึ่งบ่อยครั้งคือคุณสมบัติด้าน privacy ที่เราต้องการพอดี
  • throw forbidden error resolver throw GraphQLError พร้อม extension code อย่าง FORBIDDEN GraphQL จะบันทึก 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"]
แต่ละ field ตัดสินอย่างอิสระ: อนุญาต, ซ่อนด้วย null, หรือปฏิเสธด้วย forbidden error

การกระจาย 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 ที่รันได้ด้านล่างจึงแสดงกลไกเบื้องลึกโดยตรง

ตัวอย่างนิยาม me (user ที่ล็อกอินคนไหนก็ได้), email (เจ้าของเท่านั้น คืน null เมื่อไม่ใช่เจ้าของ) และ auditLog (admin เท่านั้น throw FORBIDDEN) แล้วรัน query เดียวกัน ทั้งในฐานะ user ปกติและในฐานะ admin เพื่อให้เห็น field หนึ่งพลิกจาก forbidden error ไปเป็นข้อมูลจริง กด Run

JavaScript

อ่าน 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ง่าย implementduplicate, ลืมง่าย
Directive (@auth)declarative, visible ใน schemaต้อง implement directive transformer
Shield / Rule Layercentralized, composableเพิ่ม dependency
Service Layerauth logic อยู่ใกล้ business logicresolver ต้อง 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 ใน User type ไม่ได้ protect — ใครก็ query ได้ผ่าน user query
  • protect ทุก field ที่ sensitive ไม่ว่าจะ access ผ่านทางไหน

💡 ตัวอย่างจากของจริง

GraphQL Shield:

  • rule-based authorization layer สำหรับ GraphQL
  • compose rule: and(isAuthenticated, isAdmin) สำหรับ admin-only field

GitHub:

  • field บาง field จะ return null แทน error เมื่อไม่มี permission
  • ทำให้ partial result แสดงข้อมูลที่ได้รับอนุญาต โดยซ่อน field ที่ไม่ได้รับ
ทำไม authorization ใน GraphQL จึงมักทำเป็นรายต่อ field แทนที่จะทำหนึ่งครั้งต่อคำขอ?
เมื่อใดที่การคืน null ดีกว่าการ throw forbidden error สำหรับ field ที่ถูกปฏิเสธ?
ใน demo ที่รันได้ เกิดอะไรขึ้นกับส่วนที่เหลือของ response เมื่อ auditLog throw FORBIDDEN error?
การตรวจสอบ permission จริง ๆ ควรอยู่ที่ใด?