Rayfin

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

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

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() 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.

@role(roleName, actions, options?)
@anonymous(actions?, options?)      // shorthand for @role('anonymous', ...)
@authenticated(actions?, options?)  // shorthand for @role('authenticated', ...)
ParameterTypeDescription
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 thoseWhich 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

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

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

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

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.

@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

ClaimDescription
claims.subSubject identifier — the signed-in user's ID.
claims.emailThe signed-in user's email address.
claims.roleThe signed-in user's role.

Operators

OperatorExample
.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

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

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

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

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:

@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

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.

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

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

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

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 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 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 for this pattern in a complete, deployed app.

Per-organization scoping

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

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;
}
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;
}
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:

    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 above — for example, claims.role.eq('admin').or(claims.sub.eq(item.created_by)) on Project.

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', ...).
PromptAdd 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`.
Something wrong on this page?Report an issueEdit this page

On this page