Role-based permissions
You’ll protect an orders API so that customers see their own orders, support staff can read all orders, and only managers can issue refunds. Routes declare permissions; a resolver maps each role to the permissions it grants; ownership is checked in the service.
Prerequisites
Section titled “Prerequisites”- Authentication that yields principals with roles, such as JWT with a
rolesclaim.
1. Define roles and permissions in one place
Section titled “1. Define roles and permissions in one place”lib/auth/roles.dart:
const rolePermissions = <String, Set<String>>{ 'customer': {'orders:read:own', 'orders:create'}, 'support': {'orders:read:own', 'orders:read:any'}, 'manager': {'orders:read:own', 'orders:read:any', 'orders:refund'},};2. Write a resolver that expands roles
Section titled “2. Write a resolver that expands roles”import 'package:gisila/gisila.dart';
import 'roles.dart';
class RolePermissionResolver extends PermissionResolver { const RolePermissionResolver(this.grants); final Map<String, Set<String>> grants;
@override Future<bool> hasPermission(Principal p, String permission) async => p.permissions.contains(permission) || p.roles.any((role) => grants[role]?.contains(permission) ?? false);
@override Future<bool> hasRole(Principal p, String role) async => p.roles.contains(role);}Direct permissions on the principal (from token scopes or API keys) still count.
3. Configure the app
Section titled “3. Configure the app”final app = GisilaApp( config: AppConfig( authenticator: jwtAuthenticator, permissionResolver: const RolePermissionResolver(rolePermissions), ),);4. Guard the routes
Section titled “4. Guard the routes”@Controller('/orders')@RequireAuth()class OrdersController { @Get('/') @AnyPermission({'orders:read:own', 'orders:read:any'}) Future<List<Order>> list(OrderService orders) => orders.visibleToCaller();
@Get('/{id}') @AnyPermission({'orders:read:own', 'orders:read:any'}) Future<Order> show(int id, OrderService orders) => orders.get(id);
@Post('/') @Permissions({'orders:create'}) Future<Order> create(OrderForm form, OrderService orders) => orders.create(form);
@Post('/{id}/refund', statusCode: 200) @Permissions({'orders:refund'}) Future<Order> refund(int id, OrderService orders) => orders.refund(id);}Class-level @RequireAuth() plus method-level permissions are combined. A whole admin area can use @Roles({'manager'}) on the class instead.
5. Check ownership in the service
Section titled “5. Check ownership in the service”The guard knows the caller holds orders:read:own, but not whose order this is:
class OrderService extends Service { Future<bool> _canReadAny() => GisilaApp.maybeOf(ctx.request)! .config .permissionResolver .hasPermission(ctx.principal!, 'orders:read:any');
Future<Order> get(int id) async { final order = await _repo.find(id); final mine = order != null && order.customerId == ctx.principal!.id; if (order == null || (!mine && !await _canReadAny())) { throw NotFound('Order $id not found'); // don't reveal other customers' orders } return order; }
Future<List<Order>> visibleToCaller() async => await _canReadAny() ? _repo.all() : _repo.forCustomer(ctx.principal!.id);}Answering 404 rather than 403 for someone else’s resource avoids confirming that it exists.
Verify it works
Section titled “Verify it works”With a test client and TestAuthenticator (see Test controllers), and the resolver configured:
final customer = client.as(const Principal(id: 'c1', roles: {'customer'}));final manager = client.as(const Principal(id: 'm1', roles: {'manager'}));
expect((await customer.post('/orders/1/refund')).statusCode, 403);expect((await customer.post('/orders/1/refund')).errorMessage, contains('orders:refund'));expect((await manager.post('/orders/1/refund')).statusCode, 200);expect((await client.get('/orders')).statusCode, 401);