Skip to main content

Roles and permissions

Lucille Order Center has exactly two roles. There is no third role, no per-restaurant admin, and no read-only auditor role.

RolePopulationReach
chefOne or more per restaurantThe restaurants they are assigned to
adminA single global operations accountEvery restaurant, plus everything a chef can do (see next)

Roles are Zitadel project roles asserted into the access token. The API reads the role from the token on every request and mirrors it onto the user row, so granting or revoking admin in Zitadel takes effect on the caller's next request rather than when their token expires. There is no role-management screen in the application. The claim shape and the two Zitadel console toggles that must both be enabled are described in authentication.

The three guards

Authorisation is not global middleware. Each route declares its own guard as a preHandler, so an unguarded route is visible where the route is defined, and a coverage test fails the build if a route outside a short public allowlist has no guard.

GuardAcceptsRejects with
requireAuthAny authenticated chef or admin401 when the token is unusable
requireChefchef or admin403 for a role that is neither
requireAdminadmin only403 for a chef

An administrator satisfies the chef guard. This is deliberate and it is the single most frequently misread part of the model: an admin can browse the catalog, build a cart and place an order, because requireChef accepts chef or admin. The SPA works the same way — the chef area sits behind a chef requirement that an admin passes, and the five admin screens nest a second admin requirement inside it. What an admin cannot do is be assigned to a restaurant: admin access is global and is deliberately not expressed as user_restaurants rows, so assigning a chef record that turns out to be an admin is refused with 422.

What each role can do

Capabilitychefadmin
Browse the unified catalogYesYes
See which supplier fulfils a catalog itemNoYes
Place an orderAssigned restaurants onlyYes, for any active restaurant
View ordersAssigned restaurants onlyAll restaurants
View an order's audit trailFor orders they can seeAll orders, unredacted
Read the global audit feedNoYes
Create, edit, deactivate catalog itemsNoYes
Create, edit, deactivate suppliers and their configNoYes
Create, edit, delete SKU-to-supplier mappingsNoYes
Create and edit restaurantsNoYes
Assign or unassign a chef to a restaurantNoYes
Manually reroute a line itemNoYes
Post an inbound Sysco back-order notificationNoYes (system-to-system)

Chefs never see supplier identity

This is enforced at the serialisation boundary, not in the UI, so a chef-role token cannot obtain supplier identity by calling the API directly:

  • the chef catalog projection selects only id, name, category, unit, unitSize and description — there is no supplier column to omit;
  • on an order's line items, supplierName is populated for admins and explicitly null for chefs;
  • on a supplier dispatch, supplierName and supplierType are absent from a chef payload entirely (the opaque supplierId UUID remains, because it is part of the shared contract);
  • audit event payloads are copied with supplierId, supplierType, supplierName, originalSupplierId, alternateSupplierId and altSupplierId stripped out, because those free-form JSONB payloads would otherwise be a back door around the rule.

One residual leak is worth knowing about: a dispatch's archived purchase-order PDF link is present on chef payloads, and the presign endpoint that opens it is guarded with requireChef, so a chef who opens that PDF sees a document addressed to Central Kitchen. Nothing in the UI labels it with a supplier name, but the document itself is not redacted.

Multi-restaurant scoping

A chef's restaurant assignments live in the user_restaurants join table and are read from the database on every request, keyed on the token's subject claim. They are never taken from the token, for two reasons:

  1. the Zitadel role claim carries organisation metadata, not restaurant identifiers — there is nothing in the token that could be read as a restaurant id;
  2. revoking an assignment then takes effect on the very next call rather than waiting for a token to expire.

Every query issued on behalf of a chef is scoped by restaurant_id IN (their assigned ids). For an admin the scope resolver returns "no filter at all" — which is why an empty assignment list must never be read as "no access" for that role. Row-level security is deliberately not used: at this scale and trust model, application-level scoping backed by an RBAC test matrix is the proportionate choice.

Status codes for authorisation failures

