Skip to content

Review M2M permissions for things that belong to multiple projects #1120

@mihow

Description

@mihow

Background

Django-guardian provides object-level permissions on model instances but does not natively support permissions on M2M relationships. This means a user with permission on Object A could potentially manipulate M2M relationships to associate it with Object B they don't have access to.

Additionally, many ViewSets gate writes on is_staff (via IsActiveStaffOrReadOnly), which is a Django admin concept — not a real permission boundary. The React UI should rely exclusively on project membership and role-based group permissions.

Why this matters

  • Active project is user-controlled — it comes from a URL parameter or query param, so any authenticated user can set any project as their "active" project.
  • is_staff is not a permission boundary — it is a Django admin flag. Staff status should only control Django admin access, not API authorization.
  • It may be possible that any authenticated user can currently update the m2m lookup table for projects they are not a member of, simply by passing a different project_id in the URL. - Verify this

Audit Results

1. M2M Cross-Project Vulnerabilities

TaxonViewSet.assign_tags

File: ami/main/api/views.py:1601-1620

tags = Tag.objects.filter(id__in=tag_ids)  # ← NO PROJECT FILTER
taxon.tags.set(tags)

Risk: User can assign tags from Project Y to a taxon visible in Project X.

Fix: Filter tags by active project:

project = self.get_active_project()
tags = Tag.objects.filter(id__in=tag_ids, Q(project=project) | Q(project__isnull=True))

SourceImageCollectionViewSet._get_source_image

File: ami/main/api/views.py:782-796

return SourceImage.objects.get(id=source_image_id)  # ← NO PROJECT FILTER

Risk: User can add images from Project Y to a collection in Project X via the add / remove actions.

Fix: Validate source image belongs to collection's project:

collection = self.get_object()
return SourceImage.objects.get(id=source_image_id, deployment__project=collection.project)

2. ViewSets Using Wrong Permission Class (IsActiveStaffOrReadOnly)

These ViewSets inherit IsActiveStaffOrReadOnly from DefaultViewSetMixin (line 115) and have no permission_classes override. Writes are gated on is_staff instead of project membership/roles:

ViewSet File Notes
EventViewSet ami/main/api/views.py:312
DetectionViewSet ami/main/api/views.py:895
OccurrenceViewSet ami/main/api/views.py:1167
TaxonViewSet ami/main/api/views.py:1344 Also has assign_tags M2M bug above
TaxaListViewSet ami/main/api/views.py:1627 @TODO in perform_create (line 1655)
TagViewSet ami/main/api/views.py:1734
ClassificationViewSet ami/main/api/views.py:1748
AlgorithmViewSet ami/ml/views.py:32
PipelineViewSet ami/ml/views.py:74
ProcessingServiceViewSet ami/ml/views.py:141 @TODO in perform_create (line 184); status and register_pipelines actions also fetch by pk with no project scoping

Two non-ViewSet endpoints also use IsActiveStaffOrReadOnly:

  • SummaryView (ami/main/api/views.py:1790) — GET-only, lower risk
  • StorageStatus (ami/main/api/views.py:1867) — has POST endpoint

3. ViewSets Already Using Proper Permissions (No Action Needed)

These ViewSets use ObjectPermission (delegates to model's check_permission()) or a custom class:

ViewSet Permission Class
ProjectViewSet ObjectPermission
DeploymentViewSet ObjectPermission
SourceImageViewSet ObjectPermission
SourceImageCollectionViewSet ObjectPermission (but has M2M bug above)
SourceImageUploadViewSet ObjectPermission
IdentificationViewSet ObjectPermission
SiteViewSet ObjectPermission
DeviceViewSet ObjectPermission
StorageSourceViewSet ObjectPermission
JobViewSet ObjectPermission
ExportViewSet ObjectPermission
UserProjectMembershipViewSet UserMembershipPermission
TaxaListTaxonViewSet IsProjectMemberOrReadOnly (fixed in #350)

4. IsProjectMemberOrReadOnly Missing is_authenticated Check

File: ami/base/permissions.py:87-100

For AnonymousUser, request.user.pk is None. The membership query returns False, but this is implicit and fragile. An explicit is_authenticated check should be added before the membership lookup.


Implementation Plan

Phase 1: Add Missing Permission Tests & Fix IsProjectMemberOrReadOnly

  1. Add explicit is_authenticated check to IsProjectMemberOrReadOnly
  2. Add test class TestIsProjectMemberOrReadOnlyPermission:
    • Unauthenticated user can read, cannot write
    • Non-member can read, cannot write
    • Member can read and write

Phase 2: Fix M2M Cross-Project Vulnerabilities

  1. TaxonViewSet.assign_tags — filter tags by active project
  2. SourceImageCollectionViewSet._get_source_image — validate source image belongs to collection's project
  3. Add cross-project test cases for both

Phase 3: Migrate ViewSets from IsActiveStaffOrReadOnly to Proper Permissions

For each ViewSet listed in section 2 above, replace IsActiveStaffOrReadOnly with the appropriate permission class. The right choice depends on whether the model supports check_permission():

  • Models with check_permission() (inheriting BaseModel pattern used by Project, Deployment, etc.) → use ObjectPermission
  • ViewSets managing resources scoped to a project but without check_permission() → use IsProjectMemberOrReadOnly
  • Shared/global resources (Algorithm, Pipeline) → need design decision on who can write

This phase is the largest and should be broken into sub-PRs by app or model group.

Phase 4: Scope ProcessingServiceViewSet Actions

The status and register_pipelines actions fetch by raw pk with no project scoping:

processing_service = ProcessingService.objects.get(pk=pk)  # ← bypasses get_queryset

These should use self.get_object() instead to respect queryset filtering and permissions.


Design Decision

Validate at view level (explicit per-endpoint checks). The vulnerable endpoints are few and explicit validation is clear and testable. is_staff should only gate Django admin access, not API endpoints.

Files to Modify

File Change
ami/base/permissions.py Add is_authenticated check to IsProjectMemberOrReadOnly
ami/main/api/views.py Fix assign_tags, _get_source_image; migrate permission classes on 7 ViewSets + 2 standalone views
ami/ml/views.py Migrate permission classes on 3 ViewSets; scope status/register_pipelines actions
ami/main/tests/ Add permission and M2M scope tests
config/settings/base.py Consider changing DEFAULT_PERMISSION_CLASSES (line 397) away from IsActiveStaffOrReadOnly

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions