Skip to the content.

AgriOps — Tenant Model

Version: 1.1 Date: May 2026 Status: Five enforcement layers in place — centralised in apps/users/permissions.py


Overview

AgriOps is a multi-tenant SaaS platform. Every organisation (Company) that uses the platform shares the same database and application instance, but each organisation’s data is completely invisible to every other organisation. This document describes exactly how that isolation is implemented, enforced, and tested.

For the architectural decision rationale, see ADR 003.

Implementation note. Isolation is enforced through centralised mixins in apps/users/permissions.pyCompanyOwnedMixin, CompanySetMixin, TenantFormFieldsMixin — plus queryset scoping and serializer validation. The inline examples below illustrate the pattern each mixin encapsulates; in current code the mixin is applied, not the raw override.


The Tenant Root

Company is the tenant root. It is the single model from which all data isolation flows.

Every model in the system has a direct ForeignKey to Company. There are no exceptions. A record without a company foreign key does not belong to the tenant model and must not contain customer data.

# Every model follows this pattern
class Supplier(models.Model):
    company = models.ForeignKey(
        'companies.Company',
        on_delete=models.CASCADE,
        related_name='suppliers'
    )
    # ... other fields

The Isolation Boundary

┌─────────────────────────────────────────┐
│           Company A (Tenant)            │
│  Users · Suppliers · Farms · Products   │
│  Inventory · PurchaseOrders · Sales     │
└─────────────────────────────────────────┘
                    ▲
         ISOLATION BOUNDARY
         No data crosses here
                    ▼
┌─────────────────────────────────────────┐
│           Company B (Tenant)            │
│  Users · Suppliers · Farms · Products   │
│  Inventory · PurchaseOrders · Sales     │
└─────────────────────────────────────────┘

A user authenticated as belonging to Company A can never read, write, or infer the existence of Company B’s data, regardless of the URL they request, the API call they make, or the query parameters they supply.


Enforcement Layer 1 — ListView Queryset Filtering

Every ListView and ListAPIView overrides get_queryset() to filter by the authenticated user’s company.

Django template views:

class SupplierListView(LoginRequiredMixin, ListView):
    model = Supplier
    template_name = 'suppliers/list.html'
    context_object_name = 'suppliers'

    def get_queryset(self):
        # TENANT ISOLATION: Only return records belonging to the current user's company
        return super().get_queryset().filter(company=self.request.user.company)

DRF API viewsets (Phase 2):

class SupplierViewSet(viewsets.ModelViewSet):
    serializer_class = SupplierSerializer
    permission_classes = [IsAuthenticated, IsTenantMember]

    def get_queryset(self):
        # TENANT ISOLATION: Scoped to authenticated user's company
        return Supplier.objects.filter(company=self.request.user.company)

Rule: Every get_queryset() method must include .filter(company=self.request.user.company). This is reviewed at every PR. A missing filter is a security vulnerability, not a bug.


Enforcement Layer 2 — DetailView Object Verification

Filtering the list is not sufficient. A user could bypass the list entirely by requesting a specific URL: /suppliers/99/ where supplier 99 belongs to a different company.

Every DetailView, UpdateView, and DeleteView verifies company ownership after fetching the object:

class SupplierDetailView(LoginRequiredMixin, DetailView):
    model = Supplier
    template_name = 'suppliers/detail.html'

    def get_object(self):
        obj = super().get_object()
        # TENANT ISOLATION: Verify this object belongs to the current user's company
        if obj.company != self.request.user.company:
            raise Http404  # 404 not 403 — see security note below
        return obj

Why 404 and not 403?

Returning HTTP 403 (Forbidden) confirms to an attacker that the record exists — they just can’t access it. This is an information leak. Returning HTTP 404 (Not Found) reveals nothing. From the attacker’s perspective, the record simply does not exist. This is the correct behaviour for a multi-tenant system.


Enforcement Layer 3 — CreateView Company Assignment

When a user creates a new record, the company field must be assigned from the server side — never accepted from the client.

class SupplierCreateView(LoginRequiredMixin, CreateView):
    model = Supplier
    fields = ['name', 'category', 'contact_person', 'phone', 'email', 'country', 'city', 'address', 'is_active']
    # Note: 'company' is NOT in fields — it is never accepted from POST data

    def form_valid(self, form):
        # TENANT ISOLATION: Assign company from authenticated user — never from form data
        form.instance.company = self.request.user.company
        return super().form_valid(form)

Why is company excluded from fields?

If company were in the form fields, a crafted POST request could assign a new record to a different company. By excluding it from fields and assigning it programmatically in form_valid(), the company assignment cannot be manipulated by the client under any circumstances.


Enforcement Layer 4 — API Serializer Company Assignment (Phase 2)

At the DRF API layer, the same principle applies. company is a read-only field on all serializers — it is never writable from the API.

class SupplierSerializer(serializers.ModelSerializer):
    company = serializers.HiddenField(
        default=serializers.CurrentUserDefault()
    )
    # company is hidden — never exposed in API input or output as a writable field

    class Meta:
        model = Supplier
        fields = ['id', 'name', 'category', 'contact_person', 'phone',
                  'email', 'country', 'city', 'is_active', 'company']
        read_only_fields = ['company']

