Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
a0ed1f3
Django guardian setup
mohamedelabbas1996 Jan 24, 2025
bbd7faa
feat: added object level permissions for Project
mohamedelabbas1996 Jan 27, 2025
bcb2eeb
feat: added object level permissions management to the admin page
mohamedelabbas1996 Jan 27, 2025
aab6000
feat: added signals to auto-assign permissions when a project is crea…
mohamedelabbas1996 Jan 27, 2025
8bb1ac3
Added tests for user permissions
mohamedelabbas1996 Jan 27, 2025
38addf3
cleanup: removed logs
mohamedelabbas1996 Jan 27, 2025
d85b88e
feat: Enforce project_id for project entities & inherit project permi…
mohamedelabbas1996 Jan 31, 2025
3e4f1d8
feat: added roles
mohamedelabbas1996 Feb 4, 2025
ac83301
feat: added custom permission checks
mohamedelabbas1996 Feb 4, 2025
080315b
cleanup: refactored ObjectPermissions to ProjectCRUDPermissions
mohamedelabbas1996 Feb 4, 2025
82f49fc
fix: made project_id param optional
mohamedelabbas1996 Feb 6, 2025
31ff972
feat: added project entities CRUD permissions
mohamedelabbas1996 Feb 6, 2025
1338032
Added tests for basic member and identifier roles
mohamedelabbas1996 Feb 6, 2025
a2a4b67
cleanup: squashed migrations
mohamedelabbas1996 Feb 6, 2025
04e58ed
fix: added CanDeleteIdentification permission to Identifier role
mohamedelabbas1996 Feb 6, 2025
6836bd2
fix: Allow only the user who created an Identification to delete it
mohamedelabbas1996 Feb 6, 2025
01d0c84
fix: Allow Project Manager to delete all Identifications in the project
mohamedelabbas1996 Feb 6, 2025
bddb311
fix: Auto-create roles when a project is created
mohamedelabbas1996 Feb 6, 2025
c28394e
fix: Prevent Project Manager from creating a project
mohamedelabbas1996 Feb 6, 2025
853ecb9
stub out solution for permissions per-collection type
mihow Feb 7, 2025
9a5d12f
Merge branch 'main' into feat/user-permissions
mohamedelabbas1996 Feb 7, 2025
bf7b091
fix: Resolved merge conflict
mohamedelabbas1996 Feb 7, 2025
3c4ec72
fix: resovled merge conflict
mohamedelabbas1996 Feb 7, 2025
670fb5f
fix: check permissions before creating an object
mohamedelabbas1996 Feb 7, 2025
3724e5f
feat: Add identifier and source image collection permissions
mohamedelabbas1996 Feb 11, 2025
0bb0783
fix: Ensure identifier permissions are reflected on the frontend
mohamedelabbas1996 Feb 11, 2025
39ec6cf
cleanup: Removed TODOs
mohamedelabbas1996 Feb 11, 2025
8458f95
fix: renamed storage permission
mohamedelabbas1996 Feb 11, 2025
eccca1f
fix: update frontend logic for entity delete permissions
annavik Feb 12, 2025
5e07ab4
fix: added create permission to the user permissions field at the col…
mohamedelabbas1996 Feb 12, 2025
a5003a6
Merge branch 'feat/user-permissions' of https://github.com/RolnickLab…
mohamedelabbas1996 Feb 12, 2025
8fd64a8
fix: Fixed Identifier role tests
mohamedelabbas1996 Feb 12, 2025
2178095
fix: Resolved migration conflict
mohamedelabbas1996 Feb 12, 2025
a10041f
fix: added algorithm (name,version) unique constraint
mohamedelabbas1996 Feb 13, 2025
b25fa77
fix: removed permissions for machine suggestions
mohamedelabbas1996 Feb 13, 2025
e32b9dd
fix: auto-assign BasicMember and ProjectManager roles for all projects
mohamedelabbas1996 Feb 18, 2025
2a8f326
fix: get the queryset for project members
mohamedelabbas1996 Feb 18, 2025
bdba187
Merge branch 'main' into feat/user-permissions
mohamedelabbas1996 Feb 18, 2025
ee03ed1
merged migrations
mohamedelabbas1996 Feb 18, 2025
964711c
fix: only run create roles once after migrations are completed
mihow Feb 19, 2025
3db5224
feat: added assign_identifiers & assign_roles management commands
mohamedelabbas1996 Feb 20, 2025
11169bd
Merge branch 'feat/user-permissions' of https://github.com/RolnickLab…
mohamedelabbas1996 Feb 20, 2025
b604345
Merge branch 'main' into feat/user-permissions
mohamedelabbas1996 Feb 20, 2025
79a18ce
fix: first, reset all permissions for non-superusers in the assign_ro…
mohamedelabbas1996 Feb 20, 2025
1f8978f
Merge branch 'feat/user-permissions' of https://github.com/RolnickLab…
mohamedelabbas1996 Feb 20, 2025
7a94767
added merge migration
mohamedelabbas1996 Feb 20, 2025
54f70fc
chore: modified doc string
mohamedelabbas1996 Feb 20, 2025
7651004
fix: added frontend permisssion checks for star and populate actions
mohamedelabbas1996 Feb 21, 2025
b1c8725
fix: adjust star button tooltip based on permissions status
annavik Feb 21, 2025
351f079
style: add gap to identification card action buttons when more than o…
annavik Feb 21, 2025
1bdba18
fix: modify create permission check to fall back to object permissions
mohamedelabbas1996 Feb 21, 2025
a45d52c
Merge branch 'feat/user-permissions' of https://github.com/RolnickLab…
mohamedelabbas1996 Feb 21, 2025
c3bca51
test: added tests to make sure that project manager can CRUD project …
mohamedelabbas1996 Feb 21, 2025
1ae6152
feat: refine permissions and role-based access control
mohamedelabbas1996 Feb 23, 2025
577731e
added migration file
mohamedelabbas1996 Feb 23, 2025
55a6dfc
fix: handle underscores in project name
mohamedelabbas1996 Feb 24, 2025
8300905
fix: only show button "Register pipelines" if user is allowed to crea…
annavik Feb 24, 2025
1324fa6
fix: mark prop as optional
annavik Feb 24, 2025
b7e6d42
style: format code
annavik Feb 24, 2025
a5d06e0
fix: Extract role name after the last underscore and check if the gro…
mohamedelabbas1996 Feb 24, 2025
6ae6995
fix: Extract role name after the last underscore and check if the gro…
mohamedelabbas1996 Feb 24, 2025
cfb88a1
Merge branch 'feat/user-permissions' of https://github.com/RolnickLab…
mohamedelabbas1996 Feb 24, 2025
19bc51f
feat: update FE to use custom permissions for jobs
annavik Feb 24, 2025
34f8fcf
fix: removed project update permission from basic member role
mohamedelabbas1996 Feb 24, 2025
73abb4b
fix: removed project update permission from basic member role
mohamedelabbas1996 Feb 24, 2025
6412eba
fix: removed project update permission from basic member role
mohamedelabbas1996 Feb 24, 2025
ff17595
fix: removed project update permission from basic member role
mohamedelabbas1996 Feb 24, 2025
bceab0a
fix: removed project update permission from basic member role
mohamedelabbas1996 Feb 24, 2025
3b7579b
Merge branch 'main' into feat/user-permissions
mohamedelabbas1996 Feb 25, 2025
1d75e9d
cleanup: merged assign_roles & assign_identifiers management commands
mohamedelabbas1996 Feb 25, 2025
44ce97a
Merge branch 'feat/user-permissions' of https://github.com/RolnickLab…
mohamedelabbas1996 Feb 25, 2025
02d9795
fix: show projects in My projetcs based on roles assigned to user
mohamedelabbas1996 Feb 26, 2025
e1c3e17
fix: show projects in My projetcs based on roles assigned to user
mohamedelabbas1996 Feb 26, 2025
b1ba917
fix: Add user to project members when they got assigned a role
mohamedelabbas1996 Feb 27, 2025
963a33b
chore: standardize the generation of the permission group name
mihow Feb 27, 2025
0d67332
chore: Added TODOs for tracking permission group names used to link p…
mohamedelabbas1996 Feb 27, 2025
57e8911
Merge branch 'main' of github.com:RolnickLab/antenna into feat/user-p…
mihow Mar 6, 2025
c7ae632
chore: add type annotation
mihow Mar 6, 2025
0a83d4e
feat: reduce logs & make log level configurable locally
mihow Mar 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envs/.local/.django
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
DJANGO_SETTINGS_MODULE="config.settings.local"
USE_DOCKER=yes
DJANGO_DEBUG=True
DJANGO_LOG_LEVEL=INFO
IPYTHONDIR=/app/.ipython

