Backend-only Usage
ZenStack gives you a pleasant type-safe approach for calling your database "directly" from the frontend without coding a backend. Moreover, even if you're only building the backend side of an app, it can still help by defining access policies right inside your data model. This results in a security model that's both easier to maintain and more reliable than a manually implemented one.
Let's have some fun by creating a simple blogging service with Express.js. You can find the final build result here.
Requirements
Our target app should meet the following requirements:
- Users can create posts for themselves.
- Post owner can update/publish/unpublish/delete their own posts.
- Users cannot make changes to posts that do not belong to them.
- Published posts can be viewed by all users.
Let's get started 🚀.
Prerequisite
- Make sure you have Node.js 18 or above installed.
- Install the VSCode extension for editing data models.
Building the app
1. Create a new Prisma project
Prisma has a nice sample project for Express.js. Let's use it as a starting point:
npx degit prisma/prisma-examples/typescript/rest-express my-blog-app
cd my-blog-app
npm install
Next, let's initialize the database with seed data and start the dev server:
npx prisma db push
npx prisma db seed
npm run dev
Your server should be listening on port 3000 for RESTful requests.
Let's add a simple API to list all posts:
app.get(`/post`, async (req, res) => {
const post = await prisma.post.findMany({
include: { author: true },
});
res.json(post);
});
Restart the server and verify it works by:
curl localhost:3000/post
You should see a list of posts like this:
[
{
"id": 1,
"createdAt": "2023-01-14T06:46:52.628Z",
"updatedAt": "2023-01-14T06:46:52.628Z",
"title": "Join the Prisma Slack",
"content": "https://slack.prisma.io",
"published": true,
"viewCount": 0,
"authorId": 1,
"author": { "id": 1, "email": "alice@prisma.io", "name": "Alice" }
},
...
]
2. Initialize the project for ZenStack
Let's run the zenstack
CLI to prepare your project for using ZenStack.
npx zenstack@latest init
The command installs a few NPM dependencies. If the project already has a Prisma schema at prisma/schema.prisma
, it's copied over to schema.zmodel
. Otherwise, a sample schema.zmodel
file is created.
Moving forward, you will keep updating schema.zmodel
file, and prisma/schema.prisma
will be automatically generated from it.
3. Add access policies
Our bootstrapped schema.zmodel
file already has a User
and a Post
model defined.
However, they don't carry any access policies. So if you use a plain Prisma client in the
backend code, anyone can do anything to any data unless you write some custom
authorization logic.
You need two things to secure it up:
- Implement an authentication mechanism to identify the current user.
- Implement authorization to control who can take which actions to what data.
#1 can usually be achieved by requiring a session cookie or a JWT token. For simplicity,
we'll skip this part in the tutorial and just pass user id in a plain X-USER-ID
header.
We'll focus on #2 and utilize ZenStack to implement authorization without manually coding it.
Let's add the following access policies to the User
and Post
model to implement the
requirements we laid out previously:
model User {
id Int @id() @default(autoincrement())
email String @unique()
name String?
posts Post[]
// make user profile public
@@allow('read', true)
}
model Post {
id Int @id() @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt()
title String
content String?
published Boolean @default(false)
viewCount Int @default(0)
author User? @relation(fields: [authorId], references: [id])
authorId Int? @default(auth().id)
// author has full access
@@allow('all', auth() == author)
// logged-in users can view published posts
@@allow('read', auth() != null && published)
}
The "authorId" field as a @default()
attribute with auth().id
value. The field will be automatically assigned with the current user's ID when creating a new record. See here for more details.
By default, all operations are denied for a model. You can use the @@allow
attribute to open up some permissions.
@@allow
takes two parameters, the first is operation: create/read/update/delete. You can use a comma separated string to pass multiple operations, or use 'all' to abbreviate all operations. The second parameter is a boolean expression that verdicts if the rule should be activated.
Similarly, @@deny
can be used to explicitly turn off some operations. It has the same syntax as @@allow
but the opposite effect.
Whether an operation is permitted is determined as follows:
- If any
@@deny
rule evaluates to true, it's denied. - If any
@@allow
rule evaluates to true, it's allowed. - Otherwise, it's denied.
Check out Understanding Access Policies for more details.
Now flush the changes to the Prisma schema and database schema:
npx zenstack generate && npx prisma db push
4. Secure the APIs
Now let's secure up our APIs, leveraging the access policies.
First, add a simple middleware to require X-USER-ID
header (to simulate authentication).
Make sure the following code is inserted above all express routes so it can be applied to
all endpoints.
app.use((req, res, next) => {
const userId = req.header('X-USER-ID');
if (!userId || Number.isNaN(parseInt(userId))) {
res.status(403).json({ error: 'unauthorized' });
} else {
next();
}
});
Next, add two helper methods to get a Prisma client that's bound to the current user identity:
import { Request } from 'express';
import { enhance } from '@zenstackhq/runtime';
function getUserId(req: Request) {
return parseInt(req.header('X-USER-ID')!);
}
// Gets a Prisma client bound to the current user identity
function getPrisma(req: Request) {
const userId = getUserId(req);
const user = Number.isNaN(userId) ? undefined : { id: userId };
return enhance(prisma, { user });
}
enhance
is a ZenStack runtime API that wraps a Prisma client and enhances it with access policy checks.
Then, change our POST /post
API to use extracted user id instead of passing in user email in the body
when creating a new post:
app.post(`/post`, async (req, res) => {
const { title, content } = req.body;
const result = await prisma.post.create({
data: { title, content },
});
res.json(result);
});
Finally, change all code that uses prisma
to getPrisma(req)
. Here I'm showing only one occurrence:
app.get(`/post`, async (req, res) => {
const post = await getPrisma(req).post.findMany({
include: { author: true },
});
res.json(post);
});
Note that you don't need to change your Prisma query because the wrapped Prisma client has exactly the same API as the original Prisma client.
5. Test the secured backend
Now, restart the server and hit the /post
endpoint again directly. You should get a 403 error because the middleware rejected it:
curl -I localhost:3000/post
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 24
ETag: W/"18-gH7/fIZxPCVRh6TuPVNAgHt/40I"
Date: Sat, 14 Jan 2023 08:08:10 GMT
Connection: keep-alive
Keep-Alive: timeout=5
We can make it work by specifying X-USER-ID
header:
curl -H "X-USER-ID: 1" localhost:3000/post
Let's create a new unpublished post for user#1:
curl --request POST \
-H "X-USER-ID: 1" -H "Content-Type: application/json" \
-d '{"title": "My new post", "content": "Some awesome content"}' \
localhost:3000/post
, and hit /post
API again with user#1:
curl -H "X-USER-ID: 1" localhost:3000/post
You should see in the result the newly created post:
[
...
{
"id": 4,
"createdAt": "2023-01-14T08:42:56.833Z",
"updatedAt": "2023-01-14T08:42:56.833Z",
"title": "My new post",
"content": "Some awesome content",
"published": false,
"viewCount": 0,
"authorId": 1,
"author": { "id": 1, "email": "alice@prisma.io", "name": "Alice" }
}
]
However, if you hit /post
API with user#2:
curl -H "X-USER-ID: 2" localhost:3000/post
, you won't see the new post because it's not published yet. Our access policies are at work.
Feel free to play more with updating and deleting posts with different user ids and see if the policies work as expected.
Wrap up
🎉 Congratulations! You've made a simple but secure blogging service without writing any authorization code. Pretty cool, isn't it?
If you have trouble following the building process, you can find the final result here. For more details about ZenStack, please refer to the Reference and Guides parts of the documentation.
Have fun building cool stuff 🚀!