Access Control
Radish provides a comprehensive permission system with role-based access control (RBAC), ownership scoping, and field-conditional permissions.
Table of Contents
- Overview
- Permission System
- Roles & Permissions
- Ownership Model
- Permission Scopes
- Field-Conditional Permissions
- Guard Utilities
- Service-Layer Enforcement
- Best Practices
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
ownerIdautomatically - 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
- Deny by default - No access unless explicitly granted
- Service layer enforcement - Permissions checked before data access
- Automatic scoping - Owner-based filtering applied automatically
- Type-safe - TypeScript types for permissions and roles
- 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:
| Permission | Description |
|---|---|
{entity}:view:all | View all records regardless of ownership |
{entity}:view:own | View only records owned by the user |
{entity}:create | Create new records |
{entity}:edit:all | Edit any record |
{entity}:edit:own | Edit only owned records |
{entity}:delete:all | Delete any record |
{entity}:delete:own | Delete only owned records |
{entity}:archive:all | Archive any record |
{entity}:archive:own | Archive only owned records |
{entity}:restore:all | Restore any archived record |
{entity}:restore:own | Restore only owned archived records |
{entity}:* | All operations for entity |
System Permissions
Radish includes built-in system permissions:
| Permission | Description |
|---|---|
system:admin | Full system access (grants all permissions) |
system:role:manage | Create and manage custom roles |
api:access | Basic API access for programmatic clients |
Permission Resolution
Permissions are resolved from multiple sources:
- Role-based permissions - Inherited from assigned roles
- Direct permissions - Granted directly to user or API key
- 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