---
title: "Permissions and row-level security"
description: "Secure Rayfin entities with @role, @anonymous, and @authenticated, including row-level policies, field visibility, action-specific rules, and multi-tenant scoping."
url: https://rayfin.ai/docs/data/permissions
markdown_url: https://rayfin.ai/docs/data/permissions.md
section: data
product: Rayfin
sdk_version: 1.34.0
cli_version: 1.33.2
last_updated: 2026-08-23T01:28:43-07:00
source: data/permissions.mdx
---

# Permissions and row-level security

> Secure Rayfin entities with @role, @anonymous, and @authenticated, including row-level policies, field visibility, action-specific rules, and multi-tenant scoping.

Permissions are declared on the entity class itself, as decorators, and compiled into
Data API Builder (DAB) configuration when you apply the schema. There is no separate
permissions file to keep in sync with your models.

## Entities without a permission decorator [#entities-without-a-permission-decorator]

> [!WARNING]
> An entity with **no** `@role()`, `@anonymous()`, or `@authenticated()` decorator does
> not become inaccessible — it silently receives `authenticated: *`, meaning **full
> create/read/update/delete access for any signed-in user**, with no row-level
> restriction. This is rarely what you want for real data. Add an explicit permission
> decorator to every entity, even if it is as simple as `@authenticated('*')`.

## Built-in roles [#built-in-roles]

Rayfin recognizes two built-in roles:

* **`anonymous`** — public access, no authentication required.
* **`authenticated`** — requires a valid signed-in session.

> [!NOTE]
> Anonymous access requires a tenant admin to enable the "Enable anonymous data access
> for Fabric Apps" switch for your tenant.

## `@role()`, and the `@anonymous()` / `@authenticated()` shorthands [#role-and-the-anonymous--authenticated-shorthands]

`@role()` is the general-purpose class decorator; `@anonymous()` and `@authenticated()`
are shorthands for `@role('anonymous', ...)` and `@role('authenticated', ...)`. Prefer the
shorthands — they read better and are what most of this page uses from here on.

```typescript
@role(roleName, actions, options?)
@anonymous(actions?, options?)      // shorthand for @role('anonymous', ...)
@authenticated(actions?, options?)  // shorthand for @role('authenticated', ...)
```

| Parameter  | Type                                                                      | Description                                                                                    |
| ---------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `roleName` | `'anonymous' \| 'authenticated'`                                          | Which built-in role this rule applies to. Only present on `@role()` — the shorthands imply it. |
| `actions`  | `'create' \| 'read' \| 'update' \| 'delete' \| '*'`, or an array of those | Which operations this rule grants. `'*'` means all four. Defaults to `'*'` if omitted.         |
| `options`  | `{ policy?, include?, exclude? }`                                         | Row-level policy and field visibility, described below.                                        |

### Grant full access to authenticated users [#grant-full-access-to-authenticated-users]

```typescript title="rayfin/data/Category.ts"
import { entity, authenticated, uuid, text } from '@microsoft/rayfin-core';

@entity()
@authenticated('*')
export class Category {
  @uuid() id!: string;
  @text({ max: 100 }) name!: string;
}
```

### Public read, authenticated write [#public-read-authenticated-write]

Combine `@anonymous()` and `@authenticated()` on the same entity to give each role a
different action set:

```typescript title="rayfin/data/Todo.ts"
import { entity, anonymous, authenticated, uuid, text } from '@microsoft/rayfin-core';

@entity()
@anonymous('read')
@authenticated(['create', 'read', 'update', 'delete'], {
  policy: (claims, item) => claims.sub.eq(item.user_id),
})
export class Todo {
  @uuid() id!: string;
  @text({ max: 200 }) title!: string;
  @text({ max: 128 }) user_id!: string;
}
```

Anyone can read every `Todo`; only a signed-in user can create, update, or delete rows,
and only rows where `user_id` matches their own `claims.sub`.

## Row-level policies with the policy DSL [#row-level-policies-with-the-policy-dsl]

Pass a `policy` callback in the options object to restrict *which rows* a role can act on.
The callback receives a typed `claims` object and a proxy for the entity's own fields
(`item`), and returns a comparison Rayfin compiles into a DAB policy string.

```typescript
@authenticated('*', {
  policy: (claims, item) => claims.sub.eq(item.user_id),
})
```

TypeScript infers the entity's shape from the decorated class, so `item.<field>` is
autocompleted and renaming a field is a compile-time error everywhere it's referenced.

