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.
| Role | Population | Reach |
|---|---|---|
chef | One or more per restaurant | The restaurants they are assigned to |
admin | A single global operations account | Every 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.
| Guard | Accepts | Rejects with |
|---|---|---|
requireAuth | Any authenticated chef or admin | 401 when the token is unusable |
requireChef | chef or admin | 403 for a role that is neither |
requireAdmin | admin only | 403 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
| Capability | chef | admin |
|---|---|---|
| Browse the unified catalog | Yes | Yes |
| See which supplier fulfils a catalog item | No | Yes |
| Place an order | Assigned restaurants only | Yes, for any active restaurant |
| View orders | Assigned restaurants only | All restaurants |
| View an order's audit trail | For orders they can see | All orders, unredacted |
| Read the global audit feed | No | Yes |
| Create, edit, deactivate catalog items | No | Yes |
| Create, edit, deactivate suppliers and their config | No | Yes |
| Create, edit, delete SKU-to-supplier mappings | No | Yes |
| Create and edit restaurants | No | Yes |
| Assign or unassign a chef to a restaurant | No | Yes |
| Manually reroute a line item | No | Yes |
| Post an inbound Sysco back-order notification | No | Yes (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,unitSizeanddescription— there is no supplier column to omit; - on an order's line items,
supplierNameis populated for admins and explicitlynullfor chefs; - on a supplier dispatch,
supplierNameandsupplierTypeare absent from a chef payload entirely (the opaquesupplierIdUUID remains, because it is part of the shared contract); - audit event payloads are copied with
supplierId,supplierType,supplierName,originalSupplierId,alternateSupplierIdandaltSupplierIdstripped 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:
- the Zitadel role claim carries organisation metadata, not restaurant identifiers — there is nothing in the token that could be read as a restaurant id;
- 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
| Situation | Status |
|---|---|
| No bearer token, or an expired, malformed or wrongly-audienced one | 401 |
| A verified token that carries neither the chef nor the admin project role | 401 |
| A valid token whose role does not permit the endpoint | 403 |
A restaurantId filter naming a restaurant the caller is not assigned to | 403 |
| Placing an order against a restaurant the caller is not assigned to | 403 |
| Fetching an order by id that belongs to another restaurant | 404 |
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
| Route | Requirement | Screen |
|---|---|---|
/login | none | Sign-in button that starts the OIDC flow |
/auth/callback | none | Consumes the access token from the URL fragment |
/403 | none | "Not allowed here" page |
/ | chef | Dashboard: current restaurant, cart, recent orders |
/catalog | chef | Browse the catalog, build the cart |
/orders/new | chef | Review and submit the cart |
/orders | chef | Order history |
/orders/:id | chef | Order detail, dispatches and audit trail |
/admin/catalog | admin | Catalog items and their mappings |
/admin/suppliers | admin | Supplier records and connection config |
/admin/mappings | admin | SKU-to-supplier mappings |
/admin/orders | admin | All orders, plus the global audit feed |
/admin/restaurants | admin | Locations 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 path | Guard | Scoping |
|---|---|---|
GET /healthz, GET /readyz | none | Cluster-internal |
GET /metrics | none | Cluster-internal; only registered when metrics are enabled |
GET /api/auth/login, GET /api/auth/callback | none | The login handshake itself |
POST /api/auth/refresh, POST /api/auth/logout | none (cookie) | Self |
GET /api/auth/me | requireAuth | Self; an admin is listed against every active location |
GET /api/catalog, GET /api/catalog/categories | requireChef | Orderable items only, no supplier fields |
POST /api/orders | requireChef | Restaurant must be accessible to the caller |
GET /api/orders | requireChef | Assigned restaurants; unfiltered for admin |
GET /api/orders/:id | requireChef | 404 outside the caller's scope |
GET /api/orders/:id/dispatches/:dispatchId/pdf | requireChef | Presigned PDF link, same scoping |
POST /api/orders/:id/backorder | requireAdmin | Inbound acknowledgment hook |
POST /api/orders/:id/line-items/:lineItemId/reroute | requireAdmin | Manual reroute |
GET, POST /api/admin/catalog | requireAdmin | Global |
GET, PUT, DELETE /api/admin/catalog/:id | requireAdmin | Global; DELETE is a soft delete |
GET, POST /api/admin/suppliers | requireAdmin | Global |
GET, PUT, DELETE /api/admin/suppliers/:id | requireAdmin | Global; DELETE deactivates |
GET, POST /api/admin/mappings | requireAdmin | Global |
GET, PUT, DELETE /api/admin/mappings/:id | requireAdmin | Global; DELETE deactivates |
GET, POST /api/admin/restaurants | requireAdmin | Global |
GET, PUT /api/admin/restaurants/:id | requireAdmin | Global |
POST /api/admin/restaurants/:id/chefs | requireAdmin | Global |
DELETE /api/admin/restaurants/:id/chefs/:userId | requireAdmin | Global |
GET /api/admin/orders, GET /api/admin/orders/:id | requireAdmin | All restaurants, supplier identity included |
GET /api/admin/orders/audit | requireAdmin | The global audit feed |
POST /api/inbound/sysco | requireAdmin | System-to-system, not a browser call |
Full request and response schemas are in the API reference.
Where to go next
- Authentication — token flow, claim shape, validation.
- Administrator guide — what the admin role does in practice.
- Chef guide — what the chef role does in practice.