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
- Four-Layer Entity Access
- Layer 1: RBAC
- Layer 2: Ownership
- Layer 3: Scoped Permissions
- Layer 4: Grants
- Field-Level Access
- Anonymous / Public Access
- Overriding Builtin Roles
- Guard Utilities
- Best Practices
Overview
Radish's access control works at two levels:
- Entity access — Can you access this record? (Four layers, checked in order)
- Field access — Which fields can you see on this record? (Role-based field restrictions)
Security Principles
- Deny by default — No access unless explicitly granted
- Service-layer enforcement — Permissions checked before data access
- Cheapest check first — In-memory checks before DB queries
- 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]
| Permission | Description |
|---|---|
product:view:all | View all records |
product:view:own | View own records only |
product:create | Create records |
product:edit:all | Edit any record |
product:edit:own | Edit own records only |
product:delete:all | Delete any record |
product:* | All operations on entity |
system:admin | Superuser — 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
| Role | Purpose | Default Permissions |
|---|---|---|
ADMIN | System administrator | system:admin (grants everything) |
USER | Authenticated user | view:own, create, edit:own on all user-owned entities |
API | Programmatic access | api:access |
ANONYMOUS | Unauthenticated requests | None (add via roles.json) |
MODERATOR | Content moderation | None (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
| Type | Description |
|---|---|
user | Records belong to a user (ownerId scoping) |
system | No 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) includesrecord.orgId— users in multiple orgs see records from all of them - Single value:
user.orgId === record.orgId
Tenant Setup
- Add org membership to User:
{
"User": {
"extends": "User",
"fields": {
"orgIds": { "type": "objectId[]", "ref": "Org" }
}
}
}
- Add
scopeandorgIdto 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
| Property | Description |
|---|---|
field | Field on this entity to match against (required) |
through | Related entity model for ownership chain (Mode 1) |
ownerField | Owner field on the related entity (default: ownerId) |
matchUserField | Field 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
$infilter 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
| Pattern | Example |
|---|---|
| Through-entity | Subscription → App → User (you own the parent) |
| Direct-match | All 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
| Field | Description |
|---|---|
entity | Entity type (e.g., "Conversation") |
entityId | Specific record ID |
grantedTo | Grantee ID |
grantedToType | Principal type: user, consumer, agent, role |
permissions | Granted actions: view, edit, delete, admin, * |
isActive | Revocable flag |
expiresAt | Optional TTL |
grantedBy | Audit 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
accessproperty → field visible to anyone who can access the record access.read: [roles]→ field only visible to listed rolesaccess.write: [roles]→ field only writable by listed roles- ADMIN (
system:admin) always sees all fields
Implementation
- list(): Restricted fields excluded via MongoDB
selectprojection (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
- Define ANONYMOUS with explicit permissions in
roles.json:
{
"ANONYMOUS": {
"label": "Anonymous",
"isSystem": false,
"permissions": [
"product:view:all",
"category:view:all"
]
}
}
- Sync roles:
radish-cli seed --sync-roles
How It Works
- Unauthenticated requests get the
ANONYMOUSrole 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:
- Define the role in
roles.jsonwithisSystem: false:
{
"USER": {
"label": "Standard User",
"isSystem": false,
"permissions": [
"product:view:all",
"product:create",
"order:view:own",
"order:create",
"plan:view:all"
]
}
}
- 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
| Pattern | Use When |
|---|---|
| RBAC | Broad 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