Skip to main content

Access Control

Radish provides a comprehensive five-layer access control system: role-based permissions (RBAC), ownership scoping, scoped permissions via entity relationships, per-record grants, and field-level access control.

Table of Contents


Overview

Radish's access control works at two levels:

  1. Entity access — Can you access this record? (Four layers, checked in order)
  2. Field access — Which fields can you see on this record? (Role-based field restrictions)

Security Principles

  1. Deny by default — No access unless explicitly granted
  2. Service-layer enforcement — Permissions checked before data access
  3. Cheapest check first — In-memory checks before DB queries
  4. Field stripping at query level — Restricted fields excluded via MongoDB projection

Four-Layer Entity Access

Every generated service checks access in this order. The first match wins — later layers are skipped.

Layer 1: RBAC        → role/permission checks (in-memory, no DB)
Layer 2: Ownership → ownerId field match (field comparison, no DB)
Layer 3: Scoped → relationship-based access (1 DB lookup)
Layer 4: Grants → explicit per-record ACL (1 DB lookup)

Ordered by efficiency — cheapest checks first.

Resolution Example

// get() resolution order:
Layer 1: hasPermission('product:view:all')return item // RBAC
Layer 2: hasPermission('product:view:own') && owned → return // Ownership
Layer 3: hasScopedAccess(item)return // Scoped
Layer 4: hasGrantAccess('product', id, 'view')return // Grants
403 Forbidden

Layer 1: RBAC

Standard role-based access control. Users have roles, roles have permissions.

Permission Format

entity:action[:scope]
PermissionDescription
product:view:allView all records
product:view:ownView own records only
product:createCreate records
product:edit:allEdit any record
product:edit:ownEdit own records only
product:delete:allDelete any record
product:*All operations on entity
system:adminSuperuser — grants everything

Defining Roles

Roles are defined in the roles blueprint (roles.json):

{
"version": 1,
"roles": {
"EDITOR": {
"label": "Editor",
"description": "Content editor",
"isSystem": false,
"permissions": [
"product:view:all",
"product:create",
"product:edit:own",
"category:view:all"
]
}
}
}

Built-in Roles

RolePurposeDefault Permissions
ADMINSystem administratorsystem:admin (grants everything)
USERAuthenticated userview:own, create, edit:own on all user-owned entities
APIProgrammatic accessapi:access
ANONYMOUSUnauthenticated requestsNone (add via roles.json)
MODERATORContent moderationNone (configure per app)

Wildcard Permissions

product:*              → all product operations
system:admin → full system access

Syncing Roles

After editing roles.json, sync without affecting existing users:

radish-cli seed --sync-roles

Layer 2: Ownership

For user-owned entities (ownership: "user"), the ownerId field is set automatically on creation. Users with :own permissions can only access their own records.

// User with product:view:own
const products = await service.list({});
// Automatically filtered: { ownerId: currentUserId }

Ownership Types

TypeDescription
userRecords belong to a user (ownerId scoping)
systemNo owner — admin-only by default

Layer 3: Scoped Permissions

Access based on relationships — either via ownership of a related entity or via a shared tenant/org field. No extra records to create — derived from existing relationships.

Mode 1: Through-Entity (Ownership Chain)

"A user can view Subscriptions for Apps they own."

{
"Subscription": {
"ownership": "system",
"scope": {
"field": "appId",
"through": "App",
"ownerField": "ownerId"
},
"fields": { ... }
}
}

The service checks: does the user own the App referenced by appId?

Mode 2: Direct-Match (Multi-Tenant)

"A user can view all records belonging to their org(s)."

{
"Conversation": {
"ownership": "system",
"scope": {
"field": "orgId",
"matchUserField": "orgIds"
},
"fields": {
"orgId": { "type": "objectId", "ref": "Org", "required": true }
}
}
}

The service checks: is record.orgId in user.orgIds?

Supports:

  • Array match: user.orgIds (array) includes record.orgId — users in multiple orgs see records from all of them
  • Single value: user.orgId === record.orgId

Tenant Setup

  1. Add org membership to User:
{
"User": {
"extends": "User",
"fields": {
"orgIds": { "type": "objectId[]", "ref": "Org" }
}
}
}
  1. Add scope and orgId to every tenant-scoped entity:
{
"App": {
"scope": { "field": "orgId", "matchUserField": "orgIds" },
"fields": { "orgId": { "type": "objectId", "ref": "Org" } }
},
"Conversation": {
"scope": { "field": "orgId", "matchUserField": "orgIds" },
"fields": { "orgId": { "type": "objectId", "ref": "Org" } }
}
}

Scope Config Reference

PropertyDescription
fieldField on this entity to match against (required)
throughRelated entity model for ownership chain (Mode 1)
ownerFieldOwner field on the related entity (default: ownerId)
matchUserFieldField on User to match directly (Mode 2, tenancy)

How It Works

  • get/update/delete: Checks relationship or field match for the specific record
  • list: Builds a MongoDB $in filter from user's owned/related entities or org IDs
  • Returns empty results (not 403) when user has scope access but no matching records

When to Use

PatternExample
Through-entitySubscription → App → User (you own the parent)
Direct-matchAll records where orgId matches your org(s) (tenancy)

Layer 4: Grants

Explicit per-record access for non-structural relationships. Requires features.grants: true in app blueprint or --features grants on CLI.

Creating Grants