# Default superuser for local development
Expand Down
4 changes: 4 additions & 0 deletions ami/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ class BaseModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

def get_project(self):
Copy link
Collaborator

@mihow mihow Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up proposal for objects that are shared between multiple projects (have a many2many): check for many2many, if instance belongs to multiple projects, they must be a superuser or permission denied. If a single project, then return that project as usual.

known many2many

Pipeline
Processing Service
Taxa
TaxaList

"""Get the project associated with the model."""
return self.project if hasattr(self, "project") else None

def __str__(self) -> str:
"""All django models should have this method."""
if hasattr(self, "name"):
Expand Down
21 changes: 20 additions & 1 deletion ami/base/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,27 @@

class LimitOffsetPaginationWithPermissions(LimitOffsetPagination):
def get_paginated_response(self, data):
model = self._get_current_model()
project = self._get_project()
paginated_response = super().get_paginated_response(data=data)
paginated_response.data = add_collection_level_permissions(
user=self.request.user, response_data=paginated_response.data
user=self.request.user, response_data=paginated_response.data, model=model, project=project
)
return paginated_response

def _get_current_model(self):
"""
Retrieve the current model from the view.
"""
view = self.request.parser_context.get("view")
if view and hasattr(view, "queryset"):
queryset = view.queryset
if queryset is not None:
return queryset.model
return None

