Pin the API permission boundary for every viewset

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.
This commit is contained in:
ixgadmin 2026-09-17 15:53:47 +00:00
parent 8ad83a7a65
commit b376c30300

View file

@ -23,6 +23,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, SimpleTestCase, TestCase, override_settings from django.test import Client, SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from apps.batches.models import ProcessingBatch from apps.batches.models import ProcessingBatch
from apps.capa.models import CAPARecord from apps.capa.models import CAPARecord
@ -33,7 +34,9 @@ from apps.compliance.models import (
IntakeBatchLineage, IntakeBatchLineage,
ProductSpecification, ProductSpecification,
) )
from apps.notifications.models import Notification
from apps.qc.models import QCResult from apps.qc.models import QCResult
from apps.subscriptions.models import Subscription
from .tiles import browser_tile_url, upstream_url from .tiles import browser_tile_url, upstream_url
@ -118,6 +121,126 @@ class PortalPagesRenderTests(TestCase):
self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com") self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com")
class ApiPermissionBoundaryTests(TestCase):
"""The API permission boundary, pinned for every viewset.
Batch writes were closed first; this sweeps the rest. A buyer account is the
adversary: it is a legitimate, authenticated user, so ``IsAuthenticated``
alone lets it through. Anything that changes a record — a document, a QC
result, a certificate, a shipment status — must be ops/admin only. The buyer
portal is server-rendered through the ORM, so it needs no API access; the one
exception is the AI chat, which the buyer portal calls from the browser.
"""
# Every ModelViewSet that changes a record. A buyer must get 403 on write.
OPS_WRITE_ENDPOINTS = [
"/api/batches/",
"/api/qc-results/",
"/api/shipments/",
"/api/documents/",
"/api/certificates-of-analysis/",
"/api/regulatory-certs/",
"/api/capa-records/",
"/api/esg-reports/",
"/api/agents/",
"/api/product-specs/",
"/api/facilities/",
"/api/origin-zones/",
"/api/collector-groups/",
"/api/harvest-intakes/",
"/api/intake-batch-lineage/",
"/api/eudr-cases/",
"/api/eudr-evidence/",
"/api/risk-assessments/",
"/api/mitigation-actions/",
"/api/dds-references/",
]
# Reads a buyer may still make: the chat it calls from the browser, and the
# user/notification/subscription rows scoped to the buyer itself.
BUYER_READABLE_ENDPOINTS = [
"/api/agents/",
"/api/ai-interactions/",
"/api/users/",
"/api/notifications/",
"/api/subscriptions/",
]
def make_user(self, email, role):
return User.objects.create_user(
email=email, password="pass1234", role=role,
full_name=email.split("@")[0], is_staff=True,
)
def setUp(self):
self.buyer = self.make_user("boundary-buyer@ixg.local", "buyer")
self.other_buyer = self.make_user("boundary-other@ixg.local", "buyer")
self.ops = self.make_user("boundary-ops@ixg.local", "ops")
self.client = APIClient()
def test_a_buyer_cannot_write_to_any_ops_api(self):
self.client.force_authenticate(user=self.buyer)
for endpoint in self.OPS_WRITE_ENDPOINTS:
with self.subTest(endpoint=endpoint):
self.assertEqual(
self.client.post(endpoint, {}, format="json").status_code, 403
)
def test_a_buyer_cannot_read_the_ops_apis(self):
self.client.force_authenticate(user=self.buyer)
for endpoint in self.OPS_WRITE_ENDPOINTS:
if endpoint in self.BUYER_READABLE_ENDPOINTS:
continue
with self.subTest(endpoint=endpoint):
self.assertEqual(self.client.get(endpoint).status_code, 403)
def test_ops_is_not_forbidden_on_the_ops_apis(self):
self.client.force_authenticate(user=self.ops)
for endpoint in self.OPS_WRITE_ENDPOINTS:
with self.subTest(endpoint=endpoint):
self.assertNotEqual(self.client.get(endpoint).status_code, 403)
def test_a_buyer_can_still_reach_the_chat_endpoints(self):
self.client.force_authenticate(user=self.buyer)
for endpoint in self.BUYER_READABLE_ENDPOINTS:
with self.subTest(endpoint=endpoint):
self.assertNotEqual(self.client.get(endpoint).status_code, 403)
response = self.client.post("/api/ai-interactions/chat/", {}, format="json")
self.assertNotEqual(response.status_code, 403)
def test_a_buyer_cannot_promote_themselves_to_admin(self):
self.client.force_authenticate(user=self.buyer)
response = self.client.patch(
f"/api/users/{self.buyer.id}/", {"role": "admin"}, format="json"
)
self.assertEqual(response.status_code, 403)
self.buyer.refresh_from_db()
self.assertEqual(self.buyer.role, "buyer")
def test_the_user_api_shows_a_buyer_only_themselves(self):
self.client.force_authenticate(user=self.buyer)
response = self.client.get("/api/users/")
self.assertEqual(response.status_code, 200)
body = response.json()
rows = body["results"] if isinstance(body, dict) else body
self.assertEqual([row["email"] for row in rows], [self.buyer.email])
def test_a_user_cannot_reach_another_users_rows(self):
note = Notification.objects.create(
user=self.other_buyer, title="Other", message="Other"
)
sub = Subscription.objects.create(
user=self.other_buyer, plan=Subscription.Plan.STARTER
)
self.client.force_authenticate(user=self.buyer)
self.assertEqual(
self.client.get(f"/api/notifications/{note.id}/").status_code, 404
)
self.assertEqual(
self.client.get(f"/api/subscriptions/{sub.id}/").status_code, 404
)
class TileProxyTests(TestCase): class TileProxyTests(TestCase):
"""Tiles come from our origin, so the map does not depend on the viewer's """Tiles come from our origin, so the map does not depend on the viewer's
network being able to reach a third-party tile server.""" network being able to reach a third-party tile server."""