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.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from apps.compliance.permissions import IsOpsOrAdmin
from .models import AIInteraction, AgentConfig from .models import AIInteraction, AgentConfig
from .serializers import ( from .serializers import (
AIInteractionSerializer, ChatRequestSerializer, AIInteractionSerializer, ChatRequestSerializer,
@ -100,8 +102,17 @@ class AIInteractionViewSet(viewsets.ReadOnlyModelViewSet):
class AgentConfigViewSet(viewsets.ModelViewSet): class AgentConfigViewSet(viewsets.ModelViewSet):
queryset = AgentConfig.objects.all() 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] 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): def get_serializer_class(self):
if self.request.user.role in ("admin", "ops") or self.request.user.is_superuser: if self.request.user.role in ("admin", "ops") or self.request.user.is_superuser:
return AgentConfigWriteSerializer 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 .models import CAPARecord
from .serializers import CAPARecordSerializer from .serializers import CAPARecordSerializer
@ -6,4 +9,7 @@ from .serializers import CAPARecordSerializer
class CAPARecordViewSet(viewsets.ModelViewSet): class CAPARecordViewSet(viewsets.ModelViewSet):
queryset = CAPARecord.objects.all() queryset = CAPARecord.objects.all()
serializer_class = CAPARecordSerializer 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 os
import uuid import uuid
from django.conf import settings 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.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response from rest_framework.response import Response
from apps.compliance.permissions import IsOpsOrAdmin
from .models import Document, CertificateOfAnalysis, RegulatoryCert from .models import Document, CertificateOfAnalysis, RegulatoryCert
from .serializers import DocumentSerializer, DocumentUploadSerializer, CertificateOfAnalysisSerializer, RegulatoryCertSerializer from .serializers import DocumentSerializer, DocumentUploadSerializer, CertificateOfAnalysisSerializer, RegulatoryCertSerializer
@ -12,14 +15,10 @@ from .serializers import DocumentSerializer, DocumentUploadSerializer, Certifica
class DocumentViewSet(viewsets.ModelViewSet): class DocumentViewSet(viewsets.ModelViewSet):
queryset = Document.objects.all() queryset = Document.objects.all()
serializer_class = DocumentSerializer serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated] # Documents and certificates are the evidence a buyer's auditor relies on.
# The buyer portal serves the buyer-visible rows through the ORM
def get_queryset(self): # (apps/buyer/views.py), so this API needs no buyer access — read or write.
user = self.request.user permission_classes = [IsOpsOrAdmin]
qs = Document.objects.all()
if user.role == "buyer":
return qs.filter(visibility__in=["buyer"])
return qs
@action(detail=False, methods=["post"], parser_classes=[MultiPartParser, FormParser]) @action(detail=False, methods=["post"], parser_classes=[MultiPartParser, FormParser])
def upload(self, request): def upload(self, request):
@ -64,23 +63,16 @@ class DocumentViewSet(viewsets.ModelViewSet):
class CertificateOfAnalysisViewSet(viewsets.ModelViewSet): class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
queryset = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all() queryset = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
serializer_class = CertificateOfAnalysisSerializer serializer_class = CertificateOfAnalysisSerializer
permission_classes = [permissions.IsAuthenticated] # 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
def get_queryset(self): # certificate is buyer-visible at all, and only ops/admin may publish one.
user = self.request.user permission_classes = [IsOpsOrAdmin]
qs = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
if user.role == "buyer":
return qs.filter(status=CertificateOfAnalysis.Status.PUBLISHED)
return qs
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
def approve(self, request, pk=None): def approve(self, request, pk=None):
"""Commercial release approval: publishes the CoA and exposes it to buyers.""" """Commercial release approval: publishes the CoA and exposes it to buyers."""
from apps.compliance.services import publish_coa 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() coa = self.get_object()
if coa.status != CertificateOfAnalysis.Status.PENDING_APPROVAL: if coa.status != CertificateOfAnalysis.Status.PENDING_APPROVAL:
return Response({"detail": "CoA is not pending approval"}, status=status.HTTP_400_BAD_REQUEST) 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"]) @action(detail=True, methods=["post"])
def supersede(self, request, pk=None): def supersede(self, request, pk=None):
from apps.compliance.permissions import IsOpsOrAdmin
from django.utils import timezone 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() coa = self.get_object()
replacement_id = request.data.get("replacement_id") replacement_id = request.data.get("replacement_id")
if not replacement_id: if not replacement_id:
@ -111,4 +100,6 @@ class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
class RegulatoryCertViewSet(viewsets.ModelViewSet): class RegulatoryCertViewSet(viewsets.ModelViewSet):
queryset = RegulatoryCert.objects.all() queryset = RegulatoryCert.objects.all()
serializer_class = RegulatoryCertSerializer 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 .models import ESGFieldReport
from .serializers import ESGFieldReportSerializer from .serializers import ESGFieldReportSerializer
@ -6,4 +9,7 @@ from .serializers import ESGFieldReportSerializer
class ESGFieldReportViewSet(viewsets.ModelViewSet): class ESGFieldReportViewSet(viewsets.ModelViewSet):
queryset = ESGFieldReport.objects.all() queryset = ESGFieldReport.objects.all()
serializer_class = ESGFieldReportSerializer 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 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.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from .models import QCResult from apps.compliance.permissions import IsOpsOrAdmin
from .serializers import QCResultSerializer, QCResultCreateSerializer
from apps.compliance.services import create_coa_for_qc from apps.compliance.services import create_coa_for_qc
from .models import QCResult
class IsOpsOrAdmin(permissions.BasePermission): from .serializers import QCResultSerializer, QCResultCreateSerializer
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))
class QCResultViewSet(viewsets.ModelViewSet): class QCResultViewSet(viewsets.ModelViewSet):
queryset = QCResult.objects.all() 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): def get_serializer_class(self):
if self.action == "create": if self.action == "create":
@ -26,7 +26,7 @@ class QCResultViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save() serializer.save()
@action(detail=True, methods=["post"], permission_classes=[IsOpsOrAdmin]) @action(detail=True, methods=["post"])
def verify(self, request, pk=None): def verify(self, request, pk=None):
"""Four-eyes quality approval. A separate authorised user validates the """Four-eyes quality approval. A separate authorised user validates the
entered result. The submitter cannot approve their own result.""" 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 .models import Shipment
from .serializers import ShipmentSerializer from .serializers import ShipmentSerializer
@ -6,4 +9,8 @@ from .serializers import ShipmentSerializer
class ShipmentViewSet(viewsets.ModelViewSet): class ShipmentViewSet(viewsets.ModelViewSet):
queryset = Shipment.objects.all() queryset = Shipment.objects.all()
serializer_class = ShipmentSerializer 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: class Meta:
model = User model = User
fields = ["id", "email", "full_name", "company_name", "role", "is_active", "created_at"] 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): class UserDetailSerializer(serializers.ModelSerializer):

View file

@ -1,4 +1,7 @@
from rest_framework import viewsets, mixins, permissions from rest_framework import viewsets, mixins, permissions
from apps.compliance.permissions import IsAdmin
from .models import User from .models import User
from .serializers import UserSerializer, UserDetailSerializer, UserCreateSerializer from .serializers import UserSerializer, UserDetailSerializer, UserCreateSerializer
@ -9,8 +12,18 @@ class UserViewSet(mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer 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] 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): def get_serializer_class(self):
if self.action == "retrieve": if self.action == "retrieve":
return UserDetailSerializer return UserDetailSerializer