> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Policies

> Row-level access rules that live next to your schema.

A **policy** is a boolean expression evaluated on every row access. It lives beside your entities, not buried in middleware.

```ts theme={null}
import { policy } from "@pylonsync/sdk";

const noteOwner = policy({
  name: "note_owner",
  entity: "Note",
  allowRead: "true",
  allowInsert: "auth.userId == data.authorId",
  allowUpdate: "auth.userId == data.authorId",
  allowDelete: "auth.userId == data.authorId",
});
```

## What the expressions see

| Binding         | Type             | Contains                                                                              |
| --------------- | ---------------- | ------------------------------------------------------------------------------------- |
| `auth.userId`   | `string \| null` | Current user's id, `null` if unauthenticated                                          |
| `auth.isAdmin`  | `boolean`        | `true` for the `admin` role / admin token / Studio session                            |
| `auth.tenantId` | `string \| null` | Selected organization id (multi-tenant apps)                                          |
| `data.*`        | `object`         | The row: incoming payload on insert; the **current stored row** on read/update/delete |
| `existing.*`    | `object`         | Synonym for the current row (same as `data.*` on read/update/delete)                  |
| `now`           | `string`         | Current UTC time as an ISO-8601 string, for time windows                              |

Roles are checked with the `auth.hasRole("x")` / `auth.hasAnyRole("a", "b")` **functions** (below) — there is no `auth.roles` array and no `auth.email` binding in policy expressions. (Email/roles live on the session and the SSR page `auth` prop, just not in the policy evaluator.)

## Each action

* **`allowRead`** — runs on query results. If false, row is filtered out.
* **`allowInsert`** — runs against the proposed `data` (the incoming row).
* **`allowUpdate`** — runs against the **current stored row** (bound as both `data` and `existing`), so ownership checks see the truth, not the caller's patch.
* **`allowDelete`** — runs against the current row (`data` / `existing`).

If a policy is omitted, that action is denied by default.

## Operators

The policy language is deliberately tiny. The **complete** operator set is:

```
==    !=                       // equality
<     <=    >    >=            // ordering (see below)
&&    ||    !                  // boolean logic
true  false  null              // literals
42    -3    4.5                // numeric literals (int / float / negative)
"string"  'string'             // string literals (either quote)
```

Plus three built-in forms:

```
auth.hasRole("admin")               // role check
auth.hasAnyRole("admin", "owner")   // any-of role check
exists(Entity where field == <expr> [and field == <expr>]*)   // correlated subquery
```

Ordering (`<` `<=` `>` `>=`) compares **numbers** numerically, two **strings** chronologically when both are valid ISO-8601 timestamps (else lexicographically), and is **deny-safe** otherwise: comparing null, a boolean, or a number against a non-numeric string is always false. This is what makes `data.publishAt <= now` deny rows that aren't published yet.

There is still **no `in`, no `ends_with`/`starts_with`, and no arithmetic (`+ - * /`).** Membership is expressed with `exists(...)`, not `in`. String prefix/suffix matching belongs in a [function](/concepts/functions).

## Examples

### Public read, author-only write

```ts theme={null}
policy({
  name: "post_public",
  entity: "Post",
  allowRead: "true",
  allowInsert: "auth.userId == data.authorId",
  allowUpdate: "auth.userId == existing.authorId",
  allowDelete: "auth.userId == existing.authorId",
});
```

### Members of an org

Membership lives in a join entity (`OrgMember { orgId, userId }`) and is checked with `exists(...)`:

```ts theme={null}
policy({
  name: "org_member",
  entity: "Document",
  allowRead: "exists(OrgMember where orgId == existing.orgId and userId == auth.userId)",
  allowInsert: "exists(OrgMember where orgId == data.orgId and userId == auth.userId)",
  allowUpdate: "exists(OrgMember where orgId == existing.orgId and userId == auth.userId)",
});
```

### Admin override

```ts theme={null}
policy({
  name: "admin_all",
  entity: "AuditLog",
  allowRead: "auth.isAdmin",          // or: auth.hasRole("admin")
  allowInsert: "auth.isAdmin",
});
```

### Time window (scheduled publishing)

`now` plus an ordering comparison gates rows by time — public once published, with the author still able to preview:

```ts theme={null}
policy({
  name: "post_published",
  entity: "Post",
  allowRead: "data.publishAt <= now || auth.userId == data.authorId",
});
```

## Policy composition

Multiple policies on the same entity **AND** together — all must pass. Use this to layer a broad rule with a narrow exception:

```ts theme={null}
const readable = policy({ name: "readable", entity: "Doc", allowRead: "true" });
const noDrafts = policy({
  name: "no_drafts",
  entity: "Doc",
  allowRead: "existing.status != 'draft' || auth.userId == existing.authorId",
});
```

## Server functions bypass policies

Policies guard the **raw** `/api/entities/*` endpoints. Server functions (`ctx.db.insert`, `ctx.db.update`, etc.) run with elevated access — they trust you to enforce your own checks inside the handler. This lets you write escape-hatch operations (admin tools, batch imports) without contorting your policy language.

## Next

<CardGroup cols={2}>
  <Card title="Functions" icon="function" href="/concepts/functions">
    Write server logic for anything policies can't express.
  </Card>

  <Card title="Live queries" icon="radio" href="/concepts/live-queries">
    Policies also filter subscription results.
  </Card>
</CardGroup>
