diff --git a/.gitmodules b/.gitmodules index 5b3bd36c..54c052af 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md index d1bb7922..b1623c5b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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= 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. diff --git a/blog/better-auth/index.mdx b/blog/better-auth/index.mdx index 84d5261d..dabc46a8 100644 --- a/blog/better-auth/index.mdx +++ b/blog/better-auth/index.mdx @@ -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 diff --git a/code-repos/zenstackhq/v3-doc-orm-policy b/code-repos/zenstackhq/v3-doc-orm-policy new file mode 160000 index 00000000..f4b864b5 --- /dev/null +++ b/code-repos/zenstackhq/v3-doc-orm-policy @@ -0,0 +1 @@ +Subproject commit f4b864b5ece8dbff17a4f75db57ab6f157c6c6fd diff --git a/code-repos/zenstackhq/v3-doc-orm-validation b/code-repos/zenstackhq/v3-doc-orm-validation new file mode 160000 index 00000000..c8643932 --- /dev/null +++ b/code-repos/zenstackhq/v3-doc-orm-validation @@ -0,0 +1 @@ +Subproject commit c8643932d21a8d0f072489f0dda21661b5d51746 diff --git a/code-repos/zenstackhq/v3-doc-quick-start b/code-repos/zenstackhq/v3-doc-quick-start index 0c878665..bddda1f7 160000 --- a/code-repos/zenstackhq/v3-doc-quick-start +++ b/code-repos/zenstackhq/v3-doc-quick-start @@ -1 +1 @@ -Subproject commit 0c878665fce7c377c11b87c3b5b2c8d186da57f8 +Subproject commit bddda1f7ba6046fff49e2beccd99dad64cd8252d diff --git a/docs/faq.md b/docs/faq.md index db01d75a..6618af5b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -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? diff --git a/src/components/StackBlitzGithub.tsx b/src/components/StackBlitzGithub.tsx index 59a87ab2..a1cb4061 100644 --- a/src/components/StackBlitzGithub.tsx +++ b/src/components/StackBlitzGithub.tsx @@ -4,7 +4,7 @@ import GithubCodeBlock from './GithubCodeBlock'; interface StackBlitzGithubProps { repoPath: string; - openFile?: string; + openFile?: string | string[]; codeFiles?: string[]; startScript?: string; } @@ -15,14 +15,16 @@ const StackBlitzGithub: React.FC = ({ 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 ( diff --git a/versioned_docs/version-3.x/modeling/relation.md b/versioned_docs/version-3.x/modeling/relation.md index 5b391ac0..aad92970 100644 --- a/versioned_docs/version-3.x/modeling/relation.md +++ b/versioned_docs/version-3.x/modeling/relation.md @@ -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. diff --git a/versioned_docs/version-3.x/orm/access-control/field-level.md b/versioned_docs/version-3.x/orm/access-control/field-level.md new file mode 100644 index 00000000..ed93df69 --- /dev/null +++ b/versioned_docs/version-3.x/orm/access-control/field-level.md @@ -0,0 +1,7 @@ +--- +sidebar_position: 4 +--- + +# Field-Level Policies 🚧 + +Coming soon. diff --git a/versioned_docs/version-3.x/orm/access-control/index.md b/versioned_docs/version-3.x/orm/access-control/index.md index 0b584167..e621d333 100644 --- a/versioned_docs/version-3.x/orm/access-control/index.md +++ b/versioned_docs/version-3.x/orm/access-control/index.md @@ -2,6 +2,19 @@ sidebar_position: 6 --- -# Access Control 🚧 +# Access Control -Coming soon 🚧 \ No newline at end of file +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. diff --git a/versioned_docs/version-3.x/orm/access-control/post-update.md b/versioned_docs/version-3.x/orm/access-control/post-update.md new file mode 100644 index 00000000..5912f437 --- /dev/null +++ b/versioned_docs/version-3.x/orm/access-control/post-update.md @@ -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 + + diff --git a/versioned_docs/version-3.x/orm/access-control/query.md b/versioned_docs/version-3.x/orm/access-control/query.md new file mode 100644 index 00000000..a337abb7 --- /dev/null +++ b/versioned_docs/version-3.x/orm/access-control/query.md @@ -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 + + + +## 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. diff --git a/versioned_docs/version-3.x/orm/access-control/write-policies.md b/versioned_docs/version-3.x/orm/access-control/write-policies.md new file mode 100644 index 00000000..ae9366c0 --- /dev/null +++ b/versioned_docs/version-3.x/orm/access-control/write-policies.md @@ -0,0 +1,297 @@ +--- +sidebar_position: 1 +--- + +import PackageInstall from '../../_components/PackageInstall'; + +# Writing Policies + +> This section describes model-level policies only. See [here](./field-level.md) for field-level policies. + +## Policy Plugin + +The access control implementation is encapsulated in its own plugin distributed via the `@zenstackhq/plugin-policy` NPM package. The plugin exports extra ZModel attributes and functions. The first step to write policies is to install the package and enable the plugin in your ZModel schema so that those definitions are imported: + + + +```zmodel title="schema.zmodel" +plugin policy { + provider = "@zenstackhq/plugin-policy" +} +``` + +## Rule Types + +Policies can be defined as whitelist rules using the `@@allow` attribute or blacklist rules using `@@deny`. + +Here's a quick example. Don't worry about the details yet. We'll cover the CRUD operations and rule expressions later. + +```zmodel +model User { + id Int @id @default(autoincrement()) + email String @unique + + // open to signup, profiles are public + @@allow('create,read', true) + + // the user himself has full access + @@allow('all', auth().id == id) +} + +model Post { + id Int @id @default(autoincrement()) + title String + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id]) + authorId Int + + // no anonymous access + @@deny('all', auth() == null) + + // published posts are readable by anyone + @@allow('read', published) + + // author has full access + @@allow('all', auth().id == authorId) +} +``` + +You can write as many policy rules as you want for a model. The order of the rules doesn't matter. ZenStack determines whether a CRUD operation is allowed using the following logic: + +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 (secure by default). + +## Operation Types + +Policy rules are expressed in terms of what CRUD operations they govern. + +- `read` + + The permission to read records. Non-readable records are filtered out transparently. + +- `create` + + The permission to create new records. The rules are evaluated **before** the creation happens. + +- `update` + + The permission to update existing records. Non-updatable records are filtered out transparently **before** the update happens. + +- `post-update` + + A special operation type to express conditions that should hold **after** an entity is updated. See [Post-Update Policies](./post-update.md) for details. + +- `delete` + + The permission to delete existing records. Non-deletable records are filtered out transparently **before** the deletion happens. + +- `all` + + Abbreviation for `create`, `read`, `update`, and `delete`. Note that `post-update` is not included and always needs to be explicitly specified. + +As you've seen in the previous sample, a rule can specify multiple operations in a comma-separated list. + +## Functions and Expressions + +ZModel supports an intuitive expression language that's very similar to JavaScript. Typical expressions include: + +- Literals like strings, numbers, and booleans +- Comparisons with `==`, `>`, etc. +- Logical combinations with `&&`, `||`, etc. +- References to the current model's fields, like `published` + +They provide the basic building blocks for composing complex policy rules. + +Functions are an extensibility mechanism that allows encapsulating specific semantics (e.g., getting the current user). Function calls are expressions, so they can be combined with other expressions using operators. + +The following sections will cover some of the functions and expression types that are specifically designed for writing policy rules. See [Functions](../../reference/zmodel/function.md) and [Expression](../../reference/zmodel/expression.md) reference for more details. Refer to the [@zenstackhq/plugin-policy](../../reference/plugins/policy.md) documentation for functions available for writing policies. + + +## Accessing Current User + +The most common type of access control rules concerns the current user. The built-in `auth()` function is designed to access the current user. `auth()` returns an object, and its type is inferred from the ZModel schema with the following rules: + +1. If there's a `model` or `type` annotated with the `@@auth` attribute, it'll be used as the type of `auth()`. +2. Otherwise, if there's a `model` or `type` named "User" (case sensitive), it'll be used. +3. Otherwise, a compilation error will be reported. + +Simply put, if you have a "user table" in your schema, just name it "User" or annotate that model with `@@auth`. If you want to decouple the `auth()`'s type from the data model for better flexibility, define a `type` and annotate it with `@@auth`. + +```zmodel +type AuthInfo { + email String + role String + + @@auth +} +``` + +With `auth()`'s type available, you can access its fields in policy expressions: + +```zmodel +model Post { + id Int @id @default(autoincrement()) + title String + + @@allow('all', auth().role == 'ADMIN') +} +``` + +So the big question becomes: Where does the value of `auth()` come from? + +ZenStack isn't an authentication library, so it doesn't know how to fetch the current login user. You are responsible for providing that piece of information. We'll cover it in the next part, where we talk about the runtime aspect of access control. For now, assume `auth()` gives you the **validated** current user info. + +If you don't provide the current user, `auth()` will return `null`, and you can use it to write rules for anonymous users, like: + +```zmodel +@@deny('all', auth() == null) +``` + +## Accessing Relations + +You can achieve a lot by writing policies using a model's simple fields; however, real-world access control often involves relations. For example: + +> A user can only create posts if his profile is verified, where "Profile" is a relation of "User". + +ZenStack's policy really shines when it comes to how flexible it is in traversing relations. + +### To-One Relations + +Accessing to-one relation is straightforward, simply use dot notation to use the relation's field, and you can chain as deeply as you need: + +```zmodel +model User { + ... + profile Profile + + @@allow('all', auth().city === profile.address.city) +} + +model Profile { + ... + address Address +} + +model Address { + ... + city String +} +``` + +### To-Many Relations + +To access to-many relations, use the "collection predicate expression" to build a boolean predicate over the related records. The expression has three variants: + +- Some: `relation?[CONDITION]` +- Every: `relation![CONDITION]` +- None: `relation^[CONDITION]` + +The `CONDITION` is an expression under the context of the relation, meaning that fields referenced in the condition are resolved against the related model. You can use `this` keyword to "escape" and refer to the fields belonging to the model where the rule is defined. + +Here's an example: + +```zmodel +model User { + ... + posts Post[] + + // user can't be deleted if he has published posts + @@deny('delete', posts?[published]) +} + +model Post { + ... + published Boolean @default(false) +} +``` + +You can nest collection predicate expressions to build deep to-many relation traversals. + +## Special Notes About `create` + +There are limitations on what relations can be accessed in `create` rules, because such rules are evaluated before the record is created. At that time, relations are not accessible yet. + +As a result, `create` rules can only access "owned" relations - those relations that have foreign keys defined in the model where the rule is defined. During create, if the foreign key fields are set, the relations are accessible. For example, the following is allowed: + +```zmodel +model Profile { + ... + user User @relation(fields: [userId], references: [id]) + userId Int + + // ✅ `user` is an owned relation + @@allow('create', auth().id == user.id) +} +``` + +But the following is not allowed: + +```zmodel +model User { + ... + profile Profile + + // ❌ `profile` is not an owned relation + @@allow('create', auth().id == profile.userId) +} +``` + +## Reusing Relation's Policies + +A common pattern in access control is that an entity's policy directly inherits from its related entity. For example: "A user's profile is readable if the user is readable." Instead of writing duplicated rules like: + +```zmodel +model User { + ... + public Boolean + @@allow('read', public) +} + +model Profile { + ... + user User + + @@allow('read', user.public) +} +``` + +You can use the `check()` function to delegate the policy check to the related entity directly: + +```zmodel +model User { + ... + public Boolean + @@allow('read', public) +} + +model Profile { + ... + user User + + @@allow('read', check(user, 'read')) +} +``` + +You can make it even more concise by omitting the second argument and inferring it from the context: + +```zmodel +model Profile { + ... + @@allow('read', check(user)) // "read" is inferred from the rule context +} +``` + +:::info +`check()` currently only supports to-one relations. +::: + +## Custom Functions 🚧 + +Coming soon. + +## RBAC, ABAC, ReBAC, ?BAC + +If you've studied access control patterns, you've probably heard many acronyms. What pattern does ZenStack employ? + +We can say it's "none", or "all of them". ZenStack doesn't force you into any specific pattern. Instead, it provides you with a set of building blocks so you can build the implementation that best fits your needs. It can be one of the existing popular patterns, a mix of them, or something without an acronym yet. diff --git a/versioned_docs/version-3.x/orm/errors.md b/versioned_docs/version-3.x/orm/errors.md index 2c8db21a..eeaf4141 100644 --- a/versioned_docs/version-3.x/orm/errors.md +++ b/versioned_docs/version-3.x/orm/errors.md @@ -7,10 +7,12 @@ description: ORM Errors The ORM uses the following error classes from `@zenstackhq/runtime` to represent different types of failures: -## `ValidationError` +## `InputValidationError` This error is thrown when the argument passed to the ORM methods is invalid, e.g., missing required fields, or containing unknown fields. The `cause` property is set to the original error thrown during validation. +If [input validation](../orm/validation.md) is used, this error is also thrown when the validation rules are violated. + ## `NotFoundError` This error is thrown when a requested record is not found in the database, e.g., when calling `findUniqueOrThrow`, `update`, etc. @@ -18,3 +20,7 @@ This error is thrown when a requested record is not found in the database, e.g., ## `QueryError` This error is used to encapsulate all other errors thrown from the underlying database driver. The `cause` property is set to the original error thrown. + +## `RejectedByPolicyError` + +This error is thrown when an operation is rejected by [access control policies](../orm/access-control/index.md). diff --git a/versioned_docs/version-3.x/orm/index.md b/versioned_docs/version-3.x/orm/index.md index b8a0f92f..131e84ea 100644 --- a/versioned_docs/version-3.x/orm/index.md +++ b/versioned_docs/version-3.x/orm/index.md @@ -1,5 +1,4 @@ --- -sidebar_position: 1 description: ZenStack ORM overview --- diff --git a/versioned_docs/version-3.x/orm/migration.md b/versioned_docs/version-3.x/orm/migration.md index 90bac71e..564d52b1 100644 --- a/versioned_docs/version-3.x/orm/migration.md +++ b/versioned_docs/version-3.x/orm/migration.md @@ -1,5 +1,6 @@ --- sidebar_position: 17 +description: Database schema migrations --- # Database Migration diff --git a/versioned_docs/version-3.x/orm/typed-json.md b/versioned_docs/version-3.x/orm/typed-json.md index a96f1475..8cafa265 100644 --- a/versioned_docs/version-3.x/orm/typed-json.md +++ b/versioned_docs/version-3.x/orm/typed-json.md @@ -19,5 +19,4 @@ ZModel allows you to define custom types and use them to [type JSON fields](../m ## Samples - diff --git a/versioned_docs/version-3.x/orm/validation.md b/versioned_docs/version-3.x/orm/validation.md index d505d5a9..91e3d04e 100644 --- a/versioned_docs/version-3.x/orm/validation.md +++ b/versioned_docs/version-3.x/orm/validation.md @@ -1,8 +1,31 @@ --- sidebar_position: 9 -description: Data validation in ZModel +description: Input validation in ZModel --- -# Data Validation 🚧 +import ZenStackVsPrisma from '../_components/ZenStackVsPrisma'; +import StackBlitzGithub from '@site/src/components/StackBlitzGithub'; -Coming soon 🚧 +# Input Validation + + +Input validation is a ZModel feature and doesn't exist in Prisma. + + +:::warning +Input validation is only applied to the ORM APIs like `create`, `update`, etc. It's not enforced at the query builder level. +::: + +When defining a model field in ZModel, you specify its type, which provides a basic level of runtime validation - when an incompatible value is passed to the field during creation or update, the ORM engine will reject the operation. + +However, oftentimes you want to express more fine-grained validation rules, such as field length, string format, or numeric range, etc. ZModel provides a set of built-in field-level validation attributes, like `@length`, `@email`, etc., for this purpose. See [reference guide](../reference/zmodel/input-validation.md) for more details. + +To support more complex rules, a powerful `@@validate` model-level attribute is provided to allow expression rules using multiple fields and logical operators. Inside the `@@validate` attribute, functions like `length()`, `isEmail()`, etc. are available for building up expressions. See [reference guide](../reference/zmodel/input-validation.md) for the complete list of available functions. + +Arguments of mutation operations are validated before sending them to the database. When a validation rule is violated, an `InputValidationError` exception is thrown, containing details about the violations. + +Internally, ZenStack uses [Zod](https://zod.dev/) to perform validation. Most of the attributes and functions are 1:1 mapped to Zod APIs. + +## Samples + + diff --git a/versioned_docs/version-3.x/reference/plugins/policy.md b/versioned_docs/version-3.x/reference/plugins/policy.md new file mode 100644 index 00000000..103ceaf9 --- /dev/null +++ b/versioned_docs/version-3.x/reference/plugins/policy.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 3 +--- + +# @zenstackhq/plugin-policy + +The `@zenstackhq/plugin-policy` plugin provides a set of ZModel declarations and an ORM runtime plugin to enable access control features. See [Access Control](../../orm/access-control/index.md) for more details. + +## ZModel Declarations + +The plugin contributes a set of ZModel attributes and functions for defining access control policies. To use them, you need to enable the plugin in your ZModel schema: + +```zmodel +plugin policy { + provider = '@zenstackhq/plugin-policy' +} +``` + +### Attributes + +Use the following model-level attributes to define access control policies: + +- `@@allow` + + ```zmodel + attribute @@allow(_ operation: String, _ condition: Boolean) + ``` + + Defines an access policy that allows a set of operations when the given condition is true. + - `operation`: comma-separated list of "create", "read", "update", "post-update", "delete". Use "all" to denote all operations. + - `condition`: a boolean expression that controls if the operation should be allowed. + +- `@@deny` + + ```zmodel + attribute @@deny(_ operation: String, _ condition: Boolean) + ``` + + Defines an access policy that denies a set of operations when the given condition is true. + - `operation`: comma-separated list of "create", "read", "update", "post-update", "delete". Use "all" to denote all operations. + - `condition`: a boolean expression that controls if the operation should be denied. + +### Functions + +The following functions can be used in policy conditions: + +- `auth()` + + ```zmodel + function auth(): User {} + ``` + + Gets the current login user. The return type of the function is the `User` model defined in the current ZModel. + +- `now()` + + ```zmodel + function now(): DateTime {} + ``` + + Gets the current datetime. + +- `contains()` + + ```zmodel + function contains(field: String, search: String, caseInSensitive: Boolean?): Boolean { + } + ``` + + Checks if the field value contains the search string. By default, the search is case-sensitive, and "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if supported, otherwise it still falls back to "LIKE" and delivers whatever the database's behavior is. + +- `startsWith()` + + ```zmodel + function startsWith(field: String, search: String, caseInSensitive: Boolean?): Boolean { + } + ``` + + Checks if the field value starts with the search string. By default, the search is case-sensitive, and "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if supported, otherwise it still falls back to "LIKE" and delivers whatever the database's behavior is. + +- `endsWith()` + + ```zmodel + function endsWith(field: String, search: String, caseInSensitive: Boolean?): Boolean { + } + ``` + + Checks if the field value ends with the search string. By default, the search is case-sensitive, and "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if supported, otherwise it still falls back to "LIKE" and delivers whatever the database's behavior is. + +- `has()` + + ```zmodel + function has(field: Any[], search: Any): Boolean {} + ``` + + Checks if the list field value has the given search value. + +- `hasSome()` + + ```zmodel + function hasSome(field: Any[], search: Any[]): Boolean { + } + ``` + + Checks if the list field value has at least one element of the search list + +- `hasEvery()` + + ```zmodel + function hasEvery(field: Any[], search: Any[]): Boolean {} + ``` + + Checks if the list field value has all elements of the search list. + + +- `isEmpty()` + + ```zmodel + function isEmpty(field: Any[]): Boolean {} + ``` + + Checks if the list field value is empty. + +- `currentModel()` + + ```zmodel + function currentModel(casing: String?): String {} + ``` + + Returns the name of the model for which the policy rule is defined. If the rule is inherited to a sub model, this function returns the name of the sub model. + + - `casing`: parameter to control the casing of the returned value. Valid values are "original", "upper", "lower", "capitalize", "uncapitalize". Defaults to "original". + +- `currentOperation()` + + ```zmodel + function currentOperation(casing: String?): String {} + ``` + + Returns the operation for which the policy rule is defined for. Note that a rule with "all" operation is expanded to "create", "read", "update", and "delete" rules, and the function returns corresponding value for each expanded version. + + - `casing`: parameter to control the casing of the returned value. Valid values are "original", "upper", "lower", "capitalize", "uncapitalize". Defaults to "original". + +## Runtime Plugin + +The plugin exports a runtime plugin `PolicyPlugin` that can be installed on the ORM client to enable access control enforcement. + +```ts +import { ZenStackClient } from '@zenstackhq/runtime'; +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; + +const db = new ZenStackClient(...); +const authDb = db.$use(new PolicyPlugin()); +``` diff --git a/versioned_docs/version-3.x/reference/zmodel/attribute.md b/versioned_docs/version-3.x/reference/zmodel/attribute.md index e8af70c7..19bae0e1 100644 --- a/versioned_docs/version-3.x/reference/zmodel/attribute.md +++ b/versioned_docs/version-3.x/reference/zmodel/attribute.md @@ -264,6 +264,10 @@ model Model { ## Predefined attributes +:::info +Attributes related to input validation are documented in a [separate page](./input-validation.md). +::: + ### @@id ```zmodel @@ -433,8 +437,8 @@ _Params_: | name | The name of the relationship | | fields | A list of fields defined in the current model | | references | A list of fields of the model on the other side of the relation | -| onDelete | Referential action to take on delete. See details [here](#referential-action). | -| onUpdate | Referential action to take on update. See details [here](#referential-action). | +| onDelete | Referential action to take on delete. See details [here](../../modeling/relation.md#referential-action). | +| onUpdate | Referential action to take on update. See details [here](../../modeling/relation.md#referential-action). | ### @map diff --git a/versioned_docs/version-3.x/reference/zmodel/data-field.md b/versioned_docs/version-3.x/reference/zmodel/data-field.md index 82d984d0..369a27ed 100644 --- a/versioned_docs/version-3.x/reference/zmodel/data-field.md +++ b/versioned_docs/version-3.x/reference/zmodel/data-field.md @@ -28,7 +28,7 @@ type Type { - **FIELD_TYPE** - Type of the field. Can be a scalar type, a reference to another model or type if the field belongs to a [model](#model), or a reference to another type if it belongs to a [type](#type). + Type of the field. Can be a scalar type, a reference to another model or type if the field belongs to a [model](./model.md), or a reference to another type if it belongs to a [type](./type.md). The following scalar types are supported: diff --git a/versioned_docs/version-3.x/reference/zmodel/expression.md b/versioned_docs/version-3.x/reference/zmodel/expression.md new file mode 100644 index 00000000..cd72df18 --- /dev/null +++ b/versioned_docs/version-3.x/reference/zmodel/expression.md @@ -0,0 +1,79 @@ +--- +sidebar_position: 8 +--- + +# Expression + +ZModel provides an expression system that allows you to construct complex computations from primitive constructs like literals and fields. Expressions are used extensively in defining access control policies and input validation rules. + +## Expression Types + +### Literal + +String (e.g., `"hello"`), number (e.g., `42`), and boolean (e.g., `true`, `false`) literals are supported. Strings can be enclosed in either single quotes (`'`) or double quotes (`"`). + +### List + +Lists can be constructed using square brackets, e.g., `[1, 2, 3]` or `['a', 'b', 'c']`. + +### Field Reference + +Model's fields can be directly referenced by their names within the current model context. + +### Member Access + +You can use the dot notation to access members of an expression that contains fields. E.g., `author.name` if `author` is a relation field. Member access expressions can be chained. + +### Unary Operator + +The following operators are supported: + +- `!` (logical NOT): E.g., `!true` + +### Binary Operator + +The following operators are supported: +- `==` (equal to): E.g., `a == b` +- `!=` (not equal to): E.g., `a != b` +- `>` (greater than): E.g., `a > b` +- `>=` (greater than or equal to): E.g., `a >= b` +- `<` (less than): E.g., `a < b` +- `<=` (less than or equal to): E.g., `a <= b` +- `&&` (logical AND): E.g., `a && b` +- `||` (logical OR): E.g., `a || b` + +ZModel follows JavaScript's operator precedence and associativity rules. + +### Collection Predicate + +Collection predicate expressions are used to express a boolean condition over a to-many relation field. The expression has the following three variants: + +- `relation?[CONDITION]` + + Evaluates to true if at least one related record satisfies the condition. E.g., `posts?[published == true]`. + +- `relation![CONDITION]` + + Evaluates to true if all related records satisfy the condition. E.g., `posts![published == true]`. + +- `relation^[CONDITION]` + + Evaluates to true if no related records satisfy the condition. E.g., `posts^[published == true]`. + +The `CONDITION` is an expression under the context of the relation, meaning that fields referenced in the condition are resolved against the related model. You can use `this` keyword to "escape" and refer to the fields belonging to the model where the rule is defined. + +### `in` Operator + +The `in` keyword can be used to check if a value exists in a list. E.g., `value in [1, 2, 3]`. + +### Function Calls + +Functions can be called using the standard syntax: `functionName(arg1, arg2, ...)`. See [Functions](./function.md) for the list of available functions. + +### Null + +The `null` keyword represents a null value expression. + +### This + +The `this` keyword refers to the current entity of the model. diff --git a/versioned_docs/version-3.x/reference/zmodel/function.md b/versioned_docs/version-3.x/reference/zmodel/function.md index 1e91123b..a716a9c3 100644 --- a/versioned_docs/version-3.x/reference/zmodel/function.md +++ b/versioned_docs/version-3.x/reference/zmodel/function.md @@ -82,6 +82,10 @@ Parameter's type can also carry the following suffix: ## Predefined functions +:::info +Functions related to input validation are documented in a [separate page](./input-validation.md). +::: + ### uuid() ```zmodel diff --git a/versioned_docs/version-3.x/reference/zmodel/import.md b/versioned_docs/version-3.x/reference/zmodel/import.md index 6d0cab02..ce408323 100644 --- a/versioned_docs/version-3.x/reference/zmodel/import.md +++ b/versioned_docs/version-3.x/reference/zmodel/import.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 --- # Import diff --git a/versioned_docs/version-3.x/reference/zmodel/input-validation.md b/versioned_docs/version-3.x/reference/zmodel/input-validation.md new file mode 100644 index 00000000..8c400489 --- /dev/null +++ b/versioned_docs/version-3.x/reference/zmodel/input-validation.md @@ -0,0 +1,252 @@ +--- +sidebar_position: 10 +--- + +# Input Validation + +This document describes the attributes and functions available for input validation in ZModel. See [Input Validation](../../orm/validation.md) for more details on how to use them. + +## Model-Level Attributes + +- `@@validate` + + ```zmodel + @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) + ``` + + Defines a model-level validation rule that can involve multiple fields and logical combinations. Use the `message` parameter to provide an optional custom error message, and the `path` parameter to provide an optional path to the field that caused the error. + +## Field-Level Attributes + +All field-level attributes have a `message` parameter that allows you to provide a custom error message. + +- **String & List Fields** + + - `@length` + + ```zmodel + @length(_ min: Int?, _ max: Int?, _ message: String?) + ``` + + Validates the length of a string or list field. + +- **String Fields** + - `@startsWith` + + ```zmodel + @startsWith(_ text: String, _ message: String?) + ``` + + Requires a string field to start with a given prefix. + + - `@endsWith` + + ```zmodel + @endsWith(_ text: String, _ message: String?) + ``` + + Requires a string field to end with a given suffix. + + - `@contains` + + ```zmodel + @contains(_text: String, _ message: String?) + ``` + + Requires a string field to contain a given substring. + + - `@email` + + ```zmodel + @email(_ message: String?) + ``` + + Requires a string field to be a valid email address. + + - `@url` + + ```zmodel + @url(_ message: String?) + ``` + + Requires a string field to be a valid URL. + + - `@datetime` + + ```zmodel + @datetime(_ message: String?) + ``` + + Requires a string field to be a valid ISO 8601 datetime. + + - `@regex` + + ```zmodel + @regex(_ regex: String, _ message: String?) + ``` + + Requires a string field to match a given regular expression. + + - `@lower` + + ```zmodel + @lower(_ value: String) + ``` + + Transforms a string field to lowercase before saving to the database. + + - `@upper` + + ```zmodel + @upper(_ value: String) + ``` + + Transforms a string field to uppercase before saving to the database. + + - `@trim` + + ```zmodel + @trim(_ value: String) + ``` + + Trims whitespace from both ends of a string field before saving to the database. + +- **Number Fields** + + - `@lt` + + ```zmodel + @lt(_ value: Any, _ message: String?) + ``` + + Requires a number field to be less than a given value. + + - `@lte` + + ```zmodel + @lte(_ value: Any, _ message: String?) + ``` + + Requires a number field to be less than or equal to a given value. + + - `@gt` + + ```zmodel + @gt(_ value: Any, _ message: String?) + ``` + + Requires a number field to be greater than a given value. + + - `@gte` + + ```zmodel + @gte(_ value: Any, _ message: String?) + ``` + + Requires a number field to be greater than or equal to a given value. + +## Functions + +- `now()` + + ```zmodel + function now(): DateTime {} + ``` + + Returns the current datetime. + +- `length()` + + ```zmodel + function length(field: Any): Int {} + ``` + + Returns the length of a string or list field. + +- `startsWith()` + + ```zmodel + function startsWith(field: String, search: String): Boolean {} + ``` + + Checks if a string field starts with a given prefix. + +- `endsWith()` + + ```zmodel + function endsWith(field: String, search: String): Boolean {} + ``` + + Checks if a string field ends with a given suffix. + +- `contains()` + + ```zmodel + function contains(field: String, search: String): Boolean {} + ``` + + Checks if a string field contains a given substring. + +- `isEmail()` + + ```zmodel + function isEmail(field: String): Boolean {} + ``` + + Checks if a string field is a valid email address. + +- `isUrl()` + + ```zmodel + function isUrl(field: String): Boolean {} + ``` + + Checks if a string field is a valid URL. + +- `isDateTime()` + + ```zmodel + function isDateTime(field: String): Boolean {} + ``` + + Checks if a string field is a valid ISO 8601 datetime. + +- `regex()` + + ```zmodel + function regex(field: String, pattern: String): Boolean {} + ``` + + Checks if a string field matches a given regular expression. + +- `has()` + + ```zmodel + function has(field: Any[], search: Any): Boolean {} + ``` + + Checks if a list field contains a given element. + +- `hasSome()` + + ```zmodel + function hasSome(field: Any[], search: Any[]): Boolean {} + ``` + + Checks if a list field contains at least one element from a given list. + +- `hasEvery()` + + ```zmodel + function hasEvery(field: Any[], search: Any[]): Boolean {} + ``` + + Checks if a list field contains all elements from a given list. + +- `isEmpty()` + + ```zmodel + function isEmpty(field: Any[]): Boolean {} + ``` + + Checks if a list field is empty. diff --git a/versioned_docs/version-3.x/reference/zmodel/plugin.md b/versioned_docs/version-3.x/reference/zmodel/plugin.md index d2350ab2..b235893e 100644 --- a/versioned_docs/version-3.x/reference/zmodel/plugin.md +++ b/versioned_docs/version-3.x/reference/zmodel/plugin.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 11 --- # Plugin