Skip to main content

Access Control

Radish provides a comprehensive permission system with role-based access control (RBAC), ownership scoping, and field-conditional permissions.

Table of Contents


Overview

Radish's access control system provides multiple layers of security:

  • Role-Based Access Control (RBAC) - Assign permissions to roles, assign roles to users
  • Ownership Scoping - Filter data by ownerId automatically
  • Permission Wildcards - Flexible permission hierarchies (product:*, *:view:all)
  • Field-Conditional Permissions - Grant access based on field values (product:view:status:published)
  • Service-Layer Enforcement - Permission checks built into services
  • Guard Utilities - Explicit guards for routes and business logic

Security Principles

  1. Deny by default - No access unless explicitly granted
  2. Service layer enforcement - Permissions checked before data access
  3. Automatic scoping - Owner-based filtering applied automatically
  4. Type-safe - TypeScript types for permissions and roles
  5. Auditable - Permission resolution is traceable

Permission System

Permission Format

Permissions follow a structured format:

entity:action[:scope]
entity:action:field:value[:scope]

Examples:

product:view:all          # View all products
product:view:own # View own products only
product:create # Create products
product:edit:all # Edit any product
product:edit:own # Edit own products
product:delete:all # Delete any product
product:* # All product operations
system:admin # System administrator (full access)

Auto-Generated Permissions

For each entity in your blueprint, Radish generates standard permissions:

PermissionDescription
{entity}:view:allView all records regardless of ownership
{entity}:view:ownView only records owned by the user
{entity}:createCreate new records
{entity}:edit:allEdit any record
{entity}:edit:ownEdit only owned records
{entity}:delete:allDelete any record
{entity}:delete:ownDelete only owned records
{entity}:archive:allArchive any record
{entity}:archive:ownArchive only owned records
{entity}:restore:allRestore any archived record
{entity}:restore:ownRestore only owned archived records
{entity}:*All operations for entity

System Permissions

Radish includes built-in system permissions:

PermissionDescription
system:adminFull system access (grants all permissions)
system:role:manageCreate and manage custom roles
api:accessBasic API access for programmatic clients

Permission Resolution

Permissions are resolved from multiple sources:

  1. Role-based permissions - Inherited from assigned roles
  2. Direct permissions - Granted directly to user or API key
  3. Wildcard expansion - product:* grants all product permissions

Example:

// User has role "EDITOR" with permissions:
// - product:view:all
// - product:edit:own

// User also has direct permission:
// - product:delete:own

// Resolved permissions:
[
'product:view:all',
'product:edit:own',
'product:delete:own'
]

// Wildcard matching:
hasPermission('product:view:all') // ✅ true (exact match)
hasPermission('product:edit:own') // ✅ true (exact match)
hasPermission('product:edit:all') // ❌ false (not granted)
hasPermission('product:delete:own') // ✅ true (direct permission)

Wildcard Permissions

Permissions support hierarchical wildcards:

product:*              # All product operations (view, create, edit, delete)
*:view:all # View all entities
system:admin # Full system access

Wildcard Resolution:

// User has: ['product:*']

hasPermission('product:view:all') // ✅ true (matches product:*)
hasPermission('product:create') // ✅ true (matches product:*)
hasPermission('product:edit:own') // ✅ true (matches product:*)
hasPermission('product:delete:all') // ✅ true (matches product:*)
hasPermission('order:view:all') // ❌ false (different entity)

Roles & Permissions

Defining Roles in Blueprint

Roles are defined in a separate YAML blueprint:

# blueprints/roles.yml
version: 1
roles:
USER:
label: User
description: Standard user with basic access
permissions:
- api:access
- product:view:all
- product:create
- product:edit:own
- product:delete:own

EDITOR:
label: Editor
description: Content editor with broader access
permissions:
- api:access
- product:* # All product operations
- category:view:all
- category:create

ADMIN:
label: Administrator
description: Full system administrator
permissions:
- system:admin # Grants everything

Built-in Roles

Radish includes three built-in roles:

USER - Standard authenticated user

permissions: ['api:access']

ADMIN - System administrator

permissions: ['system:admin']  // Grants all permissions

ANONYMOUS - Unauthenticated requests

permissions: []  // No permissions by default

Custom Roles

Create custom roles for your application:

# blueprints/roles.yml
roles:
MODERATOR:
label: Moderator
description: Community moderator
permissions:
- comment:view:all
- comment:edit:all
- comment:delete:all
- post:view:all
- post:edit:status:pending:all # Field-conditional permission

