Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@
[submodule "code-repos/zenstackhq/v3-doc-orm-polymorphism"]
path = code-repos/zenstackhq/v3-doc-orm-polymorphism
url = https://github.com/zenstackhq/v3-doc-orm-polymorphism.git
[submodule "code-repos/zenstackhq/v3-doc-orm-validation"]
path = code-repos/zenstackhq/v3-doc-orm-validation
url = https://github.com/zenstackhq/v3-doc-orm-validation.git
[submodule "code-repos/zenstackhq/v3-doc-orm-policy"]
path = code-repos/zenstackhq/v3-doc-orm-policy
url = https://github.com/zenstackhq/v3-doc-orm-policy.git
18 changes: 1 addition & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Website

This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
This website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator.

### Installation

Expand All @@ -23,19 +23,3 @@ $ pnpm build
```

This command generates static content into the `build` directory and can be served using any static contents hosting service.

### Deployment

Using SSH:

```
$ USE_SSH=true pnpm deploy
```

Not using SSH:

```
$ GIT_USER=<Your GitHub username> pnpm deploy
```

If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
2 changes: 1 addition & 1 deletion blog/better-auth/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ type Auth {
}
```

Now, we're ready to write the policy rules. You can find more information about access polices [here](https://zenstack.dev/docs/the-complete-guide/part1/access-policy/).
Now, we're ready to write the policy rules. You can find more information about access policies [here](https://zenstack.dev/docs/the-complete-guide/part1/access-policy/).

#### 1. Tenant segregation

