Food Delivery System Example
Run a Nuxt.js food delivery app and see how its authorization works in Permit.io, one policy model at a time. This worked example is for developers who build with Nuxt or Vue and want to see role-based access control (RBAC), attribute-based access control (ABAC), and relationship-based access control (ReBAC) enforced together on the same action.
By the end, you know which rule lets customers, vendors, riders, and admins in the app perform each order and meal action, and how to build the same policy in the Permit dashboard.
Prerequisites
- A Permit.io environment made for this example, and its environment API key. The example's setup writes its policy and demo users into the environment. See Get your API key.
- Docker with Compose, to run the app, its setup, and the policy decision point (PDP) container that answers permission checks. See Install Docker.
What you build
The app code is in the nuxt-food-delivery folder of the permitio/examples repository. The app has four pages, one per role:
- A customer page for browsing meals and placing orders
- A vendor page for managing meals and fulfilling orders
- A rider page for delivering orders
- An admin page for assigning riders and managing the system
Tech stack
| Layer | What the app uses |
|---|---|
| Frontend | Nuxt with Vue and PrimeVue, one page per role (customer, rider, vendor, admin) |
| Backend | Nuxt server routes whose handlers call permit.check() before each protected action |
| Authorization | RBAC for role permissions, ABAC for the free delivery offer, ReBAC for order assignments, and cities as tenants |
| Data sync | Server routes that write users, role assignments, and meal and order instances to Permit when they change |
| Data store | A JSON file that stands in for a database |
How the app uses each policy model
| Model | What it controls in the app |
|---|---|
| RBAC | Which roles can read, create, and delete meals and orders, and which role assigns riders |
| ABAC | Free delivery: orders that cost 500 or more, and riders with 500 or more rides |
| ReBAC | Order assignments: the vendor whose meals are in the order and the rider assigned to it |
Order management
Each role acts on orders at a different stage:
- Customers create orders.
- Vendors fulfill orders (mark them ready for delivery).
- Admins assign riders to orders.
- Riders deliver orders.
Composite permissions
Policies from all three models apply to the same action. For the deliver action:
- The
adminrole grantsdeliveron every order in the city (RBAC). - The
Order#Riderresource role grantsdeliveron one order. The app assigns it when an admin assigns the rider (ReBAC). - A user set of riders with 500 or more rides grants
deliveron the resource set of orders that cost 500 or more, the free delivery orders (ABAC).
The deliver route in server/routes/order/[id]/deliver.post.ts checks deliver on the order instance, which the order's rider passes through Order#Rider and an admin through the admin role. For a free delivery order, the route also checks deliver with the order's cost and without its key, which a rider passes only with 500 or more rides. Permit evaluates each check against every rule that applies to the action.
The rider and vendor tenant roles don't grant deliver or fulfill. A tenant role applies to every order in the city, so with fulfill on the vendor role every vendor could fulfill every order, and the ReBAC rules would never decide.
Multi-tenant authorization
The app uses cities (california and washington) as tenants. A role assignment in one city doesn't grant the same permission in the other city.
Continuous data syncing
The server routes write these changes to Permit when they happen:
- The user switcher creates a user, or grants or revokes a role in a city.
- A vendor or an admin creates a meal, which becomes a
Mealinstance. - A customer creates an order, which becomes an
Orderinstance, and the vendor of its meals getsOrder#Vendoron it. - An admin assigns a rider, who gets the
riderrole in the city andOrder#Rideron the order.
The app keeps the newest 10 meals and 50 orders per city, and deletes the instances of the ones it drops from Permit.
Server endpoints
The Nuxt server exposes these endpoints. Each handler checks the caller in the city they picked. The Authorization column lists the policy models that each endpoint's checks depend on.
| Method | Endpoint | Action | Authorization |
|---|---|---|---|
| GET | /meals | read | RBAC |
| POST | /meals | create | RBAC |
| DELETE | /meal/:id | delete | RBAC |
| GET | /orders | read | RBAC |
| POST | /orders | create, create-with-free-delivery | RBAC & ABAC |
| POST | /order/:id/fulfill | fulfill | ReBAC (vendor), RBAC (admin) |
| POST | /order/:id/assign-rider | assign-rider | RBAC |
| POST | /order/:id/deliver | deliver | ReBAC (rider), RBAC (admin), and ABAC for free delivery orders |
| POST | /users | None | None |
| DELETE | /users | None | None |
A denied check answers HTTP 403 with the message "You are not permitted to perform this action". When Permit can't answer, the route answers HTTP 502 and logs the operation that failed.
POST /users and DELETE /users back the sidebar's user switcher, which stands in for sign-in. They create users and grant roles for any caller. Guard them, or take the user from your session, before you reuse this code in an application with real data.
Set up and run the app
The nuxt-food-delivery README has the steps. In short, you set your environment API key as PERMIT_API_KEY in .env and run docker compose up --build. Compose starts a PDP container, a one-shot setup service that writes the policy in setup/policy.ts, eight demo users, and a demo menu to your Permit environment, and the app on http://localhost:3000.
The sidebar's Manage dialog stands in for sign-in: pick a user, choose a city, and grant or revoke a role. The app then acts as that user in that city. Setup creates these users:
| User | Role and city | Rides |
|---|---|---|
customer1 | customer in California | |
customer2 | customer in Washington | |
vendor1 | vendor in California (pizza) | |
vendor2 | vendor in California (sushi) | |
vendor3 | vendor in Washington (seafood) | |
rider1 | rider in California | 120 |
rider2 | rider in California | 650 |
admin1 | admin in California and Washington |

