メインコンテンツまでスキップ

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';