Skip to main content
Version: 2.x

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:

  1. Users can create posts for themselves.
  2. Post owner can update/publish/unpublish/delete their own posts.
  3. Users cannot make changes to posts that do not belong to them.
  4. Published posts can be viewed by all users.

Let's get started 🚀.

Prerequisite

  1. Make sure you have Node.js 18 or above installed.
  2. 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:

/src/index.ts
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
info

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:

  1. Implement an authentication mechanism to identify the current user.
  2. 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:

/schema.zmodel
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)
}
info

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.

info

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:

  1. If any @@deny rule evaluates to true, it's denied.
  2. If any @@allow rule evaluates to true, it's allowed.
  3. 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.

/src/index.ts
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:

/src/index.ts
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 });
}
tip

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:

/src/index.ts
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:

/src/index.ts
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 🚀!

Comments
Feel free to ask questions, give feedback, or report issues.

Don't Spam


You can edit/delete your comments by going directly to the discussion, clicking on the 'comments' link below