Authentication and permissions
What it is
Section titled “What it is”gisila splits access control into three pluggable parts:
- An
Authenticatorturns a request into aPrincipal(ornull). Built in: JWT, API keys, static tokens, a composite, and a test authenticator. - A
PermissionResolveranswers “does this principal hold permission X / role Y?”. The default reads the principal’s ownpermissionsandrolessets. - 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.
When to use it
Section titled “When to use it”- Guards for anything you can decide from the principal and the route alone: “signed in”, “has
orders:refund”, “is admin”. - A custom
PermissionResolverwhen permissions live in a database, depend on a tenant, or come from a policy engine. - A custom
Authenticatorfor credential types gisila doesn’t ship (session cookies, mTLS).
When not to use it
Section titled “When not to use it”- 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
ForbiddenorNotFound. - 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.
How it works
Section titled “How it works”Guards are resolved at build time:
- Class-level and method-level guards are combined: a method’s
@Permissionsadds to the class’s, it never replaces them. @Permissions,@AnyPermissionand@Rolesimply@RequireAuth.- A method-level
@Public()clears every guard on that route.
At runtime, for a guarded route:
authenticator.authenticate(request)runs.nullgives401 unauthorized. A thrownHttpExceptionwith status 401 or 403 (such as JWT’stoken_expired) is sent as-is; any other exception becomes a generic401and is logged.ctx.principalis set.- Each
@Permissionsentry must passhasPermission; then at least one@AnyPermissionentry; then at least one@Rolesentry. The first failure answers403 forbiddenwith a message naming what was missing. - Your method runs.
Roles or permissions?
Section titled “Roles or permissions?”| 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).
Example
Section titled “Example”@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); }}