def _get_project(self):
view = self.request.parser_context.get("view")
if hasattr(view, "get_active_project"):
return view.get_active_project()
return None
223 changes: 205 additions & 18 deletions ami/base/permissions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from __future__ import annotations

from typing import TYPE_CHECKING
import logging

from django.contrib.auth.models import AbstractBaseUser, AnonymousUser, User
from guardian.shortcuts import get_perms
from rest_framework import permissions

if TYPE_CHECKING:
from django.contrib.auth.models import User
from ami.jobs.models import Job
from ami.main.models import BaseModel, Deployment, Device, Project, S3StorageSource, Site, SourceImageCollection
from ami.users.roles import ProjectManager

logger = logging.getLogger(__name__)


def is_active_staff(user: User) -> bool:
Expand All @@ -28,32 +33,214 @@ def has_permission(self, request, view):
)


def add_object_level_permissions(user: User | None, response_data: dict) -> dict:
"""
Add placeholder permissions to detail views and nested objects.
def filter_permissions(permissions, model_name):
"""Filter and extract only the action part of `action_modelname`"""

If the user is logged in, they can edit any object type.
If the user is a superuser, they can delete any object type.
filtered_permissions = {
perm.split("_")[0] # Extract "action" from "action_modelname"
for perm in permissions
if perm.endswith(f"_{model_name}") # Ensure it matches the model
}
return filtered_permissions

@TODO @IMPORTANT At least check if they are the owner of the project.

def add_object_level_permissions(
user: AbstractBaseUser | AnonymousUser, instance: BaseModel, response_data: dict
) -> dict:
"""
Adds object-level permissions to the response data for a given user and instance.
This function updates the `response_data` dictionary with the permissions that the
specified `user` has on the given `instance`'s project.
"""

permissions = response_data.get("user_permissions", set())
if user and is_active_staff(user):
permissions.update(["update"])
if user.is_superuser:
permissions.update(["delete"])
project = instance.get_project() if hasattr(instance, "get_project") else None
model_name = instance._meta.model_name # Get model name
if user and user.is_superuser:
permissions.update(["update", "delete"])

if project:
user_permissions = get_perms(user, project)
# Filter and extract only the action part of "action_modelname" based on instance type
filtered_permissions = filter_permissions(permissions=user_permissions, model_name=model_name)
# Do not return create, view permissions at object-level
filtered_permissions -= {"create", "view"}
permissions.update(filtered_permissions)
response_data["user_permissions"] = permissions
return response_data


def add_collection_level_permissions(user: User | None, response_data: dict) -> dict:
"""
Add placeholder permissions to list view responses.
def add_collection_level_permissions(user: User | None, response_data: dict, model, project) -> dict:
"""Add collection-level permissions to the response data for a list view.

If the user is logged in, they can create new objects of any type.
This function modifies the `response_data` dictionary to include user permissions
for creating new objects of the specified model type. If the user is logged in and
is an active staff member, or if the user has create_model permission, the
"create" permission is added to the `user_permissions` set in the `response_data`.
"""

