From 8ad83a7a65b5ee1bfa76eea12cbdb8b59f1edf0a Mon Sep 17 00:00:00 2001 From: ixgadmin Date: Thu, 17 Sep 2026 15:53:44 +0000 Subject: [PATCH] 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. --- apps/ai/views.py | 11 +++++++++++ apps/capa/views.py | 10 ++++++++-- apps/documents/views.py | 39 +++++++++++++++------------------------ apps/esg/views.py | 10 ++++++++-- apps/qc/views.py | 20 ++++++++++---------- apps/shipments/views.py | 11 +++++++++-- apps/users/serializers.py | 5 ++++- apps/users/views.py | 13 +++++++++++++ 8 files changed, 78 insertions(+), 41 deletions(-) diff --git a/apps/ai/views.py b/apps/ai/views.py index e0a6a72..fea9b57 100644 --- a/apps/ai/views.py +++ b/apps/ai/views.py @@ -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// — 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 diff --git a/apps/capa/views.py b/apps/capa/views.py index 13d16e7..1d3bee8 100644 --- a/apps/capa/views.py +++ b/apps/capa/views.py @@ -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] diff --git a/apps/documents/views.py b/apps/documents/views.py index 5f8a510..1cffbd9 100644 --- a/apps/documents/views.py +++ b/apps/documents/views.py @@ -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] diff --git a/apps/esg/views.py b/apps/esg/views.py index 123380c..59e7c87 100644 --- a/apps/esg/views.py +++ b/apps/esg/views.py @@ -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] diff --git a/apps/qc/views.py b/apps/qc/views.py index 23ba1f0..686b723 100644 --- a/apps/qc/views.py +++ b/apps/qc/views.py @@ -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.""" diff --git a/apps/shipments/views.py b/apps/shipments/views.py index 850ace0..c483eeb 100644 --- a/apps/shipments/views.py +++ b/apps/shipments/views.py @@ -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] diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 5efdcd3..9063cff 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -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): diff --git a/apps/users/views.py b/apps/users/views.py index 5374ef6..85a8c0f 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -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