RBACMiddleware only guarded non-staff users, so any staff account stepped
past the role routing for /admin/ — an ops or buyer account with the flag
set could reach the admin index, which embeds the model registry, and a
later permission grant would have been inherited. Restrict /admin/ to the
admin role (superuser excepted) and hide the ops Admin link from non-admins.
Also close the second finding: the seed set is_staff only on creation, so
a buyer whose flag had drifted stayed staff on every re-run. Derive it from
the role on the update path too, and align existing rows with a one-off
migration so the flag never disagrees with the role.
A UK buyer can now create their own account at /portal/signup/. The screen
creates a Keycloak user through a least-privilege service account
(ixg-provisioning: manage-users, view-users) and the matching Django row as
a buyer, then sends them through the normal sign-in flow to the portal.
Staff status is derived from the role in the OIDC backend rather than
asserted, so a self-registered buyer is not staff and does not become staff
on a later sign-in. The platform root and the RBAC middleware now route
each role to its own portal, which also fixes a redirect loop that the
staff fix exposed for non-staff buyers.
Cover access on GET and POST for a buyer, each required field when blank, an ETD
after the ETA, a batch that already has a shipment (queried, not blocked), a
status change, and an edit updating in place. Add the shipment routes to both
OPS_ROUTES sweeps so the detail and edit screens render clean markup, and assert
the native date inputs carry ISO values — the defect that left the intake and
batch edit dates blank.
A buyer account is a legitimate authenticated user, so IsAuthenticated alone
lets it through. Sweep every ModelViewSet endpoint and assert a buyer gets 403
on write, and on read where the API is ops-only; assert ops is not forbidden;
and keep an explicit allowlist for the chat, user, notification and subscription
reads a buyer may still make. Also assert the escalation is closed: a buyer
cannot PATCH their own role to admin, sees only themselves in the user API, and
cannot reach another user's notification or subscription.
Replace the Bootstrap-prefix allowlist with a default-to-leak rule for bare
hyphenated tokens, parse tags quote-aware so a '>' inside an earlier attribute
cannot truncate the tag, render the batch edit and lineage screens in the sweep
with a seeded batch, and cover stale evidence in the seed re-run test.
The ops templates shipped with 346 of them: an unquoted attribute value ends at
the first whitespace, so the styles were never applied and status badges rendered
invisible. Assert it on the rendered HTML rather than the template source, because
a template may hold {{ }} inside an unquoted attribute that renders harmlessly —
the source is ambiguous, the response is not.
Harvest intakes could only be recorded in the administration area or through the
API, so the first record the business actually creates was entered outside the
portal it works in. Add list, create and correct screens under Harvest Intakes.
The form is deliberately stricter than the model. HarvestIntake allows almost
everything to be blank because records also arrive by API, and the platform
copes by flagging the gaps later — the EUDR completeness check has an intake
data test whose whole job is to catch a missing date or weight. The form
therefore requires every field those checks read: reference, collector group,
origin zone, date, weight and grade.
Three things it does beyond capturing the fields: it suggests the next intake
reference for the year (editable, since it may have to match a weighbridge
ticket), fills the origin zone in from the collector group while leaving it
editable, and refuses a duplicate reference — including one differing only in
case. It queries, rather than blocks, when the chosen zone is not the one the
group is registered to, and it marks unverified zones in the dropdown, because
geolocation passes only for a verified zone.
Correcting amends the record in place: the reference a buyer or auditor quotes
does not change.
The origin-zone map drew its background from a public tile server, which made it
the only part of the admin page that depended on a third-party domain — and the
part that breaks. Where a viewer's network, proxy or browser filter refuses
tile.openstreetmap.org (the refusal arrives as a 403) the map never draws, while
the page, the Leaflet bundle and the polygon data are all same-origin and fine.
Fetch the tiles server-side and serve them from /tiles/{z}/{x}/{y}.png: cached
on disk, so a page opened repeatedly does not re-fetch, and gated on being
signed in, so the route is not an open tile relay. The upstream stays
configurable via LEAFLET_TILE_URL, and LEAFLET_TILE_PROXY_ENABLED=False puts the
browser back on direct fetches.
When a tile still cannot be fetched, answer an empty tile rather than an error
page and say so under the map, instead of leaving a viewer with a blank grid and
no explanation.
Media was unreachable from either portal, which is why the certificate button on
the buyer compliance page returned page-not-found and no document row had
anything to click. The cause was structural: the portal and ops hosts proxy
location / to the app with a path prefix, so a stored /media/... URL arrives as
/portal/media/... and matches no route.
Add a download view per portal behind the same role gate as the page that links
to it — buyer-visible documents and published certificates for buyers, anything
the ops list shows for operations — resolving the stored path safely under
MEDIA_ROOT so a traversal or an absolute path is refused. Point the seven
templates that list files at the new routes.
Also add a portal-wide render sweep: nothing in the suite was fetching the ops
or portal pages, so a view could lose its template_name and 500 in front of a
user with every test still green.