Roles
Roles are the foundation of Zelt's authorization system. They define what a user can do.
What is a Role?
A role is a simple string that represents a permission level or capability:
const adminRoles = ['admin', 'editor', 'viewer'];
const teamRoles = ['owner', 'member', 'guest'];
const permissionRoles = ['read:users', 'write:users', 'delete:users'];
Roles are assigned during authentication via setUser():
setUser(
{ id: user.id, name: user.name },
['admin', 'user'] // ← roles
);
Defining Role Types
Use RequestContextSchema to type your roles:
declare module '@zeltjs/core' {
interface RequestContextSchema {
user: { id: string; name: string };
authRoles: ('admin' | 'editor' | 'viewer')[];
}
}
This provides:
- Autocomplete when calling
setUser() - Type checking in
@Authorized(['...']) - Type-safe
currentRoles()return value
Role Design Patterns
Hierarchical Roles
Define roles that imply other roles:
type Role = 'admin' | 'editor' | 'viewer';
const roleHierarchy: Record<Role, Role[]> = {
admin: ['admin', 'editor', 'viewer'],
editor: ['editor', 'viewer'],
viewer: ['viewer'],
};
// When setting user, expand roles
setUser(user, roleHierarchy[user.primaryRole]);
Resource-Scoped Roles
Include resource context in role names:
type Role =
| 'admin'
| `project:${string}:owner`
| `project:${string}:member`
| `team:${string}:admin`;
// User is owner of project-123, member of team-456
setUser(user, ['project:123:owner', 'team:456:admin']);
Permission-Based Roles
Use fine-grained permission strings:
type Permission =
| 'read:users'
| 'write:users'
| 'delete:users'
| 'read:posts'
| 'write:posts';
// Roles map to permissions
const rolePermissions: Record<string, Permission[]> = {
admin: ['read:users', 'write:users', 'delete:users', 'read:posts', 'write:posts'],
editor: ['read:users', 'read:posts', 'write:posts'],
viewer: ['read:users', 'read:posts'],
};
Where Roles Come From
Database
Store roles with the user record:
@Middleware
class AuthMiddleware {
constructor(
private jwtService = inject(JwtService),
private userRepo = inject(UserRepository)
) {}
async use(c: RequestContext, next: Next): Promise<Response | undefined> {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (token) {
const payload = await this.jwtService.verify(token);
const user = await this.userRepo.findById(payload.sub!);
setUser({ id: user.id, name: user.name }, user.roles);
}
await next();
return undefined;
}
}
JWT Claims
Include roles in the JWT payload:
@Controller('/auth')
class AuthController {
constructor(private jwtService = inject(JwtService)) {}
@Post('/login')
async login(user: User) {
const token = await this.jwtService.sign({
sub: user.id,
roles: user.roles,
});
return { token };
}
}
@Config
class MyJwtConfig extends JwtConfig {
override get resolveUser(): (payload: JwtPayload) => Promise<ResolveUserResult> {
return async (payload) => ({
user: { id: payload.sub! },
roles: payload.roles as string[],
});
}
}
Session Data
Store roles in the session:
@Middleware
class SessionAuthMiddleware {
constructor(
private sessionService = inject(SessionService),
private userRepo = inject(UserRepository)
) {}
async use(c: RequestContext, next: Next): Promise<Response | undefined> {
const session = this.sessionService.getSession();
if (session) {
const user = await this.userRepo.findById(session.userId);
setUser(user, session.roles);
}
await next();
return undefined;
}
}
External Service
Fetch roles from an identity provider:
@Middleware
class ExternalAuthMiddleware {
constructor(private idp = inject(IdentityProviderService)) {}
async use(c: RequestContext, next: Next): Promise<Response | undefined> {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (token) {
const userInfo = await this.idp.getUserInfo(token);
const roles = await this.idp.getRoles(userInfo.sub);
setUser({ id: userInfo.sub, name: userInfo.name }, roles);
}
await next();
return undefined;
}
}
Role Assignment Strategies
Static Assignment
Roles are set once and rarely change:
@Controller('/users')
class UserRolesController {
constructor(private userRepo = inject(UserRepository)) {}
@Authorized(['admin'])
@Post('/:id/roles')
async assignRoles(id = pathParam('id'), data = validated(RolesSchema)) {
await this.userRepo.updateRoles(id, data.roles);
return { success: true };
}
}
Dynamic Assignment
Roles are computed based on context:
@Middleware
class ProjectRolesMiddleware {
constructor(private projectRepo = inject(ProjectRepository)) {}
async use(c: RequestContext, next: Next): Promise<Response | undefined> {
const user = currentUser() as User | undefined;
const projectId = c.req.param('projectId');
if (user && projectId) {
const project = await this.projectRepo.findById(projectId);
const roles: string[] = [];
if (project.ownerId === user.id) {
roles.push('project:owner');
}
if (project.memberIds.includes(user.id)) {
roles.push('project:member');
}
setUser(user, roles);
}
await next();
return undefined;
}
}
Time-Based Roles
Roles expire or activate based on time:
const roles = user.roles.filter(role => {
const grant = user.roleGrants.find(g => g.role === role);
if (!grant) return true;
const now = Date.now();
if (grant.startsAt && now < grant.startsAt) return false;
if (grant.expiresAt && now > grant.expiresAt) return false;
return true;
});
setUser(user, roles);
Accessing Roles
In Handlers
@Controller('/app')
class AppController {
@Get('/dashboard')
dashboard() {
const roles = currentRoles();
return {
canManageUsers: roles.includes('admin'),
canEditContent: roles.includes('editor') || roles.includes('admin'),
};
}
}
In Services
class PostService {
canDelete(post: Post): boolean {
const roles = currentRoles();
const user = currentUser() as User | undefined;
if (roles.includes('admin')) return true;
if (post.authorId === user?.id) return true;
return false;
}
}
Best Practices
Keep Roles Simple
Use flat strings, not nested objects:
// ✅ Good
const goodRoles = ['admin', 'editor', 'viewer'];
// ❌ Avoid
const badRoles = [{ name: 'admin', level: 10, permissions: [] }];
Use Roles for Coarse Access
Roles answer "can this user access this feature area?" not "can this user edit this specific record?":
// ✅ Role-based: "Can access admin section"
@Controller('/admin')
class AdminController {
@Authorized(['admin'])
@Get('/dashboard')
adminDashboard() {}
}
// ❌ Not a role: "Can edit post #123"
// → Handle in service logic instead
Avoid Role Explosion
Don't create roles for every action:
// ❌ Too many roles
const tooManyRoles = ['can_view_users', 'can_create_users', 'can_edit_users', 'can_delete_users'];
// ✅ Group into meaningful roles
const meaningfulRoles = ['admin', 'user_manager', 'viewer'];
Document Your Roles
Maintain a central reference:
/**
* Application Roles
*
* - admin: Full system access
* - editor: Can create and modify content
* - viewer: Read-only access
* - moderator: Can manage user-generated content
*/
type Role = 'admin' | 'editor' | 'viewer' | 'moderator';