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
- Add explicit
is_authenticated check to IsProjectMemberOrReadOnly
- 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
TaxonViewSet.assign_tags — filter tags by active project
SourceImageCollectionViewSet._get_source_image — validate source image belongs to collection's project
- 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 |
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(viaIsActiveStaffOrReadOnly), 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
is_staffis not a permission boundary — it is a Django admin flag. Staff status should only control Django admin access, not API authorization.project_idin the URL. - Verify thisAudit Results
1. M2M Cross-Project Vulnerabilities
TaxonViewSet.assign_tagsFile:
ami/main/api/views.py:1601-1620Risk: User can assign tags from Project Y to a taxon visible in Project X.
Fix: Filter tags by active project:
SourceImageCollectionViewSet._get_source_imageFile:
ami/main/api/views.py:782-796Risk: User can add images from Project Y to a collection in Project X via the
add/removeactions.Fix: Validate source image belongs to collection's project:
2. ViewSets Using Wrong Permission Class (
IsActiveStaffOrReadOnly)These ViewSets inherit
IsActiveStaffOrReadOnlyfromDefaultViewSetMixin(line 115) and have nopermission_classesoverride. Writes are gated onis_staffinstead of project membership/roles:EventViewSetami/main/api/views.py:312DetectionViewSetami/main/api/views.py:895OccurrenceViewSetami/main/api/views.py:1167TaxonViewSetami/main/api/views.py:1344assign_tagsM2M bug aboveTaxaListViewSetami/main/api/views.py:1627@TODOinperform_create(line 1655)TagViewSetami/main/api/views.py:1734ClassificationViewSetami/main/api/views.py:1748AlgorithmViewSetami/ml/views.py:32PipelineViewSetami/ml/views.py:74ProcessingServiceViewSetami/ml/views.py:141@TODOinperform_create(line 184);statusandregister_pipelinesactions also fetch by pk with no project scopingTwo non-ViewSet endpoints also use
IsActiveStaffOrReadOnly:SummaryView(ami/main/api/views.py:1790) — GET-only, lower riskStorageStatus(ami/main/api/views.py:1867) — has POST endpoint3. ViewSets Already Using Proper Permissions (No Action Needed)
These ViewSets use
ObjectPermission(delegates to model'scheck_permission()) or a custom class:ProjectViewSetObjectPermissionDeploymentViewSetObjectPermissionSourceImageViewSetObjectPermissionSourceImageCollectionViewSetObjectPermission(but has M2M bug above)SourceImageUploadViewSetObjectPermissionIdentificationViewSetObjectPermissionSiteViewSetObjectPermissionDeviceViewSetObjectPermissionStorageSourceViewSetObjectPermissionJobViewSetObjectPermissionExportViewSetObjectPermissionUserProjectMembershipViewSetUserMembershipPermissionTaxaListTaxonViewSetIsProjectMemberOrReadOnly(fixed in #350)4.
IsProjectMemberOrReadOnlyMissingis_authenticatedCheckFile:
ami/base/permissions.py:87-100For
AnonymousUser,request.user.pkisNone. The membership query returnsFalse, but this is implicit and fragile. An explicitis_authenticatedcheck should be added before the membership lookup.Implementation Plan
Phase 1: Add Missing Permission Tests & Fix
IsProjectMemberOrReadOnlyis_authenticatedcheck toIsProjectMemberOrReadOnlyTestIsProjectMemberOrReadOnlyPermission:Phase 2: Fix M2M Cross-Project Vulnerabilities
TaxonViewSet.assign_tags— filter tags by active projectSourceImageCollectionViewSet._get_source_image— validate source image belongs to collection's projectPhase 3: Migrate ViewSets from
IsActiveStaffOrReadOnlyto Proper PermissionsFor each ViewSet listed in section 2 above, replace
IsActiveStaffOrReadOnlywith the appropriate permission class. The right choice depends on whether the model supportscheck_permission():check_permission()(inheritingBaseModelpattern used by Project, Deployment, etc.) → useObjectPermissioncheck_permission()→ useIsProjectMemberOrReadOnlyThis phase is the largest and should be broken into sub-PRs by app or model group.
Phase 4: Scope
ProcessingServiceViewSetActionsThe
statusandregister_pipelinesactions fetch by raw pk with no project scoping: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_staffshould only gate Django admin access, not API endpoints.Files to Modify
ami/base/permissions.pyis_authenticatedcheck toIsProjectMemberOrReadOnlyami/main/api/views.pyassign_tags,_get_source_image; migrate permission classes on 7 ViewSets + 2 standalone viewsami/ml/views.pystatus/register_pipelinesactionsami/main/tests/config/settings/base.pyDEFAULT_PERMISSION_CLASSES(line 397) away fromIsActiveStaffOrReadOnly