Verify: as customer1 in California, the customer page lists the California menu. The Meals and Orders lists load only for a user with a role in the current city, because GET /meals and GET /orders check read.
The next three sections describe the policy that setup creates, one model at a time, and how to build the same policy by hand in the Permit dashboard.
Configure RBAC for meals and orders
Role-based access control (RBAC) grants permissions by role. RBAC is the base layer of the app's policy:
Meals:
- Only
vendorandadmincan create, update, and delete meals. - All roles (
customer,rider,vendor, andadmin) can read meals.
Orders:
customerandadmincan create orders.- Only
admincan assign riders to orders. admincan fulfill and deliver any order in the city. Vendors and riders getfulfillanddeliveronly on their own orders, through ReBAC.- All roles can read orders.
setup/policy.ts holds these roles as data, for example the vendor role:
{
key: 'vendor',
name: 'Vendor',
permissions: ['Meal:read', 'Meal:create', 'Meal:update', 'Meal:delete', 'Order:read'],
},
To build the RBAC layer in the dashboard:
Create the Meal and Order resources
On the Policy screen, open the Resources tab. Create two resources, Meal and Order, with the action keys the route handlers check. Type the keys exactly as listed, including the hyphens: permit.check() matches on the key, and a mismatch returns false.
| Resource | Action keys | Where the app checks them |
|---|---|---|
Meal | create, read, update, delete | server/routes/meals/ and server/routes/meal/[id]/. The app doesn't check update. |
Order | create, read, fulfill, assign-rider, deliver | server/routes/orders/ and server/routes/order/[id]/ |
Add the create-with-free-delivery action on Order in Configure ABAC for free delivery.