import { GrantService } from '@generated/datalayer/services';

await new GrantService(auth).create({
entity: 'Conversation',
entityId: convId,
grantedTo: consumerId,
grantedToType: 'consumer', // user | consumer | agent | role
permissions: ['view', 'message'],
reason: 'Chat initiated'
});

Grant Fields

FieldDescription
entityEntity type (e.g., "Conversation")
entityIdSpecific record ID
grantedToGrantee ID
grantedToTypePrincipal type: user, consumer, agent, role
permissionsGranted actions: view, edit, delete, admin, *
isActiveRevocable flag
expiresAtOptional TTL
grantedByAudit trail

When to Use

  • Non-authenticated principals (chat consumers, external agents)
  • Temporary access (time-limited sharing)
  • Explicit, revocable, auditable access
  • Access that doesn't follow ownership chains

Field-Level Access

Restrict which fields are visible based on the user's roles. Applied on top of entity access — even if you can see the record, restricted fields are excluded.

Configuration

Add access to field definitions in types.json:

{
"Product": {
"fields": {
"name": { "type": "string", "required": true },
"price": { "type": "float", "required": true },
"costPrice": {
"type": "float",
"access": { "read": ["ADMIN", "MANAGER"] }
},
"margin": {
"type": "float",
"access": { "read": ["ADMIN"] }
},
"supplierNotes": {
"type": "string",
"access": {
"read": ["ADMIN", "MANAGER"],
"write": ["ADMIN"]
}
}
}
}
}

Behavior

  • No access property → field visible to anyone who can access the record
  • access.read: [roles] → field only visible to listed roles
  • access.write: [roles] → field only writable by listed roles
  • ADMIN (system:admin) always sees all fields

Implementation

  • list(): Restricted fields excluded via MongoDB select projection (efficient — never fetched from DB)
  • get(): Restricted fields stripped from response object
  • update(): Write-restricted fields stripped from input before saving

Example

ANONYMOUS with product:view:all → sees: name, price
MANAGER with product:view:all → sees: name, price, costPrice, supplierNotes
ADMIN → sees: everything

Anonymous / Public Access

Make entities publicly accessible without authentication using the ANONYMOUS role.

Setup

  1. Define ANONYMOUS with explicit permissions in roles.json:
{
"ANONYMOUS": {
"label": "Anonymous",
"isSystem": false,
"permissions": [
"product:view:all",
"category:view:all"
]
}
}
  1. Sync roles:
radish-cli seed --sync-roles

How It Works

  • Unauthenticated requests get the ANONYMOUS role automatically
  • Services check RBAC permissions before requiring authentication
  • If ANONYMOUS has product:view:all, the request proceeds without login
  • Create/update/delete still require authentication (ANONYMOUS only gets read)

Combining with Field Access

Public entities can restrict sensitive fields:

{
"Product": {
"fields": {
"name": { "type": "string" },
"price": { "type": "float" },
"costPrice": { "type": "float", "access": { "read": ["ADMIN"] } }
}
}
}

Anonymous users see name and price. Admins see everything.


Overriding Builtin Roles

Builtin roles (USER, ADMIN, ANONYMOUS) have hardcoded permissions. To customize them:

  1. Define the role in roles.json with isSystem: false:
{
"USER": {
"label": "Standard User",
"isSystem": false,
"permissions": [
"product:view:all",
"product:create",
"order:view:own",
"order:create",
"plan:view:all"
]
}
}
  1. Sync: radish-cli seed --sync-roles

The role service checks the database first. If a role exists with isSystem: false, database permissions override the hardcoded builtins.


Guard Utilities

The Guard class provides explicit permission checks for routes and business logic.

import { Guard, fromLocals } from '@generated/datalayer/core/auth/guard';

const auth = fromLocals(locals);
const guard = new Guard(auth);

guard.requireSignedIn(); // 401 if anonymous
guard.requireRole('ADMIN'); // 403 if role missing
guard.requireOwnership(item.ownerId); // 403 if not owner
guard.requireOwnerOr('ADMIN', item.ownerId); // owner OR admin

Best Practices

1. Use the Right Layer

PatternUse When
RBACBroad access patterns (admin, moderator)
Ownership"My records" — standard user CRUD
Scoped"Records related to things I own"
Grants"This specific record was shared with me"
Field access"Some fields are sensitive"

2. Trust the Service Layer

Always use services for data access — never query the repo directly:

// ✅ Correct — enforces all 4 layers + field access
const service = new ProductService(fromLocals(locals));
const products = await service.list({});

// ❌ Wrong — bypasses everything
const repo = new ProductRepo();
const products = await repo.list({});

3. Use Least Privilege

Grant specific permissions, not wildcards:

// ❌ Too broad
{ "permissions": ["product:*"] }

// ✅ Specific
{ "permissions": ["product:view:all", "product:create", "product:edit:own"] }

4. Prefer Scoped Over Grants

If access follows a structural relationship (Subscription → App → User), use scope instead of creating Grant records. Scoped access is automatic and stays in sync.

5. Use Field Access for Sensitive Data

Don't create separate "public" and "private" entities. Use one entity with field-level access:

{
"price": { "type": "float" },
"costPrice": { "type": "float", "access": { "read": ["ADMIN"] } }
}

Next Steps

  • Authentication — User authentication and session management
  • Services — Service layer and permission enforcement
  • Blueprints — Defining roles and permissions