Expand Down
1 change: 1 addition & 0 deletions code-repos/zenstackhq/v3-doc-orm-policy
Submodule v3-doc-orm-policy added at f4b864
1 change: 1 addition & 0 deletions code-repos/zenstackhq/v3-doc-orm-validation
Submodule v3-doc-orm-validation added at c86439
2 changes: 1 addition & 1 deletion code-repos/zenstackhq/v3-doc-quick-start
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ The short answer is it works in most cases. Please refer to [this guide](./guide

### Does the order in which access policies are defined matter?

No. See [here](./the-complete-guide/part1/4-access-policy/4.1-model-level.md#evaluation-of-model-level-policies) for how access polices are evaluated.
No. See [here](./the-complete-guide/part1/4-access-policy/4.1-model-level.md#evaluation-of-model-level-policies) for how access policies are evaluated.

### Is Prisma's new "prisma-client" generator supported?

Expand Down
8 changes: 5 additions & 3 deletions src/components/StackBlitzGithub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import GithubCodeBlock from './GithubCodeBlock';

interface StackBlitzGithubProps {
repoPath: string;
openFile?: string;
openFile?: string | string[];
codeFiles?: string[];
startScript?: string;
}
Expand All @@ -15,14 +15,16 @@ const StackBlitzGithub: React.FC<StackBlitzGithubProps> = ({
codeFiles: plainCodeFiles = undefined,
startScript,
}) => {
const openFiles = Array.isArray(openFile) ? openFile : openFile ? openFile.split(',') : [];

const options = {
openFile,
openFile: openFiles ? openFiles.join(',') : undefined,
view: 'editor',
startScript,
} as const;

if (!plainCodeFiles) {
plainCodeFiles = [openFile];
plainCodeFiles = [...openFiles];
}

return (
Expand Down
2 changes: 1 addition & 1 deletion versioned_docs/version-3.x/modeling/relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ model Mentorship {
}
```

## Referential Actions
## Referential Action

When defining a relation, you can use referential action to control what happens when one side of a relation is updated or deleted by setting the `onDelete` and `onUpdate` parameters in the `@relation` attribute.

Expand Down
7 changes: 7 additions & 0 deletions versioned_docs/version-3.x/orm/access-control/field-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
sidebar_position: 4
---

# Field-Level Policies 🚧

Coming soon.
17 changes: 15 additions & 2 deletions versioned_docs/version-3.x/orm/access-control/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
sidebar_position: 6
---

# Access Control 🚧
# Access Control

Coming soon 🚧
ZenStack's intuitive schema and type-safe API make it a good choice of ORM, and its built-in access control makes it a great one.

## Overview

Most applications are designed around three interconnected essential aspects: authentication, data query, and access control (aka authorization). We've covered data query in the previous parts, and this part is dedicated to access control and its relation to authentication.

Traditional wisdom considers access control to be "business logic" and thus should be implemented in the service or API layer. On the contrary, ZenStack views it as an integral part of the data model, because most of the time, "business-oriented" rules boil down to "who can CRUD which piece of data". Having them colocated with the data model reduces redundancy, ensures consistency, and improves maintainability. As a result, you have a lower chance of "leaking permissions" when the application grows in complexity.

ZenStack's access control approach is straightforward:

1. You define whitelist or blacklist policies on your models for CRUD operations.
2. When querying data, the engine converts the policies into SQL filters and injects them into the generated queries.

If you're familiar with PostgreSQL's [Row-Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html), ZenStack's idea is similar, but as the enforcement happens on the application side, it's database agnostic, doesn't involve schema migrations, and surprisingly, [performs better](https://supabase.com/docs/guides/database/postgres/row-level-security#add-filters-to-every-query) than RLS.
45 changes: 45 additions & 0 deletions versioned_docs/version-3.x/orm/access-control/post-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
sidebar_position: 3
---

# Post-Update Rules

import StackBlitzGithub from '@site/src/components/StackBlitzGithub';

:::info

In ZenStack v2, post-update rules were implicitly defined with the "update" operation by using the `future()` function to refer to the post-update values. We found this approach to be unclean and error-prone. V3 made a breaking change to introduce a separate "post-update" operation.

:::

## Overview

Among the CRUD operations, "update" is a special one because it has a "pre" state and "post" state. The "update" policies we've seen in the previous parts refer to the "pre" state, meaning that if your policies refer to the model's fields, the fields are evaluated to their values before the update happens.

However, sometimes you want to express conditions that should hold after the update happens. For example, you may want to ensure that after an update, a post's `published` field cannot be set to true unless the current user is the author. Post-update policies are designed for such scenarios.

Writing post-update rules is essentially the same as writing regular "update" rules, except that fields will refer to their post-update values. You can use the built-in `before()` function to refer to the pre-update entity if needed.

Another key difference is that "post-update" operation is by default allowed. If you don't write any post-update rules, the update operation will succeed as long as it passes the "update" policies. However, if you have any post-update rules for a model, at least one `@@allow` rule must evaluate to true for the update operation to succeed.

```zmodel
model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int

// only author can publish the post
@@deny('post-update', published == true && auth().id != authorId)

// prevent changing authorId
@@deny('post-update', before().authorId != authorId)
}
```

When post-update policies are violated, a `RejectedByPolicyError` is thrown.

## Samples

<StackBlitzGithub repoPath="zenstackhq/v3-doc-orm-policy" openFile={['post-update/zenstack/schema.zmodel', 'post-update/main.ts']} startScript="post-update" />
112 changes: 112 additions & 0 deletions versioned_docs/version-3.x/orm/access-control/query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
sidebar_position: 2
---

# Querying with Access Control

import StackBlitzGithub from '@site/src/components/StackBlitzGithub';

After defining access control policies in ZModel, it's time to enjoy their benefits.

## Installing Runtime Plugin

Similar to the schema side, access control's runtime aspect is encapsulated in the `@zenstackhq/plugin-policy` package too, as a Runtime Plugin (more about this topic [later](../plugins/index.md)). You should install it on the raw ORM client to get a new client instance with access control enforcement.

```ts
import { ZenStackClient } from '@zenstackhq/runtime';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';

// create an unprotected, "raw" ORM client
const db = new ZenStackClient(...);

// install the policy plugin
const authDb = db.$use(new PolicyPlugin());

// make queries with `authDb` to have access control enforced
...
```

## Setting Auth User

As mentioned in the previous part, you can use the `auth()` function in policy rules to refer to the current authenticated user. At runtime, you should use the `$setAuth()` API to provide such information. ZenStack itself is not an authentication library, so you need to determine how to achieve it based on your authentication mechanism.

In a web application, the typical pattern is to inspect the incoming request, extract and validate the user information from it, and then call `$setAuth()` to get an ORM client bound to that user.

```ts
import { getSessionUser } from './auth'; // your auth helper
import { authDb } from './db'; // the client with policy plugin installed

async function handleRequest(req: Request) {
const user = await getSessionUser(req);

// create an user-bound client
const userDb = authDb.$setAuth(user);

// make queries with `userDb` to make user-bound queries
...
}
```

Without calling `$setAuth()`, the client works in anonymous mode, meaning that `auth()` in ZModel is evaluated to null. You can explicitly call `$setAuth(undefined)` to get an anonymous-bound client from a client that's previously bound to a user.

Use the `$auth` property to get the user info previously set by `$setAuth()`.

## Making Queries

Access control policies are effective for both the ORM API and the query-builder API. To understand its behavior, the simplest mental model is to think that rows not satisfying the policies "don't exist".

### ORM Queries

For the most part, the ORM query behavior is very intuitive:

- Read operations like `findMany`, `findUnique`, `count`, etc., only return/involve rows that meet the "read" policies.

- Mutation operations that affect multiple rows, like `updateMany` and `deleteMany`, only impact rows that meet the "update" or "delete" policies respectively.

- Mutation operations that affect a single, unique row, like `update` and `delete`, will throw an `NotFoundError` if the target row doesn't meet the "update" or "delete" policies respectively.

:::info
Why `NotFoundError` instead of `RejectedByPolicyError`? Because the rationale is rows that don't satisfy the policies "don't exist".
:::

There are some complications when "read" and "write" policies affect the same query. It's ubiquitous because most mutation APIs involve reading the post-mutation entity to return to the caller. When the mutation succeeds but the post-mutation entity cannot be read, a `RejectedByPolicyError` is thrown, even though the mutation is persisted.

```ts
// if Post#1 is updatable but the post-update read is not allowed, the
// update will be persisted first and then a `RejectedByPolicyError`
// will be thrown
await db.post.update({
where: { id: 1 },
data: { published: false },
});
```

:::info
Why throw an error instead of returning `null`? Because it'll compromise type-safety. The `create`, `update`, and `delete` APIs don't have a nullable return type.
:::

### Query-Builder Queries

The low-level Kysely query-builder API is also subject to access control enforcement. Its behavior is intuitive:

- Calling `$qb.selectFrom()` returns readable rows only.
- When you call `$qb.insertInto()`, `RejectedByPolicyError` will be thrown if the inserted row doesn't satisfy the "create" policies. Similar for `update` and `delete`.
- Calling `$qb.update()` and `$qb.delete()` only affects rows that satisfy the "update" and "delete" policies, respectively.
- When you join tables, the joined table will be filtered to readable rows only.
- When you use sub-queries, the sub-queries will be filtered to readable rows only.

## Limitations

Here are some **IMPORTANT LIMITATIONS** about access control enforcement:

1. Mutations caused by cascade deletes/updates and database triggers are entirely internal to the database, so ZenStack cannot enforce access control on them.
2. Raw SQL queries executed via `$executeRaw()` and `$queryRaw()` are not subject to access control enforcement.
3. Similarly, raw queries made with query-builder API using the `sql` tag are not subject to access control enforcement.

## Samples

<StackBlitzGithub repoPath="zenstackhq/v3-doc-orm-policy" openFile={['basic/zenstack/schema.zmodel', 'basic/main.ts']} startScript="basic" />

## Implementation Notes

ZenStack v3's ORM is built on top of Kysely. Regardless of whether you use the ORM API or the query-builder one, queries are eventually transformed into Kysely's SQL AST and then compiled down to SQL and sent to the database for execution. The access control enforcement is implemented by transforming the AST and injecting proper filters.
Loading