Enforcement Layer 5 — Relation Field Tenant Scoping

Layers 3 and 4 stop the client setting company. They do not stop a form or serializer from offering — or accepting — another tenant’s supplier, product, farmer, or M2M selection. Django’s auto-generated ModelForm (views that set fields = [...]) builds each foreign key as a ModelChoiceField whose queryset defaults to Model.objects.all() — every tenant’s rows. That both leaks other tenants’ records into dropdowns and accepts their PKs on POST — a cross-tenant write, not just a display bug.

Web forms / CBVs — TenantFormFieldsMixin:

class TenantFormFieldsMixin:
    """Scopes every ModelChoiceField / ModelMultipleChoiceField on a Create/
    Update form to request.user.company, for any related model that is
    tenant-scoped (has a `company` FK). Safe no-op otherwise."""
    def get_form(self, form_class=None):
        form = super().get_form(form_class)
        company = self.request.user.company
        for field in form.fields.values():
            if isinstance(field, forms.ModelChoiceField):
                model = field.queryset.model
                if any(f.name == 'company' for f in model._meta.concrete_fields):
                    field.queryset = field.queryset.filter(company=company)
        return form

Applied to every tenant-facing CreateView / UpdateView. ModelMultipleChoiceField is a ModelChoiceField subclass, so M2M fields are covered too.

API serializers — validate_<field>(): each serializer validates every foreign key against the tenant boundary; a cross-tenant FK reference returns 400. Verification and engine-owned fields (is_eudr_verified, deforestation_risk_status, …) are additionally read_only so they cannot be written over the API at all.

Why this is a layer of its own: per-view scoping was relied on and it failed — two view authors scoped their FK fields and one did not, leaking suppliers across tenants. The mixin makes the scoping structural, not a thing each author has to remember. See /docs/threat-model.md §5.2.


User — Company Relationship

A user belongs to exactly one company. This is enforced at the model level.

class CustomUser(AbstractUser):
    company = models.ForeignKey(
        'companies.Company',
        on_delete=models.CASCADE,
        null=True,  # null only for superusers who span all companies
        blank=True
    )

Superuser exception: Django superusers (is_superuser=True) are platform administrators — they exist outside the tenant model. Superusers access data via the Django admin panel only, never through the regular application views. All superuser actions are logged.

Unauthenticated users: All views require authentication via LoginRequiredMixin (template views) or IsAuthenticated (API views). An unauthenticated request is redirected to the login page before any queryset is ever executed.


Tenant Isolation in the Audit Log

Every audit log entry records the company it belongs to. This means:


Phase 4 — Second Layer: PostgreSQL Row-Level Security

In Phase 4, PostgreSQL Row-Level Security (RLS) will be added as a defence-in-depth layer. This operates independently of application code.

-- Example RLS policy (Phase 4)
ALTER TABLE suppliers_supplier ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON suppliers_supplier
    USING (company_id = current_setting('app.current_company_id')::int);

With RLS in place, even if a bug in application code produced an unfiltered queryset, the database itself would refuse to return records outside the current tenant context. Two independent isolation layers — application and database.


Testing Requirements

The following tests are mandatory. They run as part of the CI/CD pipeline and are blocking — no deployment proceeds if they fail.

class TenantIsolationTests(TestCase):

    def setUp(self):
        self.company_a = Company.objects.create(name="Company A")
        self.company_b = Company.objects.create(name="Company B")
        self.user_a = CustomUser.objects.create_user(
            username='user_a', company=self.company_a, system_role='staff'
        )
        self.user_b = CustomUser.objects.create_user(
            username='user_b', company=self.company_b, system_role='staff'
        )
        self.supplier_b = Supplier.objects.create(
            company=self.company_b, name="Supplier B"
        )

    def test_list_view_only_returns_own_company_records(self):
        self.client.force_login(self.user_a)
        response = self.client.get(reverse('suppliers:list'))
        self.assertEqual(response.status_code, 200)
        # Company A user sees zero suppliers — all belong to Company B
        self.assertEqual(len(response.context['suppliers']), 0)

    def test_detail_view_returns_404_for_other_company_record(self):
        self.client.force_login(self.user_a)
        response = self.client.get(
            reverse('suppliers:detail', args=[self.supplier_b.pk])
        )
        # Must return 404, not 200 or 403
        self.assertEqual(response.status_code, 404)

    def test_create_view_assigns_correct_company(self):
        self.client.force_login(self.user_a)
        self.client.post(reverse('suppliers:create'), {'name': 'New Supplier', ...})
        new_supplier = Supplier.objects.get(name='New Supplier')
        # Must be assigned to Company A regardless of POST data
        self.assertEqual(new_supplier.company, self.company_a)

    def test_create_view_cannot_assign_other_company_via_post(self):
        self.client.force_login(self.user_a)
        # Attempt to assign record to Company B via POST
        self.client.post(reverse('suppliers:create'), {
            'name': 'Malicious Supplier',
            'company': self.company_b.pk  # This should be ignored
        })
        supplier = Supplier.objects.get(name='Malicious Supplier')
        self.assertEqual(supplier.company, self.company_a)  # Still Company A

Developer Checklist

Before submitting any PR that adds or modifies a view: