Skip to content

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.

  • Authentication that yields principals with roles, such as JWT with a roles claim.

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'},
};
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.

final app = GisilaApp(
config: AppConfig(
authenticator: jwtAuthenticator,
permissionResolver: const RolePermissionResolver(rolePermissions),
),
);
@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.

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.

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