### Supported claims [#supported-claims]

| Claim          | Description                                   |
| -------------- | --------------------------------------------- |
| `claims.sub`   | Subject identifier — the signed-in user's ID. |
| `claims.email` | The signed-in user's email address.           |
| `claims.role`  | The signed-in user's role.                    |

### Operators [#operators]

| Operator      | Example                       |
| ------------- | ----------------------------- |
| `.eq(value)`  | `claims.sub.eq(item.user_id)` |
| `.neq(value)` | `item.status.neq('archived')` |

`value` can be another claim or field reference, or a literal string, number, boolean, or
`Date`.

### Combining conditions [#combining-conditions]

Combine expressions with `.and()` and `.or()`. Both sides are parenthesized
automatically, so grouping is always explicit in the generated policy:

```typescript
policy: (claims, item) =>
  claims.sub.eq(item.user_id).and(item.isActive.eq(true))

// (claims.role eq 'admin') or (claims.sub eq item.owner_id)
policy: (claims, item) =>
  claims.role.eq('admin').or(claims.sub.eq(item.owner_id))
```

## Field-level permissions [#field-level-permissions]

Use `include` or `exclude` in the options object to control which fields a role can see or
write, per action.

```typescript title="rayfin/data/Document.ts"
import { entity, authenticated, uuid, text } from '@microsoft/rayfin-core';

@entity()
@authenticated('read', {
  policy: (claims, item) => claims.sub.eq(item.owner_id),
  exclude: ['secret'],
})
@authenticated(['create', 'update', 'delete'], {
  policy: (claims, item) => claims.sub.eq(item.owner_id),
})
export class Document {
  @uuid() id!: string;
  @text({ max: 128 }) owner_id!: string;
  @text({ max: 200 }) title!: string;
  @text({ optional: true, max: 5000 }) secret?: string;
}
```

`exclude` hides `secret` from read responses while leaving it writable on create and
update. `include` works the other way — list only the fields a role is allowed to touch
for that action, useful for a restricted create form:

```typescript
@authenticated('create', {
  policy: (claims, item) => claims.sub.eq(item.createdBy),
  include: ['title'],
})
```

`include` and `exclude` arrays are typed against the entity's actual property names, so a
typo or a renamed field is caught at compile time.

## Action-specific permissions [#action-specific-permissions]

Apply multiple decorators with a single action each when the policy or field visibility
differs per action, as in the `Document` example above. Rayfin aggregates every decorator
on the same class per role; conflicting rules for the same role and action produce a
warning when the schema is generated.

```typescript title="rayfin/data/SecureDocument.ts"
import { entity, anonymous, authenticated, uuid, text } from '@microsoft/rayfin-core';

@entity()
@anonymous('read')
@authenticated('create', {
  policy: (claims, item) => claims.sub.eq(item.createdBy),
  include: ['title'],
})
@authenticated('read', {
  policy: (claims, item) => claims.sub.eq(item.createdBy),
})
@authenticated('update', {
  policy: (claims, item) => claims.sub.eq(item.createdBy),
  exclude: ['adminContent'],
})
export class SecureDocument {
  @uuid() id!: string;
  @text({ max: 200 }) title!: string;
  @text({ optional: true, max: 5000 }) adminContent?: string;
  @text({ max: 128 }) createdBy!: string;
}
```

## Storage permissions [#storage-permissions]

