Skip to main content

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​

LayerWhat the app uses
FrontendNuxt with Vue and PrimeVue, one page per role (customer, rider, vendor, admin)
BackendNuxt server routes whose handlers call permit.check() before each protected action
AuthorizationRBAC for role permissions, ABAC for the free delivery offer, ReBAC for order assignments, and cities as tenants
Data syncServer routes that write users, role assignments, and meal and order instances to Permit when they change
Data storeA JSON file that stands in for a database

How the app uses each policy model​

ModelWhat it controls in the app
RBACWhich roles can read, create, and delete meals and orders, and which role assigns riders
ABACFree delivery: orders that cost 500 or more, and riders with 500 or more rides
ReBACOrder 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 admin role grants deliver on every order in the city (RBAC).
  • The Order#Rider resource role grants deliver on one order. The app assigns it when an admin assigns the rider (ReBAC).
  • A user set of riders with 500 or more rides grants deliver on 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 Meal instance.
  • A customer creates an order, which becomes an Order instance, and the vendor of its meals gets Order#Vendor on it.
  • An admin assigns a rider, who gets the rider role in the city and Order#Rider on 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.

MethodEndpointActionAuthorization
GET/mealsreadRBAC
POST/mealscreateRBAC
DELETE/meal/:iddeleteRBAC
GET/ordersreadRBAC
POST/orderscreate, create-with-free-deliveryRBAC & ABAC
POST/order/:id/fulfillfulfillReBAC (vendor), RBAC (admin)
POST/order/:id/assign-riderassign-riderRBAC
POST/order/:id/deliverdeliverReBAC (rider), RBAC (admin), and ABAC for free delivery orders
POST/usersNoneNone
DELETE/usersNoneNone

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.

The user switcher routes have no check

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:

UserRole and cityRides
customer1customer in California
customer2customer in Washington
vendor1vendor in California (pizza)
vendor2vendor in California (sushi)
vendor3vendor in Washington (seafood)
rider1rider in California120
rider2rider in California650
admin1admin in California and Washington

Food delivery app with a sidebar for the current user and city, the customer page with the Party pizza box in the cart and free delivery, and the California menu

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 vendor and admin can create, update, and delete meals.
  • All roles (customer, rider, vendor, and admin) can read meals.

Orders:

  • customer and admin can create orders.
  • Only admin can assign riders to orders.
  • admin can fulfill and deliver any order in the city. Vendors and riders get fulfill and deliver only 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:

1

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.

ResourceAction keysWhere the app checks them
Mealcreate, read, update, deleteserver/routes/meals/ and server/routes/meal/[id]/. The app doesn't check update.
Ordercreate, read, fulfill, assign-rider, deliverserver/routes/orders/ and server/routes/order/[id]/

Add the create-with-free-delivery action on Order in Configure ABAC for free delivery.

Creating the Meal and Order resources and their actions on the Resources tab

2

Create the four roles​

On the Roles tab, create the customer, rider, vendor, and admin roles.

Roles tab listing the customer, rider, vendor, and admin roles

3

Grant RBAC permissions in the Policy Editor​

In the Policy Editor, check these actions for each role:

RoleMealOrder
customerreadread, create
vendorread, create, update, deleteread
riderreadread
adminread, create, update, deleteread, 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 customer or an admin can create an order with free delivery if the order's cost is 500 or more.
  • A rider can deliver a free delivery order if the rider's number_of_rides is 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:

1

Add the cost attribute and the free delivery action​

  1. Open the Order resource for editing.
  2. Add a create-with-free-delivery action. The app checks this action when it decides whether to issue free delivery.
  3. In the ABAC section, add a cost attribute of type Number.
  4. Save the changes.

Editing the Order resource to add the create-with-free-delivery action and a cost attribute of type Number, then creating a resource set for orders that cost 500 or more

2

Create a resource set for orders that cost 500 or more​

  1. On the ABAC Rules tab, create a resource set for Order. Setup uses the key orders_above_500.
  2. Add the condition cost greater than or equal to 500.

Every order whose cost matches the condition belongs to the resource set.

3

Add the number_of_rides user attribute​

In the user attributes settings, add a number_of_rides attribute of type Number.

Adding a number_of_rides user attribute of type Number

4

Create a user set for riders with 500 or more rides​

  1. On the ABAC Rules tab, create a user set. Setup uses the key experienced_riders.
  2. Add the condition number_of_rides greater than or equal to 500.

Every user whose number_of_rides matches the condition belongs to the user set.

5

Grant ABAC permissions in the Policy Editor​

The user set and the resource set appear in the Policy Editor.

  1. For the customer and admin roles, check create-with-free-delivery on the order resource set.
  2. For the rider user set, check deliver on 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:

1

Create the Vendor and Rider resource roles​

  1. Open the Order resource for editing.
  2. In the ReBAC section, create two resource roles with the keys Vendor and Rider. The Policy Editor shows them as Order#Vendor and Order#Rider. The app assigns Vendor in server/routes/orders/index.post.ts and Rider in server/routes/order/[id]/assign-rider.post.ts, both by that exact key.
2

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().

server/routes/users/index.post.ts
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 namespaceWhat it manages
permit.api.resourceInstancesIndividual resource instances, such as one meal or one order
permit.api.roleAssignmentsRole 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:

server/routes/orders/index.post.ts
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.

server/utils/api.ts
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:

server/routes/order/[id]/deliver.post.ts
// 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():

server/routes/orders/index.post.ts
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:

src/components/OrdersDisplay.vue (script setup): the deliver handler
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:

src/components/OrdersDisplay.vue (template): the Deliver button
<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.

Frontend checks hide controls, they do not enforce

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:

TestExpected resultModel 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 orderThe order's Fulfilled Time fills in.ReBAC
As admin1, assign rider1 to the paid order and to the free orderEach order's Rider and Rider Assigned Time fill in.RBAC
As rider1, deliver the paid orderThe 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​