Restrict every data API to ops and admin

Batch writes were closed in 88eede5, but every other app's viewset still used
IsAuthenticated, which in this platform means any buyer. A buyer could rewrite a
document record, create a QC result (which moves the batch status and raises a
CAPA), and reach the write endpoints for CAPAs, certificates, ESG reports and
shipments.

- Use the one IsOpsOrAdmin from apps.compliance.permissions; drop the duplicate
  copy in apps/qc/views.py.
- Apply it to the whole viewset on shipments, qc, capa, esg, documents,
  certificates-of-analysis and regulatory-certs. The buyer portal is
  server-rendered through the ORM, so it needs none of these APIs.
- Keep the buyer chat working: AI interactions stay authenticated and scoped,
  and agent reads stay open while agent writes become ops/admin.
- Close a buyer-to-admin escalation in the users API: writes are admin-only and
  role/is_active are read-only, so a buyer can no longer PATCH their own row to
  role=admin and then pass every IsOpsOrAdmin check.
This commit is contained in:
ixgadmin 2026-09-17 15:53:44 +00:00
parent fa9295d15a
commit 8ad83a7a65
8 changed files with 78 additions and 41 deletions

View file

@ -5,6 +5,8 @@ from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.compliance.permissions import IsOpsOrAdmin
from .models import AIInteraction, AgentConfig
from .serializers import (
AIInteractionSerializer, ChatRequestSerializer,
@ -100,8 +102,17 @@ class AIInteractionViewSet(viewsets.ReadOnlyModelViewSet):
class AgentConfigViewSet(viewsets.ModelViewSet):
queryset = AgentConfig.objects.all()
# The buyer chat reads the agent it talks to — templates/portal/chat_embed.html
# fetches /api/agents/<id>/ — so reads stay open to an authenticated user,
# scoped by get_queryset to their own or the shared agents. Only ops/admin
# may create or change one.
permission_classes = [permissions.IsAuthenticated]
def get_permissions(self):
if self.action in ("create", "update", "partial_update", "destroy"):
return [IsOpsOrAdmin()]
return super().get_permissions()
def get_serializer_class(self):
if self.request.user.role in ("admin", "ops") or self.request.user.is_superuser:
return AgentConfigWriteSerializer

View file

@ -1,4 +1,7 @@
from rest_framework import viewsets, permissions
from rest_framework import viewsets
from apps.compliance.permissions import IsOpsOrAdmin
from .models import CAPARecord
from .serializers import CAPARecordSerializer
@ -6,4 +9,7 @@ from .serializers import CAPARecordSerializer
class CAPARecordViewSet(viewsets.ModelViewSet):
queryset = CAPARecord.objects.all()
serializer_class = CAPARecordSerializer
permission_classes = [permissions.IsAuthenticated]
# Corrective actions are an internal ops record — the buyer portal does not
# read them at all, through the ORM or this API — so both read and write are
# closed to a buyer.
permission_classes = [IsOpsOrAdmin]

View file

@ -1,10 +1,13 @@
import os
import uuid
from django.conf import settings
from rest_framework import viewsets, permissions, status
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from apps.compliance.permissions import IsOpsOrAdmin
from .models import Document, CertificateOfAnalysis, RegulatoryCert
from .serializers import DocumentSerializer, DocumentUploadSerializer, CertificateOfAnalysisSerializer, RegulatoryCertSerializer
@ -12,14 +15,10 @@ from .serializers import DocumentSerializer, DocumentUploadSerializer, Certifica
class DocumentViewSet(viewsets.ModelViewSet):
queryset = Document.objects.all()
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
qs = Document.objects.all()
if user.role == "buyer":
return qs.filter(visibility__in=["buyer"])
return qs
# Documents and certificates are the evidence a buyer's auditor relies on.
# The buyer portal serves the buyer-visible rows through the ORM
# (apps/buyer/views.py), so this API needs no buyer access — read or write.
permission_classes = [IsOpsOrAdmin]
@action(detail=False, methods=["post"], parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
@ -64,23 +63,16 @@ class DocumentViewSet(viewsets.ModelViewSet):
class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
queryset = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
serializer_class = CertificateOfAnalysisSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
qs = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
if user.role == "buyer":
return qs.filter(status=CertificateOfAnalysis.Status.PUBLISHED)
return qs
# A certificate is released to buyers through the ORM (apps/buyer/views.py),
# not this API, so the API needs no buyer access. Only a published
# certificate is buyer-visible at all, and only ops/admin may publish one.
permission_classes = [IsOpsOrAdmin]
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
"""Commercial release approval: publishes the CoA and exposes it to buyers."""
from apps.compliance.services import publish_coa
from apps.compliance.permissions import IsOpsOrAdmin
if not IsOpsOrAdmin().has_permission(request, self):
return Response({"detail": "Not permitted"}, status=status.HTTP_403_FORBIDDEN)
coa = self.get_object()
if coa.status != CertificateOfAnalysis.Status.PENDING_APPROVAL:
return Response({"detail": "CoA is not pending approval"}, status=status.HTTP_400_BAD_REQUEST)
@ -89,11 +81,8 @@ class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def supersede(self, request, pk=None):
from apps.compliance.permissions import IsOpsOrAdmin
from django.utils import timezone
if not IsOpsOrAdmin().has_permission(request, self):
return Response({"detail": "Not permitted"}, status=status.HTTP_403_FORBIDDEN)
coa = self.get_object()
replacement_id = request.data.get("replacement_id")
if not replacement_id:
@ -111,4 +100,6 @@ class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
class RegulatoryCertViewSet(viewsets.ModelViewSet):
queryset = RegulatoryCert.objects.all()
serializer_class = RegulatoryCertSerializer
permission_classes = [permissions.IsAuthenticated]
# Regulatory certificates are an internal compliance record; the buyer portal
# does not read them, so the API is ops/admin only.
permission_classes = [IsOpsOrAdmin]

View file

@ -1,4 +1,7 @@
from rest_framework import viewsets, permissions
from rest_framework import viewsets
from apps.compliance.permissions import IsOpsOrAdmin
from .models import ESGFieldReport
from .serializers import ESGFieldReportSerializer
@ -6,4 +9,7 @@ from .serializers import ESGFieldReportSerializer
class ESGFieldReportViewSet(viewsets.ModelViewSet):
queryset = ESGFieldReport.objects.all()
serializer_class = ESGFieldReportSerializer
permission_classes = [permissions.IsAuthenticated]
# The buyer portal reads ESG reports through the ORM
# (apps/buyer/views.py::BatchDetailView), not this API, so the API needs no
# buyer read and is closed to one.
permission_classes = [IsOpsOrAdmin]

View file

@ -1,22 +1,22 @@
from django.utils import timezone
from rest_framework import viewsets, permissions, status
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import QCResult
from .serializers import QCResultSerializer, QCResultCreateSerializer
from apps.compliance.permissions import IsOpsOrAdmin
from apps.compliance.services import create_coa_for_qc
class IsOpsOrAdmin(permissions.BasePermission):
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and (user.role in ("ops", "admin") or user.is_superuser))
from .models import QCResult
from .serializers import QCResultSerializer, QCResultCreateSerializer
class QCResultViewSet(viewsets.ModelViewSet):
queryset = QCResult.objects.all()
permission_classes = [permissions.IsAuthenticated]
# Recording a QC result moves the batch's status, quarantines a failure and
# can raise a CAPA, so the whole viewset — read and write — is ops/admin
# only. The buyer portal reads QC results through the ORM
# (apps/buyer/views.py), not this API.
permission_classes = [IsOpsOrAdmin]
def get_serializer_class(self):
if self.action == "create":
@ -26,7 +26,7 @@ class QCResultViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save()
@action(detail=True, methods=["post"], permission_classes=[IsOpsOrAdmin])
@action(detail=True, methods=["post"])
def verify(self, request, pk=None):
"""Four-eyes quality approval. A separate authorised user validates the
entered result. The submitter cannot approve their own result."""

View file

@ -1,4 +1,7 @@
from rest_framework import viewsets, permissions
from rest_framework import viewsets
from apps.compliance.permissions import IsOpsOrAdmin
from .models import Shipment
from .serializers import ShipmentSerializer
@ -6,4 +9,8 @@ from .serializers import ShipmentSerializer
class ShipmentViewSet(viewsets.ModelViewSet):
queryset = Shipment.objects.all()
serializer_class = ShipmentSerializer
permission_classes = [permissions.IsAuthenticated]
# The buyer portal reads shipments through the ORM (apps/buyer/views.py), not
# this API, so nothing outside ops/admin needs it — including for reads. A
# buyer must not be able to create a shipment or rewrite the status its own
# page displays.
permission_classes = [IsOpsOrAdmin]

View file

@ -6,7 +6,10 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "email", "full_name", "company_name", "role", "is_active", "created_at"]
read_only_fields = ["id", "created_at"]
# Role and is_active are never set through this API: a writable role is
# how a buyer could promote themselves to admin. They are changed in the
# administration area instead.
read_only_fields = ["id", "created_at", "role", "is_active"]
class UserDetailSerializer(serializers.ModelSerializer):

View file

@ -1,4 +1,7 @@
from rest_framework import viewsets, mixins, permissions
from apps.compliance.permissions import IsAdmin
from .models import User
from .serializers import UserSerializer, UserDetailSerializer, UserCreateSerializer
@ -9,8 +12,18 @@ class UserViewSet(mixins.ListModelMixin,
viewsets.GenericViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
# A signed-in user may read their own record (get_queryset scopes a
# non-admin to it) but must not be able to write one. Without this, a buyer
# could PATCH their own row and set role to "admin", which would then pass
# every IsOpsOrAdmin check on the platform. Role and is_active are read-only
# in the serializer as well, so no API caller can change them.
permission_classes = [permissions.IsAuthenticated]
def get_permissions(self):
if self.action in ("create", "update", "partial_update", "destroy"):
return [IsAdmin()]
return super().get_permissions()
def get_serializer_class(self):
if self.action == "retrieve":
return UserDetailSerializer