VIEWER:
label: Read-Only Viewer
description: Can only view content
permissions:
- product:view:all
- category:view:all
- post:view:all

Assigning Roles to Users

Roles are assigned when creating or updating users:

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

// Create user with roles
const user = await userService.create({
email: 'editor@example.com',
password: 'secret123',
roles: ['USER', 'EDITOR'] // Multiple roles
});

// Update user roles
await userService.update(user.id, {
roles: ['USER', 'EDITOR', 'MODERATOR']
});

Ownership Model

Radish uses an ownership model where each entity can have an ownerId field.

Automatic Ownership

Services automatically inject ownerId from auth context:

// User creates a product
const service = new ProductService({
userId: 'user-123',
ownerId: 'user-123', // Set to userId
roles: ['USER'],
permissions: []
});

const product = await service.create({
title: 'My Product',
price: 29.99
// ownerId automatically set to 'user-123'
});

console.log(product.ownerId); // 'user-123'

Owner Scoping

Services automatically filter by ownerId based on permissions:

// User with 'product:view:own' permission
const service = new ProductService({
userId: 'user-123',
ownerId: 'user-123',
roles: ['USER'],
permissions: ['product:view:own']
});

// Automatically filtered to user's products
const products = await service.list({});
// Returns only products where ownerId = 'user-123'

// Admin with 'product:view:all' permission
const adminService = new ProductService({
userId: 'admin-456',
ownerId: 'admin-456',
roles: ['ADMIN'],
permissions: ['system:admin']
});

// No owner filtering applied
const allProducts = await adminService.list({});
// Returns all products regardless of owner

Admin Override

Admins can create entities with explicit ownerId:

// Regular users - ownerId from auth context
const product1 = await service.create({
title: 'My Product'
// ownerId set to current user
});

// Admins - specify ownerId explicitly
const product2 = await service.createWithOwner(
{ title: 'Product for User' },
'other-user-id' // Explicit owner
);

Permission Required: system:admin


Permission Scopes

Scope Types

Permissions can have two scopes:

:all - Access to all records regardless of ownership

product:view:all    // View all products
product:edit:all // Edit any product
product:delete:all // Delete any product

:own - Access only to records owned by the user

product:view:own    // View only own products
product:edit:own // Edit only own products
product:delete:own // Delete only own products

Scope Resolution

Services check both scopes and apply appropriate filtering:

// User permissions
permissions: ['product:view:own', 'product:edit:own']

// List products
await service.list({});
// Automatically adds filter: { ownerId: 'user-123' }

// Edit product
await service.update('prod-123', { title: 'Updated' });
// Checks ownership before allowing update
// Throws FORBIDDEN if product.ownerId !== user.ownerId

Field-Conditional Permissions

Grant permissions based on specific field values.

Format

entity:action:field:value[:scope]

Examples:

product:view:status:published         # View products where status=published
product:edit:status:draft:own # Edit own draft products
post:delete:category:spam:all # Delete spam posts (any owner)

Use Cases

Content Moderation:

MODERATOR:
permissions:
- post:view:all
- post:edit:status:pending:all # Edit pending posts
- post:delete:status:spam:all # Delete spam
- post:approve:status:pending:all # Approve pending posts

Status-Based Access:

EDITOR:
permissions:
- article:edit:status:draft:own # Edit own drafts
- article:edit:status:review:own # Edit own articles in review
- article:view:status:published # View published articles

Department-Based Access:

HR_MANAGER:
permissions:
- employee:view:department:hr:all
- employee:edit:department:hr:all

Field-Conditional Matching

Services check field conditions automatically:

// User permissions
permissions: ['product:edit:status:draft:own']

// Try to edit draft product (user owns it)
const draftProduct = { id: '1', status: 'draft', ownerId: 'user-123' };
await service.update('1', { title: 'Updated' });
// ✅ Allowed - matches permission condition

// Try to edit published product (user owns it)
const publishedProduct = { id: '2', status: 'published', ownerId: 'user-123' };
await service.update('2', { title: 'Updated' });
// ❌ FORBIDDEN - status doesn't match permission condition

Guard Utilities

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

Basic Guards

import { Guard } from '@generated/server/auth/guard';

const guard = new Guard(authContext);

// Require authentication
guard.requireSignedIn();
// Throws UNAUTHORIZED (401) if anonymous

// Require specific user type
guard.requireUser();
// Throws FORBIDDEN (403) if API key

// Require specific role
guard.requireRole('ADMIN');
// Throws FORBIDDEN (403) if user doesn't have role