logger.info(f"add_collection_level_permissions model {model.__name__}, {type(model)} ")
permissions = response_data.get("user_permissions", set())
if user and is_active_staff(user):
if user and user.is_superuser:
permissions.add("create")

if user and project and f"create_{model.__name__.lower()}" in get_perms(user, project):
permissions.add("create")
response_data["user_permissions"] = permissions
return response_data


class CRUDPermission(permissions.BasePermission):
"""
Generic CRUD permission class that dynamically checks user permissions on an object.
Permission names follow the convention: `create_<model>`, `update_<model>`, `delete_<model>`.
"""

model = None

def has_permission(self, request, view):
"""Handles general permission checks"""

return True # Fallback to object level permissions

def has_object_permission(self, request, view, obj):
"""Handles object-level permission checks."""
model_name = self.model._meta.model_name
project = obj.get_project() if hasattr(obj, "get_project") else None
# check for create action
if view.action == "create":
return request.user.is_superuser or request.user.has_perm(f"create_{model_name}", project)

if view.action == "retrieve":
return True # Allow all users to view objects

# Map ViewSet actions to permission names
action_perms = {
"retrieve": f"view_{model_name}",
"update": f"update_{model_name}",
"partial_update": f"update_{model_name}",
"destroy": f"delete_{model_name}",
}

required_perm = action_perms.get(view.action)
if not required_perm:
return True
return request.user.has_perm(required_perm, project)


class ProjectCRUDPermission(CRUDPermission):
model = Project


class JobCRUDPermission(CRUDPermission):
model = Job


class DeploymentCRUDPermission(CRUDPermission):
model = Deployment


class SourceImageCollectionCRUDPermission(CRUDPermission):
model = SourceImageCollection


class CanStarSourceImage(permissions.BasePermission):
"""Custom permission to check if the user can star a Source image."""

permission = Project.Permissions.STAR_SOURCE_IMAGE

def has_object_permission(self, request, view, obj):
if view.action in ["unstar", "star"]:
project = obj.get_project() if hasattr(obj, "get_project") else None
return request.user.has_perm(self.permission, project)
return True


class S3StorageSourceCRUDPermission(CRUDPermission):
model = S3StorageSource


class SiteCRUDPermission(CRUDPermission):
model = Site


class DeviceCRUDPermission(CRUDPermission):
model = Device


# Identification permission checks
class CanUpdateIdentification(permissions.BasePermission):
"""Custom permission to check if the user can update/create an identification."""

permission = Project.Permissions.UPDATE_IDENTIFICATION

def has_object_permission(self, request, view, obj):
if view.action in ["create", "update", "partial_update"]:
project = obj.get_project() if hasattr(obj, "get_project") else None
return request.user.has_perm(self.permission, project)
return True