Grant RBAC permissions in the Policy Editor
In the Policy Editor, check these actions for each role:
| Role | Meal | Order |
|---|---|---|
customer | read | read, create |
vendor | read, create, update, delete | read |
rider | read | read |
admin | read, create, update, delete | read, create, fulfill, assign-rider, deliver |
Verify: as customer1 in California, add a meal to the cart and click Order Now. The order appears on the Orders tab. Switch the city to Washington: the menu doesn't load, because customer1 has no role in the washington tenant.
Configure ABAC for free delivery
Attribute-based access control (ABAC) grants permissions based on attributes of the user and the resource. You group attribute conditions into user sets and resource sets, and grant permissions to those sets in the Policy Editor.
The app uses ABAC for a free delivery offer:
- A
customeror anadmincan create an order with free delivery if the order'scostis 500 or more. - A rider can deliver a free delivery order if the rider's
number_of_ridesis 500 or more.
These ABAC rules extend the RBAC roles and actions with conditions. Setup creates the resource set orders_above_500 and the user set experienced_riders, and these rules in setup/policy.ts:
export const CONDITION_SET_RULES: readonly ConditionSetRuleCreate[] = [
{
user_set: 'customer',
is_role: true,
permission: 'Order:create-with-free-delivery',
resource_set: 'orders_above_500',
},
{
user_set: 'admin',
is_role: true,
permission: 'Order:create-with-free-delivery',
resource_set: 'orders_above_500',
},
{
user_set: 'experienced_riders',
permission: 'Order:deliver',
resource_set: 'orders_above_500',
},
];
is_role: true grants a tenant role's permission on a resource set, as the Policy Editor does when you check a role's box under a resource set.
To build the ABAC layer in the dashboard:
Add the cost attribute and the free delivery action
- Open the
Orderresource for editing. - Add a
create-with-free-deliveryaction. The app checks this action when it decides whether to issue free delivery. - In the ABAC section, add a
costattribute of type Number. - Save the changes.

Create a resource set for orders that cost 500 or more
- On the ABAC Rules tab, create a resource set for
Order. Setup uses the keyorders_above_500. - Add the condition
costgreater than or equal to 500.
Every order whose cost matches the condition belongs to the resource set.
Add the number_of_rides user attribute
In the user attributes settings, add a number_of_rides attribute of type Number.

