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.
All four are widening: each asks what else may I see? and the first match wins. Tenancy is the opposite — it confines, and sits outside all four. See Tenancy below.
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
Tenancy
Status: blueprint keys shipped in
@abeedoo/radish-schemas@1.12.0; generator support is not built yet. Declaring them validates but has no effect. Documented here because the blueprint and data work can be done now.
It is a scope with a different connective
Tenancy is not a fifth layer. It is the same relationship resolution the four layers already use, joined to them with AND instead of OR:
( tenantId = A ∨ tenantId = B ) ∧ ( ownerId = me ∨ scopedFilter ∨ grants )
└─ tenancy: an OR *within* the axis └─ the four layers, unchanged
{tenantId: {$in: myTenants}} is exactly what scope resolution produces today. The only
difference is the connective. A scope widens — it ORs with ownership, so a user in two
tenants would match their own records in either. A tenant confines.
Your tenant set comes from you, not from the request. It resolves from User's
reference to the boundary entity, the same way matchUserField resolves a scope. There is
no active-tenant header, subdomain, or session claim.
Declaring it
"Org": { "tenant": true } // the boundary — nothing partitions until this exists
"Review": { } // strict, by default
"Setting": { "tenancy": "optional" } // rows with NO tenant are shared with everyone
"Template": { "tenancy": "none" } // deliberately exempt
One declaration per app. Everything else is derived: which entities partition (anything
with a ref to the boundary), the membership field (User's ref to it), and whether a user
may hold several (whether that ref is an array).
Strict is the default, and it fails closed. Forget an opt-out and an entity is over-confined — someone complains. A permissive default would mean forgetting an opt-in leaks silently. The only thing anyone writes is an exception.
"optional" must be declared, never inferred from the field being nullable. Two
identical-looking cases with opposite intent: nulls that are shared defaults (Setting)
and nulls that are un-backfilled orphans — the second must stay hidden.
Tenancy is still an ordinary filter
The partition is the floor, not the view. A page showing one organisation passes
?orgId=A like ?status=active. Filter to a tenant you do not belong to and you get
empty results — there is nothing to validate, because you can only filter inside what
you may already see.
"Organisations I belong to" needs no endpoint: the boundary entity is scoped by its own id
against your membership field automatically, so OrgService.list() returns only yours.
Writes need what reads do not
Reads take a set; writes must pick one:
| your membership | create() |
|---|---|
| one tenant | stamped automatically |
| several | the tenant must be in the payload, validated against your membership |
| several, none given | throws — guessing would pick a tenant for the user |
Caller input is validated, never trusted, and never silently overridden.
Adopting it
- Backfill every row that should belong to a tenant.
- Declare
tenant: true, regenerate, compare row counts — while every user has one tenant they should be identical. - Make the ref
required— a separate migration, and only after backfill; it prevents new orphans, not existing ones.
Order matters: declaring before backfilling filters un-backfilled rows out of sight. That is recoverable — remove the declaration and they return — but it is disruptive, so declare on a branch first.