class CanDeleteIdentification(permissions.BasePermission):
"""Custom permission to check if the user can delete an identification."""

permission = Project.Permissions.DELETE_IDENTIFICATION

def has_object_permission(self, request, view, obj):
project = obj.get_project() if hasattr(obj, "get_project") else None
# Check if user is superuser or staff or project manager
if view.action == "destroy":
if request.user.is_superuser or ProjectManager.has_role(request.user, project):
return True
# Check if the user is the owner of the object
return obj.user == request.user
return True


# Job run permission check
class CanRunJob(permissions.BasePermission):
"""Custom permission to check if the user can run a job."""

permission = Project.Permissions.RUN_JOB

def has_object_permission(self, request, view, obj):
if view.action == "run":
project = obj.get_project() if hasattr(obj, "get_project") else None
return request.user.has_perm(self.permission, project)
return True


class CanRetryJob(permissions.BasePermission):
"""Custom permission to check if the user can retry a job."""

permission = Project.Permissions.RETRY_JOB

def has_object_permission(self, request, view, obj):
if view.action == "retry":
project = obj.get_project() if hasattr(obj, "get_project") else None
return request.user.has_perm(self.permission, project)
return True


class CanCancelJob(permissions.BasePermission):
"""Custom permission to check if the user can cancel a job."""

permission = Project.Permissions.CANCEL_JOB

def has_object_permission(self, request, view, obj):
if view.action == "cancel":
project = obj.get_project() if hasattr(obj, "get_project") else None
return request.user.has_perm(self.permission, project)
return True


class CanPopulateSourceImageCollection(permissions.BasePermission):
"""Custom permission to check if the user can populate a collection."""

permission = Project.Permissions.POPULATE_COLLECTION

def has_object_permission(self, request, view, obj):
if view.action == "populate":
project = obj.get_project() if hasattr(obj, "get_project") else None
return request.user.has_perm(self.permission, project)
return True
18 changes: 13 additions & 5 deletions ami/base/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import typing
import urllib.parse

Expand All @@ -9,6 +10,8 @@

from .permissions import add_object_level_permissions

logger = logging.getLogger(__name__)


def reverse_with_params(viewname: str, args=None, kwargs=None, request=None, params: dict = {}, **extra) -> str:
query_string = urllib.parse.urlencode(params)
Expand Down Expand Up @@ -40,14 +43,19 @@ class DefaultSerializer(serializers.HyperlinkedModelSerializer):
url_field_name = "details"
id = serializers.IntegerField(read_only=True)

def get_permissions(self, instance_data):
request = self.context.get("request")
user = request.user if request else None
return add_object_level_permissions(user, instance_data)
def get_permissions(self, instance, instance_data):
request: Request = self.context["request"]
user = request.user

return add_object_level_permissions(
user=user,
instance=instance,
response_data=instance_data,
)

def to_representation(self, instance):
instance_data = super().to_representation(instance)
instance_data = self.get_permissions(instance_data)
instance_data = self.get_permissions(instance=instance, instance_data=instance_data)
return instance_data


Expand Down
38 changes: 38 additions & 0 deletions ami/base/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging

from django.shortcuts import get_object_or_404
from rest_framework import serializers

from ami.main.models import Project

logger = logging.getLogger(__name__)


class ProjectMixin:
"""
Mixin to handle project_id fetching from query parameters or URL parameters.
By default, project_id is required, but this can be overridden.
"""

require_project = False # Project is optional

def get_active_project(self):
from ami.base.serializers import SingleParamSerializer

project_id = None
# Extract from URL `/projects/` is in the url path
if "/projects/" in self.request.path:
project_id = self.kwargs.get("pk")

# If not in URL, try query parameters
if not project_id:
if self.require_project:
project_id = SingleParamSerializer[int].clean(
param_name="project_id",
field=serializers.IntegerField(required=True, min_value=0),
data=self.request.query_params,
)
else:
project_id = self.request.query_params.get("project_id") # No validation

return get_object_or_404(Project, id=project_id) if project_id else None
Loading