The same decorators secure storage folders declared with `@blob()` — see
[Field types](/docs/data/field-types#blob-storage-folders). Applied to a `@blob()` class,
Rayfin generates a storage policy instead of a database policy, using the same
`policy` / `include` / `exclude` options:

```typescript title="rayfin/storage/ProfileImage.ts"
import { blob, authenticated } from '@microsoft/rayfin-core';

@blob('avatars')
@authenticated('*', {
  policy: (claims, item) => claims.sub.eq(item.owner_id),
})
export class ProfileImage {
  owner_id!: string;
}
```

## Multi-tenant patterns [#multi-tenant-patterns]

Row-level policies compare claims to fields on the *same row* — there is no join in the
policy itself, and only `claims.sub`, `claims.email`, and `claims.role` are available (see
[Supported claims](#supported-claims) above). That covers per-user scoping directly, but
scoping rows to a shared organization or team needs a stored `organization_id` plus a
membership entity, since there is no organization or tenant claim to compare against.

### Per-user scoping [#per-user-scoping]

Per-user scoping is the `policy: (claims, item) => claims.sub.eq(item.user_id)` pattern
used throughout this page — every row carries a `user_id` set from the caller's session,
and the policy compares it to `claims.sub`. See [Build a todo app](/docs/recipes/todo-app)
for this pattern in a complete, deployed app.

### Per-organization scoping [#per-organization-scoping]

Rows shared across a team need a stored `organization_id`, plus a membership record that
says who belongs to which organization:

```typescript title="rayfin/data/Organization.ts"
import { entity, role, uuid, text } from '@microsoft/rayfin-core';

@entity()
@role('authenticated', ['read', 'update', 'delete'], {
  policy: (claims, item) => claims.sub.eq(item.owner_id),
})
@role('authenticated', 'create')
export class Organization {
  @uuid() id!: string;
  @text({ max: 200 }) name!: string;
  @text({ max: 128 }) owner_id!: string;
}
```

```typescript title="rayfin/data/OrganizationMember.ts"
import { entity, role, uuid, text, one } from '@microsoft/rayfin-core';
import { Organization } from './Organization.js';

@entity()
@role('authenticated', '*', {
  policy: (claims, item) => claims.sub.eq(item.user_id),
})
export class OrganizationMember {
  @uuid() id!: string;
  @uuid() organization_id!: string;
  @one(() => Organization) organization?: Organization;
  @text({ max: 128 }) user_id!: string;
}
```

```typescript title="rayfin/data/Project.ts"
import { entity, role, uuid, text, one } from '@microsoft/rayfin-core';
import { Organization } from './Organization.js';

@entity()
@role('authenticated', '*', {
  policy: (claims, item) => claims.sub.eq(item.created_by),
})
export class Project {
  @uuid() id!: string;
  @text({ max: 200 }) name!: string;
  @uuid() organization_id!: string;
  @one(() => Organization) organization?: Organization;
  @text({ max: 128 }) created_by!: string;
}
```

`organization_id` is `@uuid()` here — unlike `user_id`, it *is* a foreign key: it
references `Organization.id` through `@one(() => Organization)`, so its type must match
the primary key it points at. Import `Organization` with a regular `import` (not `import
type`) — the decorator needs the runtime class value, not just its type.

The `@role` policy on `Project` above still only enforces ownership by the row's creator
(`claims.sub.eq(item.created_by)`) — the policy DSL can't express "the caller is a member
of `item.organization_id`" directly, since that requires a join against
`OrganizationMember`. Two things layer on top of the row policy to get organization-wide
sharing:

1. **Scope every read by organization membership**, looked up first:

   ```typescript
   const memberships = await client.data.OrganizationMember.select(['organization_id'])
     .where({ user_id: { eq: session.user.id } })
     .execute();

   const projects = await client.data.Project.select(['id', 'name', 'organization_id'])
     .where({ organization_id: { eq: memberships[0].organization_id } })
     .execute();
   ```

2. **Check membership in application code before writes** — verify the caller has an
   `OrganizationMember` row for the target `organization_id` before creating or updating a
   `Project` in that organization. The row-level policy alone won't stop a member of one
   organization from writing into another's `organization_id` if your application code
   doesn't check first.

Layer an admin bypass onto either pattern the same way described in
[Combining conditions](#combining-conditions) above — for example,
`claims.role.eq('admin').or(claims.sub.eq(item.created_by))` on `Project`.

## Best practices [#best-practices]

* Add an explicit permission decorator to every entity — never rely on the default (see
  the warning at the top of this page).
* Include a `user_id` (or similarly named) field on any entity scoped to the signed-in
  user, and pair it with a `policy` comparing it to `claims.sub`.
* Start restrictive and expand as needed — it's easier to widen a policy later than to
  discover data was over-exposed.
* Use separate `@role()` / `@authenticated()` entries per action when field visibility
  differs by action, as shown above.
* Prefer the `@anonymous()` / `@authenticated()` shorthands over `@role('anonymous', ...)`
  / `@role('authenticated', ...)`.

```prompt title="Add row-level security to an entity"
In my Rayfin project, add row-level security to the Todo entity in rayfin/data/Todo.ts so
each signed-in user can only read and write their own rows. Use @authenticated('*') with
a policy comparing claims.sub to item.user_id. Remove any implicit reliance on default
permissions — the decorator must be explicit. Then apply the schema with `rayfin up`.
```
