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.

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]
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

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 membershipcreate()
one tenantstamped automatically
severalthe tenant must be in the payload, validated against your membership
several, none giventhrows — guessing would pick a tenant for the user

Caller input is validated, never trusted, and never silently overridden.

Adopting it​

  1. Backfill every row that should belong to a tenant.
  2. Declare tenant: true, regenerate, compare row counts — while every user has one tenant they should be identical.
  3. 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.