// Require specific scope (for API keys)
guard.requireScope('products:write');
// Throws FORBIDDEN (403) if scope not granted

Ownership Guards

// Require ownership or admin role
guard.requireOwnerOr('ADMIN', product.ownerId);
// Allows if:
// - user owns the resource (ownerId matches), OR
// - user has ADMIN role

// Check ownership
guard.requireOwnership(product.ownerId);
// Throws FORBIDDEN if ownerId doesn't match

Usage in Services

Guards are available in all service methods via this.guard:

export class ProductService extends BaseService {
async customOperation(id: string) {
// Require authentication
this.guard.requireSignedIn();

const product = await this.repo.get(id);
if (!product) return null;

// Require ownership or admin
this.guard.requireOwnerOr('ADMIN', product.ownerId);

// Perform operation
return this.repo.update(id, { /* ... */ });
}
}

Service-Layer Enforcement

Services automatically enforce permissions for all operations.

CRUD Permission Checks

// list() - Checks view permissions
await service.list({});
// Requires: product:view:all OR product:view:own
// Auto-scopes to ownerId if view:own

// get() - Checks view permissions + ownership
await service.get('prod-123');
// Requires: product:view:all OR product:view:own
// If view:own, verifies ownerId matches

// create() - Checks create permission
await service.create({ title: 'Product' });
// Requires: product:create

// update() - Checks edit permissions + ownership
await service.update('prod-123', { title: 'Updated' });
// Requires: product:edit:all OR product:edit:own
// If edit:own, verifies ownerId matches

// delete() - Checks delete permissions + ownership
await service.delete('prod-123');
// Requires: product:delete:all OR product:delete:own
// If delete:own, verifies ownerId matches

Permission Resolution

// Services use BaseService methods
protected async hasPermission(permission: string): Promise<boolean>
protected async getResolvedPermissions(): Promise<string[]>
protected async hasPermissionWithContext(permission, context): Promise<boolean>

Example:

// Check permission in service method
async customMethod() {
const canCreate = await this.hasPermission('product:create');
if (!canCreate) {
throw createError.forbidden();
}

// Proceed with operation
}

Best Practices

1. Use Least Privilege

Grant only the minimum permissions needed:

❌ Don't:

USER:
permissions:
- system:admin # Too broad!

✅ Do:

USER:
permissions:
- product:view:all
- product:create
- product:edit:own
- product:delete:own

2. Prefer Scoped Permissions

Use :own scope when possible to limit access:

❌ Don't:

EDITOR:
permissions:
- article:delete:all # Can delete anyone's articles!

✅ Do:

EDITOR:
permissions:
- article:view:all
- article:create
- article:edit:own
- article:delete:own # Only own articles

3. Use Field-Conditional Permissions

Restrict access based on record state:

MODERATOR:
permissions:
- post:edit:status:pending:all # Only edit pending posts
- post:delete:status:spam:all # Only delete spam

4. Leverage Permission Wildcards Carefully

Use wildcards for admin roles, not regular users:

❌ Don't:

USER:
permissions:
- product:* # Too broad for regular users

✅ Do:

ADMIN:
permissions:
- system:admin # Appropriate for admins

USER:
permissions:
- product:view:all
- product:create
- product:edit:own

5. Trust Service Layer, Not Routes

Always use services for permission checks:

❌ Don't:

// In HTTP route - bypasses permissions!
export async function GET({ params }) {
const repo = new ProductRepo();
return await repo.get(params.id);
}

✅ Do:

// In HTTP route - enforces permissions
export async function GET({ params, locals }) {
const ctx = fromLocals(locals);
const service = new ProductService(ctx);
return await service.get(params.id);
}

6. Use Guards for Custom Logic

For complex permission logic, use guards explicitly:

async customOperation(id: string) {
this.guard.requireSignedIn();

const item = await this.repo.get(id);
if (!item) return null;

// Custom permission check
const canModify = await this.hasPermission('item:modify:all') ||
(await this.hasPermission('item:modify:own') && item.ownerId === this.auth.ownerId);

if (!canModify) {
throw createError.forbidden();
}

// Proceed with operation
}

7. Document Custom Permissions

Add descriptions to custom permissions:

REVIEWER:
label: Content Reviewer
description: Reviews and approves user-submitted content
permissions:
# View all submissions
- submission:view:all
# Approve pending submissions
- submission:approve:status:pending:all
# Reject spam submissions
- submission:reject:status:spam:all

Next Steps

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