Create a user set for riders with 500 or more rides
- On the ABAC Rules tab, create a user set. Setup uses the key
experienced_riders. - Add the condition
number_of_ridesgreater than or equal to 500.
Every user whose number_of_rides matches the condition belongs to the user set.
Grant ABAC permissions in the Policy Editor
The user set and the resource set appear in the Policy Editor.
- For the
customerandadminroles, checkcreate-with-free-deliveryon the order resource set. - For the rider user set, check
deliveron the order resource set.
Verify: as customer1 in California, order the Party pizza box (520). The order's Delivery Fee row reads FREE. Order a Margherita pizza (120) and the row shows a fee of 50, because create-with-free-delivery returns false for a cost under 500.
Configure ReBAC for order assignments
Relationship-based access control (ReBAC) grants permissions based on relationships between users and resource instances. The app uses ReBAC for two rules:
- A vendor can fulfill an order only if the order holds that vendor's meals.
- A rider can deliver an order only if an admin assigned the order to that rider.
The fulfill and deliver routes check the specific order instance, { type: 'Order', key: orderId, tenant }, so the PDP evaluates the user's resource roles on that order. A resource role is a role that applies to instances of one resource type, written Resource#Role. See ReBAC overview.
Setup creates the two resource roles on Order from setup/policy.ts:
export const ORDER_ROLES: readonly ResourceRoleCreate[] = [
{ key: 'Vendor', name: 'Vendor', permissions: ['fulfill', 'read'] },
{ key: 'Rider', name: 'Rider', permissions: ['deliver', 'read'] },
];
To build the ReBAC layer in the dashboard:
Create the Vendor and Rider resource roles
- Open the
Orderresource for editing. - In the ReBAC section, create two resource roles with the keys
VendorandRider. The Policy Editor shows them asOrder#VendorandOrder#Rider. The app assignsVendorinserver/routes/orders/index.post.tsandRiderinserver/routes/order/[id]/assign-rider.post.ts, both by that exact key.
Grant ReBAC permissions in the Policy Editor
In the Policy Editor, check fulfill and read for Order#Vendor, and deliver and read for Order#Rider.
Verify: as customer1, order a pizza. Switch to vendor2, the sushi vendor, and click Fulfill on the pizza order: the app answers "You are not permitted to perform this action", because vendor2 holds no Order#Vendor role on the instance. Switch to vendor1 and fulfill it again: the order's Fulfilled Time fills in.
Sync app data to Permit
The policies depend on users, orders, and role assignments that exist in Permit. The app writes them with methods on permit.api from the Permit Node.js SDK.
The POST /users handler in server/routes/users/index.post.ts creates the user, or syncs the user with number_of_rides when the request sets it for a rider. A sync replaces the user, so the handler calls users.create() for every other change and treats HTTP 409 as "the user exists". That way a role change doesn't wipe a rider's number_of_rides. The DELETE /users handler deletes the user with permit.api.users.delete().
if (noOfRides === undefined) {
// Permit write: creates the user; a 409 means the user exists already.
await permitWriteOnce(`create user ${user}`, () => api.users.create({ key: user }), 409);
} else {
await permitWrite(`sync user ${user}`, () =>
// Permit write: creates or replaces the user with the number_of_rides attribute the
// experienced_riders user set reads (ABAC).
api.users.sync({ key: user, attributes: { number_of_rides: noOfRides } }),
);
}
The app also syncs resource instances and role assignments:
| SDK namespace | What it manages |
|---|---|
permit.api.resourceInstances | Individual resource instances, such as one meal or one order |
permit.api.roleAssignments | Role assignments, both tenant-level (RBAC) and resource instance-level (ReBAC) |
Resource instances and role assignments belong to a tenant. Pass the tenant key, or "default" if your application is not multi-tenant. Setup creates the tenants, resources, roles, and condition sets with permit.api.tenants, permit.api.resources, permit.api.roles, permit.api.resourceRoles, permit.api.conditionSets, and permit.api.conditionSetRules. See the Condition Sets API.
When a customer creates an order, the app creates the order as a resource instance in the caller's city, and assigns the Order#Vendor resource role on that order to the vendor of its meals:
const register = () => api.resourceInstances.create({ key, resource: 'Order', tenant });
// ...
await permitWrite(`make ${vendor} the vendor of order ${key}`, () =>
// Permit write: gives the meals' vendor Order#Vendor on this order, so they may fulfill it
// (ReBAC).
permit().api.roleAssignments.assign({
user: vendor,
role: 'Vendor',
resource_instance: `Order:${key}`,
tenant,
}),
);
The app doesn't store the order's cost on the instance, and passes it with the two checks that need it instead. A check with an order's key also matches the ABAC rules against the attributes Permit stores for that order. With cost stored on free delivery orders, the keyed deliver check would let any rider with 500 or more rides deliver any free order, assigned or not.
When the app drops an old meal or order, it deletes the instance, and the instance role assignments on it, with permit.api.resourceInstances.delete(). For more sync options, see Sync users.
Enforce permissions in the server
The route handlers call permit.check() through two helpers in server/utils/api.ts. isAllowed() asks the PDP in the caller's city, and authorize() answers a deny with HTTP 403. One permit.check() call evaluates every RBAC, ABAC, and ReBAC rule that applies to the action.
export async function authorize(
caller: Caller,
action: string,
resource: CityResource,
): Promise<void> {
if (!(await isAllowed(caller, action, resource))) {
throw createError({ statusCode: 403, message: 'You are not permitted to perform this action' });
}
}
The deliver route checks the order instance, and for a free delivery order also the order's cost:
// Permit check: the order's own rider (Order#Rider, ReBAC) or an admin (RBAC) may deliver it.
await authorize(caller, 'deliver', { type: 'Order', key: String(order.id) });
if (order.deliveryFee === 0) {
// Permit check: a free-delivery order also needs a rider with 500 or more rides (ABAC: the
// experienced_riders user set on the orders_above_500 resource set), or an admin. The cost goes
// with the check, without the order's key, so only roles and attributes decide it.
await authorize(caller, 'deliver', { type: 'Order', attributes: { cost: order.totalPrice } });
}
The order route decides the delivery fee with the same pattern. A denied create-with-free-delivery check isn't an error, so the route calls isAllowed():
const freeDelivery = await isAllowed(caller, 'create-with-free-delivery', {
type: 'Order',
attributes: { cost: totalPrice },
});
const deliveryFee = freeDelivery ? 0 : DELIVERY_FEE;
Frontend checks in a separate app
The nuxt-food-delivery app enforces permissions only on the server. For a frontend example, the permit-vue-example repository on the casl branch builds a food delivery UI with permit-fe-sdk and CASL. It is a separate app with its own backend and an RBAC-only policy, in which vendors fulfill orders and riders deliver them through their tenant roles.
In that app, src/stores/abilities.ts creates the permit-fe-sdk client with a backendUrl and calls loadLocalStateBulk() once with every action and resource the UI checks: create, read, and delete on Meal, and create, read, fulfill, assign-rider, and deliver on Order. Components then read the stored results with permit.value.check(action, resource, resourceAttributes), which returns a boolean without calling the backend again.
The deliver handler in src/components/OrdersDisplay.vue checks before it sends the request:
const deliver = async (orderId: number) => {
if (permit.value.check('deliver', 'Order', {})) {
isDelivering.value = true;
const success = await orders.deliver(orderId);
isDelivering.value = false;
if (success) {
toast.add({
severity: 'success',
summary: 'Order Delivered',
life: 3000
});
}
}
};
The same check hides the button, so a rider who cannot deliver the order never sees it:
<Button
type="submit"
label="Deliver"
v-if="!order.deliveredTime && permit.check('deliver', 'Order', {})"
@click="deliver(order.id)"
:loading="isDelivering"
/>
The Fulfill button and the assign rider form in the same component follow this pattern with the fulfill and assign-rider actions. Read src/components/OrdersDisplay.vue for the complete component.
permit.check() in the browser reads a result the backend already returned, and anyone can call your API directly. Keep the server check on every endpoint. A UI-only check leaves the endpoint open.
Test the full policy
With the Compose stack running, open http://localhost:3000 and test each rule. Use the sidebar's Manage dialog to switch the current user and city. Each row lists the expected result:
| Test | Expected result | Model that decides |
|---|---|---|
As customer1 in California, order the Party pizza box (520) | The order's Delivery Fee row reads FREE. | RBAC and ABAC |
As customer1, order a Margherita pizza (120) | The Delivery Fee row shows 50. | ABAC |
As vendor2, fulfill a pizza order | "You are not permitted to perform this action". | ReBAC |
As vendor1, fulfill the pizza order | The order's Fulfilled Time fills in. | ReBAC |
As admin1, assign rider1 to the paid order and to the free order | Each order's Rider and Rider Assigned Time fill in. | RBAC |
As rider1, deliver the paid order | The order's Delivered Time fills in. | ReBAC |
As rider1 (120 rides), deliver the free order | "You are not permitted to perform this action". | ABAC |
As rider2, deliver an order assigned to rider1 | "You are not permitted to perform this action". | ReBAC |
As customer1, order another Party pizza box. As admin1, assign rider2 (650 rides) to it, then deliver it as rider2. | The order's Delivered Time fills in. | ReBAC and ABAC |
To test tenant isolation, switch customer1 to Washington. The menu doesn't load, because the customer role assignment belongs to the california tenant.
Verify: every row above matches. When a row does not, open the Audit Log screen in Permit and select the entry for that check. The entry shows the user, action, resource, and tenant, and its decision log carries a human-readable reason for the result.
Next steps
- Read the nuxt-food-delivery source to see the checks in each route handler.
- Build ReBAC policies for other relationship patterns.
- Check permissions with permit.check() for all check options.
- Ask questions and share what you build in the Permit Slack community.
