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:
parent
fa9295d15a
commit
8ad83a7a65
8 changed files with 78 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue