Permission trong GraphQL & Prisma
Bài đăng này đã không được cập nhật trong 6 năm
Với bất kì hệ thống backend nào, permission và authentication luôn là những vấn đề quan trọng cần quan tâm.
Với ai đã từng tiếp xúc qua với GraphQL thì đều có thể nhận ra điều này - GraphQL là một hệ thống mở. Nhìn vào một hệ thống GraphQL thì phía client hoàn toàn có thể thấy được toàn bộ các API cũng như cấu trúc data của hệ thống. Điều này có thể thay đổi sau này - nhưng hiện giờ thì toàn bộ các hoạt động cũng như dữ liệu của hệ thống đều bị phơi bày ra hết.
Trong bài viết này, mình sẽ đề cập đến 1 vài cách cơ bản xử lý với permission khi sử dụng GraphqlQL thông qua Prisma.
Schema hệ thống
Với toàn bộ bài viết này, mình sẽ chỉ sử dụng 1 cấu trúc dữ liệu để có thể dễ dàng so sánh được các cách tiếp cận với nhau.
Schema trong bài sẽ bao gồm 3 bảng: User, AuthProvider (phương thức đăng nhập của 1 user - có thể là bằng user - password hay 3rd party login ...) và bảng App. Các app sẽ có owner là 1 User.
type User {
id: ID! @unique
name: String
role: Role! @default(value: "USER")
providers: [AuthProvider!]!
}
type AuthProvider {
id: ID! @unique
user: User!
type: ProviderType!
uuid: String @unique
token: String
email: String!
password: String
}
type App {
id: ID! @unique
type: AppType!
owner: User!
members: [Member!]!
info: String!
}
Nhìn vào 3 schema phía trên, ta sẽ thấy ngay được hàng loạt vấn đề - đó là bất cứ người nào có quyền truy cập vào GraphQL Server thì cũng sẽ có mọi người để xem, chỉnh sửa ... tóm lại là làm MỌI THỨ với dữ liệu của bạn.
Để tằng tính an toàn cho API, ít nhất ta có thể thêm vào một vài luật như sau:
UpdateAppInfo
sẽ chỉ cóowner
có quyền.- xem thông tin của một app sẽ chỉ có
owner
vàmember
có quyền. - KHÔNG trả về password khi phía client lấy thông tin user.
Cách số 1: Trâu bò
Nhắc lại một chút về cấu trúc hệ thống
Trong hệ thống của mình, Prisma chỉ là một lớp (layer) ORM ở giữa, ta vẫn cần có một GraphQL Server chính và ở đây thì mình sử dụng GraphQL-Yoga.
Thêm vào đó, ta sử dụng thêm GraphQL-binding để tự động sinh ra các hàm CRUD cơ bản cho API của mình.
Xử lý permission trong resolver
Cách đơn giản nhất đó là ta có thể implement logic kiểm tra permission khi viết resolver:
const Mutation = {
updateAppInfo: async (parent, { id, info }, context, info) => {
const userId = getUserId(context);
const app = await context.db.query.app({
where: {
id: id
}
});
if(userId == app.owner.id) {
// update logic
} else throw new Error(
// ... not found error
)
}
}
Ta tìm bản ghi app
và so sánh app.owner.id
với id của current user rồi sau đó mới cho phép thực hiện logic update.
Với cách tiếp cận này, về lâu dài, resolver của ta sẽ trở nên lộn xộn và trùng lặp với hàng loạt logic kiểm tra permission.
Cách số 2: Sử dụng GraphQL Directive
Với cách thứ 2 này, ta sẽ sử dụng một tính năng khác của GraphQL - Directive - để nhúng trực tiếp việc khai báo permission vào thẳng schema
directive @isOwner on FIELD | FIELD_DEFINITION
directive @privateField on FIELD | FIELD_DEFINITION
// ta sử dụng các directive kể trên để nhúng thẳng vào mutation
type Mutation {
updateAppInfo(id: ID!, info: String!): App! @isOwner
}
// cũng như nhúng thẳng vào type definition trong schema
type AuthProvider {
id: ID! @unique
user: User!
type: ProviderType!
uuid: String @unique
token: String
email: String!
password: String @privateField
}
So với cách đầu tiên, ta có thể thấy ưu điểm lớn nhất của cách này: đó là từ phía client nhìn vào thì hoàn toàn có thể thấy được các permission này mà không phải đọc vào code bên trong resolver. Cùng với đó, việc sử dụng lại 1 logic permission chỉ đơn giản là copy lại directive đó đến nơi mình muốn
Với cách thứ 2 này, ta sẽ thử viết resolver cho directive privateField. Như tên gọi của nó - privateField
sẽ ẩn đi giá trị của trường mình muốn ẩn giấu, và nếu client vẫn cố tình query tới thì sẽ trả về 1 error cho phía client.
Bắt đầu với file index.ts
- thường là nơi khởi tạo GraphQL Server của ta.
import { GraphQLServer } from 'graphql-yoga'
import { Prisma } from './generated/prisma'
import { resolvers, fragmentReplacements } from './resolvers'
const db = new Prisma({
fragmentReplacements,
endpoint: process.env.PRISMA_ENDPOINT,
secret: process.env.PRISMA_SECRET,
debug: true,
})
const server = new GraphQLServer({
typeDefs: './src/schema.graphql',
resolvers,
resolverValidationOptions: {
requireResolversForResolveType: false,
},
context: req => ({ ...req, db })
})
server.start({}, ({ port }) => console.log(`Server is running on http://localhost:${port}`))
Đoạn code ở trên mới chỉ khỏi tạo một Prisma Server, sau đó khởi tạo tiếp một GraphQL Server và kết nối 2 server này lại với nhau, các resolver logic chính được import từ bên ngoài.
Bây giờ ta sẽ tạo một directiveResolvers, sau đó import nó vào chỗ khai báo khởi tạo GraphQLServer phía trên.
// directive.resolvers.ts
export const directiveResolvers = {
isPrivate: (next, source, {}, ctx) => {
throw new Error(`Private field !`);
},
// ... các resolver khác ...
}
.............
// index.ts
import { directiveResolvers } from './directive.resolvers'
const server = new GraphQLServer({
typeDefs: './src/schema.graphql',
resolvers,
directiveResolvers,
resolverValidationOptions: {
requireResolversForResolveType: false,
},
context: req => ({ ...req, db })
})
resolver này rất đơn giản: khi gặp trường đi kèm với resolver này thì bắn thẳng ra error message.
Đến giờ chạy thử
Trước khi ta đính kèm directive vào, việc truy cập để lấy ra password như là kết quả của 1 query hay mutation hoàn toàn không gặp cản trở gì.
Sau khi ta đính kèm directive vào, việc lấy ra password sẽ trả về giá trị null đi kèm với 1 message thông báo lỗi.
Cách số 3: Sử dụng GraphQL Middlewares
Nếu như sử dụng cách số 2 thì ta sẽ phải chọc thẳng vào schema của prisma.
Còn ở cách thứ 3 này, ta sẽ sử dụng 1 tính năng khác nữa của GraphQL, đó là middleware
.
Ta sẽ sử dụng package graphql-middleware
để implement. Việc cài đặt chỉ đơn giản với một câu lệnh
yarn add graphql-middleware
(thậm chí nếu ta sử dụng graphql-yoga
thì package sẽ tự động được cài như là 1 dependency)
Việc sử dụng middleware cũng gần tương tự với directive.
Đầu tiên ta khai báo middleware:
const isPrivated = async (resolve, parent, args, ctx, info) => {
throw new Error(`access privated field`)
}
export const privateFields = {
AuthProvider: {
password: isPrivated,
}
}
Nhìn vào hàm isPrivated ở ví dụ này, ta thấy số lượng params của hàm tăng lên 1, đó hàm resolve
ở đầu: đối với 1 middleware, sau khi đi qua logic xử lý, middleware sẽ gọi tới hàm resolve
này để xử lý tiếp giá trị của ta - thường là các middleware kế sau; nhưng với ví dụ ở đây thì ta chỉ đơn giản là trả luôn về error mà thôi.
Tiếp đó, const privateFields
sẽ có nhiệm vụ export ra 1 object, object này sẽ được graphql-middleware
ghép nối tiếp vào các query và mutation sẵn có của server. (Như thấy ở đoạn code trên thì ta thêm vào isPrivated
cho trường password của bảng AuthProvider
)
Lúc này, bên phía index.ts
ta cũng tiến hành import tương tự như khi sử dụng directive.
import { privateFields } from './middlewares/privateFields';
...
const server = new GraphQLServer({
typeDefs: './src/schema.graphql',
resolvers,
resolverValidationOptions: {
requireResolversForResolveType: false,
},
context: req => ({ ...req, db }),
middlewares: [privateFields],
})
Áp dụng middleware này, kết quả trả về cũng tương tự như với directive.
So sánh & kết luận:
Qua 3 cách trên, ta có thể thấy rằng việc áp dụng cách thứ 1 là không hề tối ưu một chút nào. Nếu nghiệp vụ có nhiều phần lặp đi lặp lại, cách 2 hoặc cách 3 sẽ được khuyến khích hơn.
So với cách 3 (và cách 1) thì cách 2 sẽ có 1 ưu điểm là phía client sẽ có thể nhìn thấy luôn cấu trúc dữ liệu cũng như các permission được implement ở đâu trong hệ thống của ta. Tuy nhiên khi sử dụng cách này với graphql-yoga
và prisma
thì lại đòi hỏi ta phải chỉnh sửa vào thẳng schema
. Cũng còn 1 cách nữa đó là có thể sử dụng keyword extend
để mở rộng các bảng bên phía graphql-yoga server
extend type AuthProvider {
password: String @privateField
}
Tuy nhiên có vẻ như keyword này chưa được implement trong graphql-yoga
Vì vậy ta cũng có thể lựa chọn cách thứ 3 - tách hoàn toàn logic xử lý permission ra khỏi schema
cũng như ra khỏi logic nghiệp vụ chính của hệ thống.
Tham khảo
All rights reserved