├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prisma └── seed.ts ├── schema.zmodel ├── src └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.env* 4 | *.db 5 | schema.prisma -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ZenStack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Please ⭐ us on the ZenStack repo if you like 🤝 2 | 3 | https://github.com/zenstackhq/zenstack 4 | 5 | # ZenStack SaaS Backend Template 6 | 7 | SaaS Backend Template using express.js 8 | 9 | ## Features 10 | 11 | - Multi-tenant 12 | - Soft delete 13 | - Sharing by group 14 | 15 | ## Data Model 16 | 17 | In `schema.zmodel,` there are 4 models, and their relationships are as below: 18 | ![data model](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8dx12h1fiumotwhhxr7z.png) 19 | 20 | - Organization is the top-level tenant. Any instance of User, post, and group belong to an organization. 21 | - One user could belong to multiple organizations and groups 22 | - One post belongs to a user and could belong to multiple groups. 23 | 24 | ## Permissions 25 | 26 | Let’s take a look at all the permissions of the Post and how they could be expressed using ZenStack’s access policies. 27 | 28 | 💡 _You can find the detailed reference of access policies syntax below: 29 | [https://zenstack.dev/docs/reference/zmodel-language#access-policy](https://zenstack.dev/docs/reference/zmodel-language#access-policy)_ 30 | 31 | - Create 32 | 33 | the owner must be set to the current user, and the organization must be set to one that the current user belongs to. 34 | 35 | ```tsx 36 | @@allow('create', owner == auth() && org.members?[id == auth().id]) 37 | ``` 38 | 39 | - Update 40 | 41 | only the owner can update it and is not allowed to change the organization or owner 42 | 43 | ```tsx 44 | @@allow('update', owner == auth() && org.future().members?[id == auth().id] && future().owner == owner) 45 | ``` 46 | 47 | - Read 48 | 49 | - allow the owner to read 50 | ```tsx 51 | @@allow('read', owner == auth()) 52 | ``` 53 | - allow the member of the organization to read it if it’s public 54 | ```tsx 55 | @@allow('read', isPublic && org.members?[id == auth().id]) 56 | ``` 57 | - allow the group members to read it 58 | ```tsx 59 | @@allow('read', groups?[users?[id == auth().id]]) 60 | ``` 61 | 62 | - Delete 63 | 64 | - don’t allow delete 65 | The operation is not allowed by default if no rule is specified for it. 66 | - The record is treated as deleted if `isDeleted` is true, aka soft delete. 67 | ```tsx 68 | @@deny('all', isDeleted == true) 69 | ``` 70 | 71 | You can see the complete data model together with the above access policies defined in the 72 | 73 | ```tsx 74 | abstract model organizationBaseEntity { 75 | id String @id @default(uuid()) 76 | createdAt DateTime @default(now()) 77 | updatedAt DateTime @updatedAt 78 | isDeleted Boolean @default(false) @omit 79 | isPublic Boolean @default(false) 80 | owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) 81 | ownerId String 82 | org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) 83 | orgId String 84 | groups Group[] 85 | 86 | // when create, owner must be set to current user, and user must be in the organization 87 | @@allow('create', owner == auth() && org.members?[id == auth().id]) 88 | // only the owner can update it and is not allowed to change the owner 89 | @@allow('update', owner == auth() && org.members?[id == auth().id] && future().owner == owner) 90 | // allow owner to read 91 | @@allow('read', owner == auth()) 92 | // allow shared group members to read it 93 | @@allow('read', groups?[users?[id == auth().id]]) 94 | // allow organization to access if public 95 | @@allow('read', isPublic && org.members?[id == auth().id]) 96 | // can not be read if deleted 97 | @@deny('all', isDeleted == true) 98 | } 99 | 100 | model Post extends organizationBaseEntity { 101 | title String 102 | content String 103 | } 104 | ``` 105 | 106 | ### Model Inheritance 107 | 108 | You may be curious about why these rules are defined within the abstract `organizationBaseEntity` model rather than the specific **`Post`** model. That’s why I say it is **Scalable**. With ZenStack's model inheritance capability, all common permissions can be conveniently handled within the abstract base model. 109 | 110 | Consider the scenario where a newly hired developer needs to add a new **`ToDo`** model. He can effortlessly achieve this by simply extending the `organizationBaseEntity` : 111 | 112 | ```tsx 113 | model ToDo extends organizationBaseEntity { 114 | name String 115 | isCompleted Boolean @default(false) 116 | } 117 | ``` 118 | 119 | All the multi-tenant, soft delete and sharing features will just work automatically. Additionally, if any specialized access control logic is required for **`ToDo`**, such as allowing shared individuals to update it, you can effortlessly add the corresponding policy rule within the **`ToDo`** model without concerns about breaking existing functionality: 120 | 121 | ```tsx 122 | @@allow('update', groups?[users?[id == auth().id]] ) 123 | ``` 124 | 125 | ## Running 126 | 127 | 1. Install dependencies 128 | 129 | ```bash 130 | npm install 131 | ``` 132 | 133 | 2. build 134 | 135 | ```bash 136 | npm run build 137 | ``` 138 | 139 | 3. seed data 140 | 141 | ```bash 142 | npm run seed 143 | ``` 144 | 145 | 4. start 146 | 147 | ```bash 148 | npm run dev 149 | ``` 150 | 151 | ## Testing 152 | 153 | The seed data is like below: 154 | 155 | ![data](https://github.com/jiashengguo/my-blog-app/assets/16688722/6dfb2e8c-d1c3-4eec-8022-e03bf2dd42fd) 156 | 157 | So in the Prisma team, each user created a post: 158 | 159 | - **Join Discord** is not shared, so it could only be seen by Robin 160 | - **Join Slack** is shared in the group to which Robin belongs so that it can be seen by both Robin and Bryan. 161 | - **Follow Twitter** is a public one so that it could be seen by Robin, Bryan, and Gavin 162 | 163 | You could simply call the Post endpoint to see the result simulate different users: 164 | 165 | ```tsx 166 | curl -H "X-USER-ID: robin@prisma.io" localhost:3000/api/post 167 | ``` 168 | 169 | 💡 _It uses the plain text of the user id just for test convenience. In the real world, you should use a more secure way to pass IDs like JWT tokens._ 170 | 171 | Based on the sample data, each user should see a different count of posts from 0 to 3. 172 | 173 | ### Soft Delete 174 | 175 | Since it’s soft delete, the actual operation is to update `isDeleted` to true. Let’s delete the “Join Salck” post of Robin by running below: 176 | 177 | ```tsx 178 | curl -X PUT \ 179 | -H "X-USER-ID: robin@prisma.io" -H "Content-Type: application/json" \ 180 | -d '{"data":{ "type":"post", "attributes":{ "isDeleted": true } } }'\ 181 | localhost:3000/api/post/slack 182 | ``` 183 | 184 | After that, if you try to access the Post endpoint again, the result won’t contain the “Join Slack” post anymore. If you are interested in how it works under the hook, check out another post for it: 185 | 186 | [Soft Delete: Implementation Issues in Prisma and Solution in Zenstack](https://zenstack.dev/blog/soft-delete) 187 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rest-express", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "zenstack generate && prisma db push", 7 | "dev": "ts-node src/index.ts", 8 | "seed": "prisma db seed", 9 | "up": "npm i @zenstackhq/runtime@latest && npm i -D zenstack@latest" 10 | }, 11 | "dependencies": { 12 | "@prisma/client": "5.18.0", 13 | "@zenstackhq/runtime": "^2.4.1", 14 | "@zenstackhq/server": "^2.4.1", 15 | "express": "4.18.2" 16 | }, 17 | "devDependencies": { 18 | "@types/express": "4.17.15", 19 | "@types/node": "18.11.18", 20 | "prisma": "5.18.0", 21 | "ts-node": "10.9.1", 22 | "typescript": "4.9.4", 23 | "zenstack": "^2.4.1" 24 | }, 25 | "prisma": { 26 | "seed": "ts-node prisma/seed.ts" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Prisma } from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | const userData: Prisma.UserCreateInput[] = [ 6 | // Prisma Team 7 | { 8 | id: "robin@prisma.io", 9 | name: "Robin", 10 | email: "robin@prisma.io", 11 | orgs: { 12 | create: [ 13 | { 14 | id: "prisma", 15 | name: "prisma", 16 | }, 17 | ], 18 | }, 19 | groups: { 20 | create: [ 21 | { 22 | id: "community", 23 | name: "community", 24 | orgId: "prisma", 25 | }, 26 | ], 27 | }, 28 | posts: { 29 | create: [ 30 | { 31 | id: "slack", 32 | title: "Join the Prisma Slack", 33 | content: "https://slack.prisma.io", 34 | orgId: "prisma", 35 | }, 36 | ], 37 | }, 38 | }, 39 | { 40 | id: "bryan@prisma.io", 41 | name: "Bryan", 42 | email: "bryan@prisma.io", 43 | orgs: { 44 | connect: { 45 | id: "prisma", 46 | }, 47 | }, 48 | posts: { 49 | create: [ 50 | { 51 | id: "discord", 52 | title: "Join the Prisma Discord", 53 | content: "https://discord.gg/jS3XY7vp46", 54 | orgId: "prisma", 55 | groups: { 56 | connect: { 57 | id: "community", 58 | }, 59 | }, 60 | }, 61 | ], 62 | }, 63 | }, 64 | { 65 | id: "gavin@prisma.io", 66 | name: "Gavin", 67 | email: "gavin@prisma.io", 68 | orgs: { 69 | connect: { 70 | id: "prisma", 71 | }, 72 | }, 73 | posts: { 74 | create: [ 75 | { 76 | id: "twitter", 77 | title: "Follow Prisma on Twitter", 78 | content: "https://twitter.com/prisma", 79 | isPublic: true, 80 | orgId: "prisma", 81 | }, 82 | ], 83 | }, 84 | }, 85 | // ZenStack Team 86 | { 87 | id: "js@zenstack.dev", 88 | name: "JS", 89 | email: "js@zenstack.dev", 90 | orgs: { 91 | create: [ 92 | { 93 | id: "zenstack", 94 | name: "zenstack", 95 | }, 96 | ], 97 | }, 98 | }, 99 | ]; 100 | 101 | async function main() { 102 | console.log(`Start seeding ...`); 103 | for (const u of userData) { 104 | const user = await prisma.user.create({ 105 | data: u, 106 | }); 107 | console.log(`Created user with id: ${user.id}`); 108 | } 109 | console.log(`Seeding finished.`); 110 | } 111 | 112 | main() 113 | .then(async () => { 114 | await prisma.$disconnect(); 115 | }) 116 | .catch(async (e) => { 117 | console.error(e); 118 | await prisma.$disconnect(); 119 | process.exit(1); 120 | }); 121 | -------------------------------------------------------------------------------- /schema.zmodel: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider="sqlite" 3 | url="file:./dev.db" 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | /** 10 | * Model for a user 11 | */ 12 | model User { 13 | id String @id @default(uuid()) 14 | email String @unique 15 | password String? @password @omit 16 | name String? 17 | orgs Organization[] 18 | posts Post[] 19 | groups Group[] 20 | 21 | // can be created by anyone, even not logged in 22 | @@allow('create', true) 23 | // can be read by users in the same organization 24 | @@allow('read', orgs?[members?[auth() == this]]) 25 | // full access by oneself 26 | @@allow('all', auth() == this) 27 | } 28 | 29 | /** 30 | * Model for a organization 31 | */ 32 | model Organization { 33 | id String @id @default(uuid()) 34 | name String 35 | members User[] 36 | post Post[] 37 | groups Group[] 38 | 39 | // everyone can create a organization 40 | @@allow('create', true) 41 | // any user in the organization can read the organization 42 | @@allow('read', members?[auth().id == id]) 43 | } 44 | 45 | /** 46 | * Base model for all entites in a organization 47 | */ 48 | abstract model organizationBaseEntity { 49 | id String @id @default(uuid()) 50 | createdAt DateTime @default(now()) 51 | updatedAt DateTime @updatedAt 52 | isDeleted Boolean @default(false) @omit 53 | isPublic Boolean @default(false) 54 | owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) 55 | ownerId String 56 | org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) 57 | orgId String 58 | groups Group[] 59 | 60 | // when create, owner must be set to current user, and user must be in the organization 61 | @@allow('create', owner == auth() && org.members?[id == auth().id]) 62 | // only the owner can update it and is not allowed to change the owner 63 | @@allow('update', owner == auth() && org.members?[id == auth().id] && future().owner == owner) 64 | // allow owner to read 65 | @@allow('read', owner == auth()) 66 | // allow shared group members to read it 67 | @@allow('read', groups?[users?[id == auth().id]]) 68 | // allow organization to access if public 69 | @@allow('read', isPublic && org.members?[id == auth().id]) 70 | // can not be read if deleted 71 | @@deny('all', isDeleted == true) 72 | } 73 | 74 | /** 75 | * Model for a post 76 | */ 77 | model Post extends organizationBaseEntity { 78 | title String 79 | content String 80 | } 81 | 82 | /** 83 | * Model for a group 84 | */ 85 | model Group { 86 | id String @id @default(uuid()) 87 | name String 88 | users User[] 89 | posts Post[] 90 | org Organization @relation(fields: [orgId], references: [id]) 91 | orgId String 92 | 93 | // group is shared by organization 94 | @@allow('all', org.members?[auth().id == id]) 95 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { enhance } from '@zenstackhq/runtime'; 3 | import { ZenStackMiddleware } from '@zenstackhq/server/express'; 4 | import express from 'express'; 5 | import { RestApiHandler } from '@zenstackhq/server/api'; 6 | 7 | const app = express(); 8 | 9 | app.use(express.json()); 10 | const prisma = new PrismaClient(); 11 | app.use( 12 | '/api', 13 | ZenStackMiddleware({ 14 | getPrisma: (req) => 15 | enhance(prisma, { 16 | user: { id: req.header('X-USER-ID')! }, 17 | }), 18 | handler: RestApiHandler({ endpoint: 'http://localhost:3000/api' }), 19 | }) 20 | ); 21 | 22 | app.use((req, res, next) => { 23 | const userId = req.header('X-USER-ID'); 24 | if (!userId) { 25 | res.status(403).json({ error: 'unauthorized' }); 26 | } else { 27 | next(); 28 | } 29 | }); 30 | 31 | app.listen(3000, () => 32 | console.log(` 33 | 🚀 Server ready at: http://localhost:3000`) 34 | ); 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "outDir": "dist", 5 | "strict": true, 6 | "lib": ["esnext"], 7 | "esModuleInterop": true 8 | } 9 | } 10 | --------------------------------------------------------------------------------