Skip to content

Authentication and permissions

gisila splits access control into three pluggable parts:

  1. An Authenticator turns a request into a Principal (or null). Built in: JWT, API keys, static tokens, a composite, and a test authenticator.
  2. A PermissionResolver answers “does this principal hold permission X / role Y?”. The default reads the principal’s own permissions and roles sets.
  3. Guards declared with annotations (@RequireAuth, @Permissions, @AnyPermission, @Roles, @Public) say what each route requires.

The framework runs the authenticator and resolver before your method on every guarded route, and answers 401 or 403 itself.

  • Guards for anything you can decide from the principal and the route alone: “signed in”, “has orders:refund”, “is admin”.
  • A custom PermissionResolver when permissions live in a database, depend on a tenant, or come from a policy engine.
  • A custom Authenticator for credential types gisila doesn’t ship (session cookies, mTLS).
  • Ownership and row-level rules (“users may edit only their own posts”) depend on data the guard doesn’t have. Check them in the service and throw Forbidden or NotFound.
  • Optional authentication (show more data when signed in): guards are all-or-nothing and the authenticator doesn’t run on public routes. Call the authenticator yourself in that route.

Guards are resolved at build time:

  • Class-level and method-level guards are combined: a method’s @Permissions adds to the class’s, it never replaces them.
  • @Permissions, @AnyPermission and @Roles imply @RequireAuth.
  • A method-level @Public() clears every guard on that route.

At runtime, for a guarded route:

  1. authenticator.authenticate(request) runs. null gives 401 unauthorized. A thrown HttpException with status 401 or 403 (such as JWT’s token_expired) is sent as-is; any other exception becomes a generic 401 and is logged.
  2. ctx.principal is set.
  3. Each @Permissions entry must pass hasPermission; then at least one @AnyPermission entry; then at least one @Roles entry. The first failure answers 403 forbidden with a message naming what was missing.
  4. Your method runs.
Use When
Permissions (orders:refund) in guards Almost always. Routes say what capability they need; which roles grant it can change without code changes.
Roles in guards Coarse areas (an admin controller) when roles come from an identity provider and you don’t want a mapping table.
A resolver that maps roles to permissions You want permission guards but your tokens carry roles. See Role-based permissions.

Keep permission names stable and namespaced (resource:action).

@Controller('/projects/{projectId}/tasks')
@RequireAuth()
class TasksController {
@Get('/')
@Permissions({'tasks:read'})
Future<List<Task>> list(String projectId, TaskService tasks) => tasks.list(projectId);
@Delete('/{id}')
@AnyPermission({'tasks:delete', 'admin'})
Future<void> delete(String projectId, String id, TaskService tasks) =>
tasks.delete(projectId, id); // the service checks the caller owns the project
}
class TaskService extends Service {
Future<void> delete(String projectId, String id) async {
final project = await _projects.find(projectId);
if (project == null || project.ownerId != ctx.principal!.id) {
throw NotFound('Project not found');
}
await _tasks.delete(id);
}
}