SituationStatus
No bearer token, or an expired, malformed or wrongly-audienced one401
A verified token that carries neither the chef nor the admin project role401
A valid token whose role does not permit the endpoint403
A restaurantId filter naming a restaurant the caller is not assigned to403
Placing an order against a restaurant the caller is not assigned to403
Fetching an order by id that belongs to another restaurant404

The two middle-to-bottom rows are the interesting pair. A token that verifies but carries no usable role is a 401, not a 403, because in practice it means one of Zitadel's role-assertion toggles is off — the request is unauthenticated as far as this application is concerned, and there is no role to refuse. Passing an unassigned restaurantId as a filter is a 403, because that is a mistake and answering "no orders" would hide the permission problem. Asking for another restaurant's order by id is a 404, because a 403 would confirm the id exists and let a chef probe the shape of other locations' business.

Route-to-role: the SPA

RouteRequirementScreen
/loginnoneSign-in button that starts the OIDC flow
/auth/callbacknoneConsumes the access token from the URL fragment
/403none"Not allowed here" page
/chefDashboard: current restaurant, cart, recent orders
/catalogchefBrowse the catalog, build the cart
/orders/newchefReview and submit the cart
/orderschefOrder history
/orders/:idchefOrder detail, dispatches and audit trail
/admin/catalogadminCatalog items and their mappings
/admin/suppliersadminSupplier records and connection config
/admin/mappingsadminSKU-to-supplier mappings
/admin/ordersadminAll orders, plus the global audit feed
/admin/restaurantsadminLocations and chef assignment

A visitor with no session is sent to /login, and the path they were attempting is remembered across the full-page identity-provider round trip. A signed-in chef who reaches an /admin/... URL is sent to /403 — not silently redirected to the dashboard — and the Administration group is not rendered in the sidebar for them at all. Both are conveniences: the control is the API.

Route-to-role: the API

Method and pathGuardScoping
GET /healthz, GET /readyznoneCluster-internal
GET /metricsnoneCluster-internal; only registered when metrics are enabled
GET /api/auth/login, GET /api/auth/callbacknoneThe login handshake itself
POST /api/auth/refresh, POST /api/auth/logoutnone (cookie)Self
GET /api/auth/merequireAuthSelf; an admin is listed against every active location
GET /api/catalog, GET /api/catalog/categoriesrequireChefOrderable items only, no supplier fields
POST /api/ordersrequireChefRestaurant must be accessible to the caller
GET /api/ordersrequireChefAssigned restaurants; unfiltered for admin
GET /api/orders/:idrequireChef404 outside the caller's scope
GET /api/orders/:id/dispatches/:dispatchId/pdfrequireChefPresigned PDF link, same scoping
POST /api/orders/:id/backorderrequireAdminInbound acknowledgment hook
POST /api/orders/:id/line-items/:lineItemId/rerouterequireAdminManual reroute
GET, POST /api/admin/catalogrequireAdminGlobal
GET, PUT, DELETE /api/admin/catalog/:idrequireAdminGlobal; DELETE is a soft delete
GET, POST /api/admin/suppliersrequireAdminGlobal
GET, PUT, DELETE /api/admin/suppliers/:idrequireAdminGlobal; DELETE deactivates
GET, POST /api/admin/mappingsrequireAdminGlobal
GET, PUT, DELETE /api/admin/mappings/:idrequireAdminGlobal; DELETE deactivates
GET, POST /api/admin/restaurantsrequireAdminGlobal
GET, PUT /api/admin/restaurants/:idrequireAdminGlobal
POST /api/admin/restaurants/:id/chefsrequireAdminGlobal
DELETE /api/admin/restaurants/:id/chefs/:userIdrequireAdminGlobal
GET /api/admin/orders, GET /api/admin/orders/:idrequireAdminAll restaurants, supplier identity included
GET /api/admin/orders/auditrequireAdminThe global audit feed
POST /api/inbound/syscorequireAdminSystem-to-system, not a browser call

Full request and response schemas are in the API reference.

Where to go next