Skip to main content

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 16 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:

bash
npx degit prisma/prisma-examples/typescript/rest-express my-blog-app
cd my-blog-app
npm install
bash
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:

bash
npx prisma db push
npx prisma db seed
npm run dev
bash
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
ts
app.get(`/post`, async (req, res) => {
const post = await prisma.post.findMany({
include: { author: true },
});
res.json(post);
});
/src/index.ts
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:

bash
curl localhost:3000/post
bash
curl localhost:3000/post

You should see a list of posts like this:

json
[
{
"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" }
},
...
]
json
[
{
"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.

bash
npx zenstack@latest init
bash
npx zenstack@latest init
tip

The command installs a few NPM dependencies and copies Prisma schema from prisma/schema.prisma to schema.zmodel. Moving forward, you will keep updating this 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
prisma
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?
// author has full access
@@allow('all', auth() == author)
// logged-in users can view published posts
@@allow('read', auth() != null && published)
}
/schema.zmodel
prisma
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?
// author has full access
@@allow('all', auth() == author)
// logged-in users can view published posts
@@allow('read', auth() != null && published)
}
tip

By default, all operations are denied for a model. You can use @@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 verdicting 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 the 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.

Now flush the changes to the Prisma schema and database schema:

bash
npx zenstack generate && npx prisma db push
bash
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
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();
}
});
/src/index.ts
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
ts
import { Request } from 'express';
import { withPolicy } 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 withPolicy(prisma, {
user,
});
}
/src/index.ts
ts
import { Request } from 'express';
import { withPolicy } 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 withPolicy(prisma, {
user,
});
}
tip

withPolicy 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
ts
app.post(`/post`, async (req, res) => {
const { title, content } = req.body;
const result = await prisma.post.create({
data: {
title,
content,
author: { connect: { id: getUserId(req) } },
},
});
res.json(result);
});
/src/index.ts
ts
app.post(`/post`, async (req, res) => {
const { title, content } = req.body;
const result = await prisma.post.create({
data: {
title,
content,
author: { connect: { id: getUserId(req) } },
},
});
res.json(result);
});

Finally, change all code that uses prisma to getPrisma(req). Here I'm showing only one occurrence:

/src/index.ts
ts
app.get(`/post`, async (req, res) => {
const post = await getPrisma(req).post.findMany({
include: { author: true },
});
res.json(post);
});
/src/index.ts
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:

bash
curl -I localhost:3000/post
bash
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
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:

bash
curl -H "X-USER-ID: 1" localhost:3000/post
bash
curl -H "X-USER-ID: 1" localhost:3000/post

Let's create a new unpublished post for user#1:

bash
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
bash
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:

bash
curl -H "X-USER-ID: 1" localhost:3000/post
bash
curl -H "X-USER-ID: 1" localhost:3000/post

You should see in the result the newly created post:

json
[
...
{
"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" }
}
]
json
[
...
{
"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:

bash
curl -H "X-USER-ID: 2" localhost:3000/post
bash
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 🚀!