feat(permissions): centralize workspace role capability checks
This commit is contained in:
@@ -1,22 +1,46 @@
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from apps.workspaces.models import WorkspaceMembership
|
|
||||||
|
from apps.workspaces.models import Workspace
|
||||||
|
from apps.workspaces.services import (
|
||||||
class IsClientWorkspaceMember(permissions.BasePermission):
|
CLIENTS_CREATE,
|
||||||
"""
|
CLIENTS_DELETE,
|
||||||
Allows access only to users who are active members of the workspace associated with the client.
|
CLIENTS_EDIT,
|
||||||
"""
|
CLIENTS_VIEW,
|
||||||
message = "شما عضو فضای کاری این مشتری نیستید."
|
has_workspace_capability,
|
||||||
|
)
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
"""
|
|
||||||
Validates if the user exists in the workspace memberships for the requested client's workspace.
|
class IsClientWorkspaceMember(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
if not request.user.is_authenticated:
|
Applies capability-based access checks for client resources.
|
||||||
return False
|
"""
|
||||||
|
|
||||||
return WorkspaceMembership.objects.filter(
|
message = "You do not have permission to access this client."
|
||||||
workspace=obj.workspace,
|
|
||||||
user=request.user,
|
def has_permission(self, request, view):
|
||||||
is_active=True
|
if not request.user.is_authenticated:
|
||||||
).exists()
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
workspace_id = request.data.get("workspace_id")
|
||||||
|
if not workspace_id:
|
||||||
|
return False
|
||||||
|
workspace = Workspace.objects.filter(id=workspace_id, is_deleted=False).first()
|
||||||
|
return bool(
|
||||||
|
workspace and has_workspace_capability(request.user, workspace, CLIENTS_CREATE)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
capability = {
|
||||||
|
"retrieve": CLIENTS_VIEW,
|
||||||
|
"list": CLIENTS_VIEW,
|
||||||
|
"update": CLIENTS_EDIT,
|
||||||
|
"partial_update": CLIENTS_EDIT,
|
||||||
|
"destroy": CLIENTS_DELETE,
|
||||||
|
}.get(view.action, CLIENTS_VIEW)
|
||||||
|
return has_workspace_capability(request.user, obj.workspace, capability)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ def test_workspace_membership_update_skips_self_notifications(
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 403
|
||||||
assert _notifications_for(owner) == []
|
assert _notifications_for(owner) == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from apps.projects.models import ProjectMembership
|
from apps.projects.models import ProjectMembership
|
||||||
|
from apps.workspaces.services import (
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
has_project_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_project_from_obj(obj):
|
def get_project_from_obj(obj):
|
||||||
@@ -10,40 +16,44 @@ def get_project_from_obj(obj):
|
|||||||
return obj if hasattr(obj, "workspace") else obj.project
|
return obj if hasattr(obj, "workspace") else obj.project
|
||||||
|
|
||||||
|
|
||||||
class IsProjectMember(permissions.BasePermission):
|
class IsProjectMember(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Allows access only to users who have an active membership in the project.
|
Allows access only to users who have an active membership in the project.
|
||||||
"""
|
"""
|
||||||
message = "شما عضو این پروژه نیستید."
|
message = "شما عضو این پروژه نیستید."
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
project = get_project_from_obj(obj)
|
||||||
return ProjectMembership.objects.filter(
|
return has_project_capability(request.user, project, PROJECTS_VIEW)
|
||||||
project=project,
|
|
||||||
user=request.user,
|
|
||||||
is_active=True,
|
class IsProjectManager(permissions.BasePermission):
|
||||||
is_deleted=False
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class IsProjectManager(permissions.BasePermission):
|
|
||||||
"""
|
"""
|
||||||
Allows access only to users who are active MANAGERs of the project.
|
Allows access only to users who are active MANAGERs of the project.
|
||||||
"""
|
"""
|
||||||
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
project = get_project_from_obj(obj)
|
||||||
return ProjectMembership.objects.filter(
|
return has_project_capability(request.user, project, PROJECTS_EDIT)
|
||||||
project=project,
|
|
||||||
user=request.user,
|
|
||||||
role=ProjectMembership.Role.MANAGER,
|
class CanManageProjectMembers(permissions.BasePermission):
|
||||||
is_active=True,
|
message = "Only authorized users can manage project memberships."
|
||||||
is_deleted=False
|
|
||||||
).exists()
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = get_project_from_obj(obj)
|
||||||
|
return has_project_capability(
|
||||||
|
request.user,
|
||||||
|
project,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
)
|
||||||
|
|||||||
@@ -31,17 +31,27 @@ from apps.projects.api.serializers import (
|
|||||||
ProjectRateSerializer, ProjectRateCreateSerializer, ProjectRateUpdateSerializer,
|
ProjectRateSerializer, ProjectRateCreateSerializer, ProjectRateUpdateSerializer,
|
||||||
ProjectUserRateSerializer, ProjectUserRateCreateSerializer, ProjectUserRateUpdateSerializer
|
ProjectUserRateSerializer, ProjectUserRateCreateSerializer, ProjectUserRateUpdateSerializer
|
||||||
)
|
)
|
||||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||||
from apps.projects.services.projects import (
|
from apps.projects.services.projects import (
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
toggle_project_archive
|
toggle_project_archive
|
||||||
)
|
)
|
||||||
from apps.projects.services.memberships import add_project_member, update_project_member
|
from apps.projects.services.memberships import add_project_member, update_project_member
|
||||||
from apps.projects.services.rates import (
|
from apps.projects.services.rates import (
|
||||||
create_project_rate, update_project_rate,
|
create_project_rate, update_project_rate,
|
||||||
create_project_user_rate, update_project_user_rate
|
create_project_user_rate, update_project_user_rate
|
||||||
)
|
)
|
||||||
|
from apps.workspaces.services import (
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
has_project_capability,
|
||||||
|
has_workspace_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectViewSet(ModelViewSet):
|
class ProjectViewSet(ModelViewSet):
|
||||||
@@ -104,8 +114,13 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
|
|
||||||
members_data = serializer.validated_data.pop("members", [])
|
members_data = serializer.validated_data.pop("members", [])
|
||||||
|
|
||||||
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
||||||
client_id = serializer.validated_data.get("client")
|
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to create projects in this workspace."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
client_id = serializer.validated_data.get("client")
|
||||||
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None
|
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None
|
||||||
|
|
||||||
project = create_project(
|
project = create_project(
|
||||||
@@ -230,7 +245,7 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class BaseProjectNestedViewSet(ModelViewSet):
|
class BaseProjectNestedViewSet(ModelViewSet):
|
||||||
"""
|
"""
|
||||||
Base ViewSet for nested project models to share common permission and queryset logic.
|
Base ViewSet for nested project models to share common permission and queryset logic.
|
||||||
"""
|
"""
|
||||||
@@ -245,17 +260,11 @@ class BaseProjectNestedViewSet(ModelViewSet):
|
|||||||
permission_classes = [IsAuthenticated, IsProjectMember]
|
permission_classes = [IsAuthenticated, IsProjectMember]
|
||||||
return [permission() for permission in permission_classes]
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
def verify_manager_access(self, project_id):
|
def verify_manager_access(self, project_id):
|
||||||
"""Helper to verify if the requesting user is a manager of the target project."""
|
"""Helper to verify if the requesting user is a manager of the target project."""
|
||||||
is_manager = ProjectMembership.objects.filter(
|
project = get_object_or_404(Project, id=project_id, is_deleted=False)
|
||||||
project_id=project_id,
|
if not has_project_capability(self.request.user, project, PROJECT_MEMBERS_ADD):
|
||||||
user=self.request.user,
|
raise PermissionDenied("You must be a project manager to perform this action.")
|
||||||
role=ProjectMembership.Role.MANAGER,
|
|
||||||
is_active=True,
|
|
||||||
is_deleted=False
|
|
||||||
).exists()
|
|
||||||
if not is_manager:
|
|
||||||
raise PermissionDenied("You must be a project manager to perform this action.")
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
||||||
@@ -275,14 +284,14 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
|||||||
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
|
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
|
||||||
return ProjectMembershipSerializer
|
return ProjectMembershipSerializer
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
project_id = serializer.validated_data["project_id"]
|
project_id = serializer.validated_data["project_id"]
|
||||||
self.verify_manager_access(project_id)
|
self.verify_manager_access(project_id)
|
||||||
|
|
||||||
project = get_object_or_404(Project, id=project_id, is_deleted=False)
|
project = get_object_or_404(Project, id=project_id, is_deleted=False)
|
||||||
membership = add_project_member(
|
membership = add_project_member(
|
||||||
project=project,
|
project=project,
|
||||||
user_id=serializer.validated_data["user_id"],
|
user_id=serializer.validated_data["user_id"],
|
||||||
@@ -298,6 +307,12 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
|||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
membership = self.get_object()
|
membership = self.get_object()
|
||||||
|
if not has_project_capability(
|
||||||
|
request.user,
|
||||||
|
membership.project,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
):
|
||||||
|
raise PermissionDenied("You do not have permission to update project members.")
|
||||||
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
|
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@@ -330,6 +345,12 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
|||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
membership = self.get_object()
|
membership = self.get_object()
|
||||||
|
if not has_project_capability(
|
||||||
|
request.user,
|
||||||
|
membership.project,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
):
|
||||||
|
raise PermissionDenied("You do not have permission to remove project members.")
|
||||||
recipient = membership.user
|
recipient = membership.user
|
||||||
project = membership.project
|
project = membership.project
|
||||||
role = membership.role
|
role = membership.role
|
||||||
|
|||||||
41
apps/tags/api/permissions.py
Normal file
41
apps/tags/api/permissions.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from apps.workspaces.models import Workspace
|
||||||
|
from apps.workspaces.services import (
|
||||||
|
TAGS_CREATE,
|
||||||
|
TAGS_DELETE,
|
||||||
|
TAGS_EDIT,
|
||||||
|
TAGS_VIEW,
|
||||||
|
has_workspace_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IsTagWorkspaceAllowed(permissions.BasePermission):
|
||||||
|
message = "You do not have permission to access this tag."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
workspace_id = request.data.get("workspace_id")
|
||||||
|
if not workspace_id:
|
||||||
|
return False
|
||||||
|
workspace = Workspace.objects.filter(id=workspace_id, is_deleted=False).first()
|
||||||
|
return bool(
|
||||||
|
workspace and has_workspace_capability(request.user, workspace, TAGS_CREATE)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
capability = {
|
||||||
|
"retrieve": TAGS_VIEW,
|
||||||
|
"list": TAGS_VIEW,
|
||||||
|
"update": TAGS_EDIT,
|
||||||
|
"partial_update": TAGS_EDIT,
|
||||||
|
"destroy": TAGS_DELETE,
|
||||||
|
}.get(view.action, TAGS_VIEW)
|
||||||
|
return has_workspace_capability(request.user, obj.workspace, capability)
|
||||||
@@ -8,20 +8,21 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.tags.api.serializers import (
|
from apps.tags.api.serializers import (
|
||||||
TagSerializer,
|
TagSerializer,
|
||||||
TagCreateSerializer,
|
TagCreateSerializer,
|
||||||
TagUpdateSerializer
|
TagUpdateSerializer
|
||||||
)
|
)
|
||||||
from apps.tags.services.tags import create_tag, update_tag
|
from apps.tags.api.permissions import IsTagWorkspaceAllowed
|
||||||
|
from apps.tags.services.tags import create_tag, update_tag
|
||||||
|
|
||||||
|
|
||||||
class TagViewSet(ModelViewSet):
|
class TagViewSet(ModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoints for managing tags.
|
API endpoints for managing tags.
|
||||||
"""
|
"""
|
||||||
pagination_class = CustomLimitOffsetPagination
|
pagination_class = CustomLimitOffsetPagination
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated, IsTagWorkspaceAllowed]
|
||||||
|
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
filterset_fields = ["workspace"]
|
filterset_fields = ["workspace"]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from apps.time_entries.services.time_entries import (
|
|||||||
update_time_entry,
|
update_time_entry,
|
||||||
stop_time_entry
|
stop_time_entry
|
||||||
)
|
)
|
||||||
|
from apps.workspaces.services import TIME_ENTRIES_MANAGE_OWN, has_workspace_capability
|
||||||
|
|
||||||
|
|
||||||
class TimeEntryViewSet(ModelViewSet):
|
class TimeEntryViewSet(ModelViewSet):
|
||||||
@@ -150,11 +151,16 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
output_serializer = TimeEntrySerializer(entry)
|
output_serializer = TimeEntrySerializer(entry)
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
partial = kwargs.pop("partial", False)
|
partial = kwargs.pop("partial", False)
|
||||||
entry = self.get_object()
|
entry = self.get_object()
|
||||||
|
if not has_workspace_capability(request.user, entry.workspace, TIME_ENTRIES_MANAGE_OWN):
|
||||||
serializer = self.get_serializer(data=request.data, partial=partial)
|
return Response(
|
||||||
|
{"detail": "You do not have permission to manage time entries in this workspace."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=partial)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
updated_entry = update_time_entry(
|
updated_entry = update_time_entry(
|
||||||
@@ -166,11 +172,16 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
def stop(self, request, pk=None):
|
def stop(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
Dedicated endpoint to stop an actively running timer.
|
Dedicated endpoint to stop an actively running timer.
|
||||||
"""
|
"""
|
||||||
entry = self.get_object()
|
entry = self.get_object()
|
||||||
|
if not has_workspace_capability(request.user, entry.workspace, TIME_ENTRIES_MANAGE_OWN):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to manage time entries in this workspace."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -181,11 +192,16 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
output_serializer = TimeEntrySerializer(stopped_entry)
|
output_serializer = TimeEntrySerializer(stopped_entry)
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Soft deletes the time entry.
|
Soft deletes the time entry.
|
||||||
"""
|
"""
|
||||||
entry = self.get_object()
|
entry = self.get_object()
|
||||||
entry.is_deleted = True
|
if not has_workspace_capability(request.user, entry.workspace, TIME_ENTRIES_MANAGE_OWN):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to manage time entries in this workspace."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
entry.is_deleted = True
|
||||||
entry.save(update_fields=["is_deleted", "updated_at"])
|
entry.save(update_fields=["is_deleted", "updated_at"])
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -2,24 +2,23 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.workspaces.models import WorkspaceMembership
|
from apps.workspaces.models import Workspace
|
||||||
|
from apps.workspaces.services import TIME_ENTRIES_MANAGE_OWN, has_workspace_capability
|
||||||
|
|
||||||
def _verify_workspace_access(user, workspace_id):
|
|
||||||
"""
|
def _verify_workspace_access(user, workspace_id):
|
||||||
Ensures the user is an active member of the specified workspace.
|
"""
|
||||||
"""
|
Ensures the user is an active member of the specified workspace.
|
||||||
has_access = WorkspaceMembership.objects.filter(
|
"""
|
||||||
workspace_id=workspace_id,
|
workspace = Workspace.objects.filter(id=workspace_id, is_deleted=False).first()
|
||||||
user=user,
|
if not workspace or not has_workspace_capability(
|
||||||
is_active=True,
|
user,
|
||||||
is_deleted=False
|
workspace,
|
||||||
).exists()
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
):
|
||||||
if not has_access:
|
raise PermissionDenied("You do not have access to this workspace.")
|
||||||
raise PermissionDenied("You do not have access to this workspace.")
|
|
||||||
|
|
||||||
|
|
||||||
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False):
|
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False):
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
from apps.workspaces.services import (
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
has_workspace_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IsWorkspaceOwner(permissions.BasePermission):
|
class IsWorkspaceOwner(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Permission check:
|
Permission check:
|
||||||
- User must be the explicit 'owner' on the Workspace model.
|
- User must be the explicit 'owner' on the Workspace model.
|
||||||
@@ -11,98 +17,86 @@ class IsWorkspaceOwner(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
message = "Access denied. You must be the Workspace Owner to perform this action."
|
message = "Access denied. You must be the Workspace Owner to perform this action."
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(obj, Workspace):
|
if isinstance(obj, Workspace):
|
||||||
workspace = obj
|
workspace = obj
|
||||||
elif isinstance(obj, WorkspaceMembership):
|
elif isinstance(obj, WorkspaceMembership):
|
||||||
workspace = obj.workspace
|
workspace = obj.workspace
|
||||||
elif hasattr(obj, 'workspace'):
|
elif hasattr(obj, "workspace"):
|
||||||
workspace = obj.workspace
|
workspace = obj.workspace
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if workspace.owner == request.user:
|
return workspace.owner_id == request.user.id
|
||||||
return True
|
|
||||||
|
|
||||||
return WorkspaceMembership.objects.filter(
|
class IsWorkspaceAdmin(permissions.BasePermission):
|
||||||
workspace=workspace,
|
|
||||||
user=request.user,
|
|
||||||
role=WorkspaceMembership.Role.OWNER,
|
|
||||||
is_active=True
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class IsWorkspaceAdmin(permissions.BasePermission):
|
|
||||||
"""
|
"""
|
||||||
Permission check:
|
Permission check:
|
||||||
- User's role in the workspace is either 'ADMIN' or 'OWNER'.
|
- User's role in the workspace is either 'ADMIN' or 'OWNER'.
|
||||||
"""
|
"""
|
||||||
message = "Access denied. You must be a Workspace Admin or Owner to perform this action."
|
message = "Access denied. You must be a Workspace Admin or Owner to perform this action."
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(obj, Workspace):
|
if isinstance(obj, Workspace):
|
||||||
workspace = obj
|
workspace = obj
|
||||||
elif isinstance(obj, WorkspaceMembership):
|
elif isinstance(obj, WorkspaceMembership):
|
||||||
workspace = obj.workspace
|
workspace = obj.workspace
|
||||||
elif hasattr(obj, 'workspace'):
|
elif hasattr(obj, "workspace"):
|
||||||
workspace = obj.workspace
|
workspace = obj.workspace
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if workspace.owner == request.user:
|
return has_workspace_capability(request.user, workspace, WORKSPACE_EDIT)
|
||||||
return True
|
|
||||||
|
|
||||||
allowed_roles = [
|
class IsWorkspaceMember(permissions.BasePermission):
|
||||||
WorkspaceMembership.Role.OWNER,
|
|
||||||
WorkspaceMembership.Role.ADMIN,
|
|
||||||
]
|
|
||||||
|
|
||||||
return WorkspaceMembership.objects.filter(
|
|
||||||
workspace=workspace,
|
|
||||||
user=request.user,
|
|
||||||
role__in=allowed_roles,
|
|
||||||
is_active=True
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class IsWorkspaceMember(permissions.BasePermission):
|
|
||||||
"""
|
"""
|
||||||
Permission check:
|
Permission check:
|
||||||
- User's role in the workspace is 'OWNER', 'ADMIN', or 'MEMBER'.
|
- User's role in the workspace is 'OWNER', 'ADMIN', or 'MEMBER'.
|
||||||
"""
|
"""
|
||||||
message = "Access denied. You must be an active member of this workspace."
|
message = "Access denied. You must be an active member of this workspace."
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(obj, Workspace):
|
if isinstance(obj, Workspace):
|
||||||
workspace = obj
|
workspace = obj
|
||||||
elif isinstance(obj, WorkspaceMembership):
|
elif isinstance(obj, WorkspaceMembership):
|
||||||
workspace = obj.workspace
|
workspace = obj.workspace
|
||||||
elif hasattr(obj, 'workspace'):
|
elif hasattr(obj, "workspace"):
|
||||||
workspace = obj.workspace
|
workspace = obj.workspace
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if workspace.owner == request.user:
|
return has_workspace_capability(request.user, workspace, WORKSPACE_VIEW)
|
||||||
return True
|
|
||||||
|
|
||||||
allowed_roles = [
|
class CanWorkspaceManageMembers(permissions.BasePermission):
|
||||||
WorkspaceMembership.Role.OWNER,
|
message = "Access denied. You do not have permission to manage workspace members."
|
||||||
WorkspaceMembership.Role.ADMIN,
|
|
||||||
WorkspaceMembership.Role.MEMBER,
|
def has_object_permission(self, request, view, obj):
|
||||||
]
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
return WorkspaceMembership.objects.filter(
|
|
||||||
workspace=workspace,
|
if isinstance(obj, Workspace):
|
||||||
user=request.user,
|
workspace = obj
|
||||||
role__in=allowed_roles,
|
elif isinstance(obj, WorkspaceMembership):
|
||||||
is_active=True
|
workspace = obj.workspace
|
||||||
).exists()
|
elif hasattr(obj, "workspace"):
|
||||||
|
workspace = obj.workspace
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return has_workspace_capability(
|
||||||
|
request.user,
|
||||||
|
workspace,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,11 +13,22 @@ from apps.notifications.services import (
|
|||||||
notify_workspace_membership_removed,
|
notify_workspace_membership_removed,
|
||||||
notify_workspace_membership_role_changed,
|
notify_workspace_membership_role_changed,
|
||||||
)
|
)
|
||||||
from apps.workspaces.api.permissions import IsWorkspaceOwner, IsWorkspaceAdmin
|
from apps.workspaces.api.permissions import (
|
||||||
|
CanWorkspaceManageMembers,
|
||||||
|
IsWorkspaceAdmin,
|
||||||
|
IsWorkspaceMember,
|
||||||
|
IsWorkspaceOwner,
|
||||||
|
)
|
||||||
from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer
|
from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer
|
||||||
from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter
|
from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
from apps.workspaces.services import (
|
||||||
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
|
can_assign_workspace_role,
|
||||||
|
can_change_workspace_membership,
|
||||||
|
has_workspace_capability,
|
||||||
|
)
|
||||||
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceViewSet(ModelViewSet):
|
class WorkspaceViewSet(ModelViewSet):
|
||||||
@@ -39,10 +50,12 @@ class WorkspaceViewSet(ModelViewSet):
|
|||||||
Q(memberships__user=user, memberships__is_active=True)
|
Q(memberships__user=user, memberships__is_active=True)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in ["update", "partial_update"]:
|
if self.action in ["list", "retrieve"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
return [IsAuthenticated(), IsWorkspaceMember()]
|
||||||
|
if self.action in ["update", "partial_update"]:
|
||||||
|
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
||||||
|
|
||||||
elif self.action == "destroy":
|
elif self.action == "destroy":
|
||||||
return [IsAuthenticated(), IsWorkspaceOwner()]
|
return [IsAuthenticated(), IsWorkspaceOwner()]
|
||||||
|
|
||||||
@@ -77,14 +90,31 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
Q(workspace__memberships__user=user, workspace__memberships__is_active=True)
|
Q(workspace__memberships__user=user, workspace__memberships__is_active=True)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in ["update", "partial_update"]:
|
if self.action in ["list", "retrieve", "create", "update", "partial_update"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
return [IsAuthenticated(), CanWorkspaceManageMembers()]
|
||||||
if self.action in ["destroy"]:
|
if self.action in ["destroy"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceOwner()]
|
return [IsAuthenticated(), CanWorkspaceManageMembers()]
|
||||||
|
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
workspace_id = request.query_params.get("workspace")
|
||||||
|
if not workspace_id:
|
||||||
|
return Response(
|
||||||
|
{"detail": "workspace query parameter is required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||||
|
if not has_workspace_capability(request.user, workspace, WORKSPACE_MEMBERS_VIEW):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to view workspace members."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Overridden to check permissions manually.
|
Overridden to check permissions manually.
|
||||||
@@ -100,13 +130,24 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
|
|
||||||
workspace = get_object_or_404(Workspace, id=workspace_id)
|
workspace = get_object_or_404(Workspace, id=workspace_id)
|
||||||
|
|
||||||
permission = IsWorkspaceAdmin()
|
permission = IsWorkspaceAdmin()
|
||||||
if not permission.has_object_permission(request, self, workspace):
|
if not permission.has_object_permission(request, self, workspace):
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "You must be a Workspace Admin or Owner to add members."},
|
{"detail": "You must be a Workspace Admin or Owner to add members."},
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
requested_role = request.data.get("role")
|
||||||
|
if requested_role and not can_assign_workspace_role(
|
||||||
|
request.user,
|
||||||
|
workspace,
|
||||||
|
requested_role,
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to assign this workspace role."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
membership = serializer.save()
|
membership = serializer.save()
|
||||||
@@ -127,6 +168,17 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
previous_role = membership.role
|
previous_role = membership.role
|
||||||
previous_is_active = membership.is_active
|
previous_is_active = membership.is_active
|
||||||
|
|
||||||
|
requested_role = request.data.get("role")
|
||||||
|
if not can_change_workspace_membership(
|
||||||
|
request.user,
|
||||||
|
membership,
|
||||||
|
new_role=requested_role,
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to change this workspace membership."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(membership, data=request.data, partial=partial)
|
serializer = self.get_serializer(membership, data=request.data, partial=partial)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
updated_membership = serializer.save()
|
updated_membership = serializer.save()
|
||||||
@@ -168,6 +220,11 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
membership = self.get_object()
|
membership = self.get_object()
|
||||||
|
if not can_change_workspace_membership(request.user, membership):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You do not have permission to remove this workspace membership."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
recipient = membership.user
|
recipient = membership.user
|
||||||
workspace = membership.workspace
|
workspace = membership.workspace
|
||||||
role = membership.role
|
role = membership.role
|
||||||
|
|||||||
71
apps/workspaces/services/__init__.py
Normal file
71
apps/workspaces/services/__init__.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from apps.workspaces.services.permissions import (
|
||||||
|
CLIENTS_CREATE,
|
||||||
|
CLIENTS_DELETE,
|
||||||
|
CLIENTS_EDIT,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_DELETE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
TAGS_DELETE,
|
||||||
|
TAGS_EDIT,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
WORKSPACE_DELETE,
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_MEMBERS_ADD,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
can_assign_workspace_role,
|
||||||
|
can_change_workspace_membership,
|
||||||
|
can_manage_workspace_members,
|
||||||
|
get_workspace_membership,
|
||||||
|
get_workspace_role,
|
||||||
|
has_project_capability,
|
||||||
|
has_workspace_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WORKSPACE_VIEW",
|
||||||
|
"WORKSPACE_EDIT",
|
||||||
|
"WORKSPACE_DELETE",
|
||||||
|
"WORKSPACE_MEMBERS_VIEW",
|
||||||
|
"WORKSPACE_MEMBERS_ADD",
|
||||||
|
"WORKSPACE_MEMBERS_REMOVE",
|
||||||
|
"WORKSPACE_MEMBERS_CHANGE_ROLE",
|
||||||
|
"CLIENTS_VIEW",
|
||||||
|
"CLIENTS_CREATE",
|
||||||
|
"CLIENTS_EDIT",
|
||||||
|
"CLIENTS_DELETE",
|
||||||
|
"TAGS_VIEW",
|
||||||
|
"TAGS_CREATE",
|
||||||
|
"TAGS_EDIT",
|
||||||
|
"TAGS_DELETE",
|
||||||
|
"PROJECTS_VIEW",
|
||||||
|
"PROJECTS_CREATE",
|
||||||
|
"PROJECTS_EDIT",
|
||||||
|
"PROJECTS_DELETE",
|
||||||
|
"PROJECTS_ARCHIVE",
|
||||||
|
"PROJECT_MEMBERS_VIEW",
|
||||||
|
"PROJECT_MEMBERS_ADD",
|
||||||
|
"PROJECT_MEMBERS_REMOVE",
|
||||||
|
"PROJECT_MEMBERS_CHANGE_ROLE",
|
||||||
|
"TIME_ENTRIES_VIEW_OWN",
|
||||||
|
"TIME_ENTRIES_MANAGE_OWN",
|
||||||
|
"get_workspace_membership",
|
||||||
|
"get_workspace_role",
|
||||||
|
"has_workspace_capability",
|
||||||
|
"has_project_capability",
|
||||||
|
"can_manage_workspace_members",
|
||||||
|
"can_assign_workspace_role",
|
||||||
|
"can_change_workspace_membership",
|
||||||
|
]
|
||||||
210
apps/workspaces/services/permissions.py
Normal file
210
apps/workspaces/services/permissions.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from apps.projects.models import ProjectMembership
|
||||||
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACE_VIEW = "workspace.view"
|
||||||
|
WORKSPACE_EDIT = "workspace.edit"
|
||||||
|
WORKSPACE_DELETE = "workspace.delete"
|
||||||
|
WORKSPACE_MEMBERS_VIEW = "workspace.members.view"
|
||||||
|
WORKSPACE_MEMBERS_ADD = "workspace.members.add"
|
||||||
|
WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove"
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE = "workspace.members.change_role"
|
||||||
|
CLIENTS_VIEW = "clients.view"
|
||||||
|
CLIENTS_CREATE = "clients.create"
|
||||||
|
CLIENTS_EDIT = "clients.edit"
|
||||||
|
CLIENTS_DELETE = "clients.delete"
|
||||||
|
TAGS_VIEW = "tags.view"
|
||||||
|
TAGS_CREATE = "tags.create"
|
||||||
|
TAGS_EDIT = "tags.edit"
|
||||||
|
TAGS_DELETE = "tags.delete"
|
||||||
|
PROJECTS_VIEW = "projects.view"
|
||||||
|
PROJECTS_CREATE = "projects.create"
|
||||||
|
PROJECTS_EDIT = "projects.edit"
|
||||||
|
PROJECTS_DELETE = "projects.delete"
|
||||||
|
PROJECTS_ARCHIVE = "projects.archive"
|
||||||
|
PROJECT_MEMBERS_VIEW = "project_members.view"
|
||||||
|
PROJECT_MEMBERS_ADD = "project_members.add"
|
||||||
|
PROJECT_MEMBERS_REMOVE = "project_members.remove"
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role"
|
||||||
|
TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"
|
||||||
|
TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"
|
||||||
|
|
||||||
|
PROJECT_MANAGER_CAPABILITIES = {
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
WORKSPACE_ROLE_CAPABILITIES = {
|
||||||
|
WorkspaceMembership.Role.OWNER: {
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_DELETE,
|
||||||
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
|
WORKSPACE_MEMBERS_ADD,
|
||||||
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
CLIENTS_CREATE,
|
||||||
|
CLIENTS_EDIT,
|
||||||
|
CLIENTS_DELETE,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
TAGS_EDIT,
|
||||||
|
TAGS_DELETE,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_DELETE,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
},
|
||||||
|
WorkspaceMembership.Role.ADMIN: {
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
|
WORKSPACE_MEMBERS_ADD,
|
||||||
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
CLIENTS_CREATE,
|
||||||
|
CLIENTS_EDIT,
|
||||||
|
CLIENTS_DELETE,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
TAGS_EDIT,
|
||||||
|
TAGS_DELETE,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_DELETE,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
},
|
||||||
|
WorkspaceMembership.Role.MEMBER: {
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
},
|
||||||
|
WorkspaceMembership.Role.GUEST: {
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
TAGS_VIEW,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_membership(user, workspace: Workspace) -> WorkspaceMembership | None:
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return WorkspaceMembership.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_role(user, workspace: Workspace) -> str | None:
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if workspace.owner_id == user.id:
|
||||||
|
return WorkspaceMembership.Role.OWNER
|
||||||
|
|
||||||
|
membership = get_workspace_membership(user, workspace)
|
||||||
|
return getattr(membership, "role", None)
|
||||||
|
|
||||||
|
|
||||||
|
def has_workspace_capability(user, workspace: Workspace, capability: str) -> bool:
|
||||||
|
role = get_workspace_role(user, workspace)
|
||||||
|
if not role:
|
||||||
|
return False
|
||||||
|
return capability in WORKSPACE_ROLE_CAPABILITIES.get(role, set())
|
||||||
|
|
||||||
|
|
||||||
|
def has_project_capability(user, project, capability: str) -> bool:
|
||||||
|
if has_workspace_capability(user, project.workspace, capability):
|
||||||
|
return True
|
||||||
|
|
||||||
|
workspace_role = get_workspace_role(user, project.workspace)
|
||||||
|
if workspace_role not in {
|
||||||
|
WorkspaceMembership.Role.OWNER,
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_project_manager = ProjectMembership.objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
role=ProjectMembership.Role.MANAGER,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
).exists()
|
||||||
|
return is_project_manager and capability in PROJECT_MANAGER_CAPABILITIES
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_workspace_members(user, workspace: Workspace) -> bool:
|
||||||
|
return has_workspace_capability(user, workspace, WORKSPACE_MEMBERS_CHANGE_ROLE)
|
||||||
|
|
||||||
|
|
||||||
|
def can_assign_workspace_role(user, workspace: Workspace, role: str) -> bool:
|
||||||
|
actor_role = get_workspace_role(user, workspace)
|
||||||
|
if actor_role == WorkspaceMembership.Role.OWNER:
|
||||||
|
return True
|
||||||
|
if actor_role == WorkspaceMembership.Role.ADMIN:
|
||||||
|
return role != WorkspaceMembership.Role.OWNER
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_change_workspace_membership(user, membership: WorkspaceMembership, *, new_role: str | None = None) -> bool:
|
||||||
|
workspace = membership.workspace
|
||||||
|
actor_role = get_workspace_role(user, workspace)
|
||||||
|
if actor_role not in {
|
||||||
|
WorkspaceMembership.Role.OWNER,
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if membership.user_id == user.id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target_is_canonical_owner = workspace.owner_id == membership.user_id
|
||||||
|
target_is_owner_role = membership.role == WorkspaceMembership.Role.OWNER
|
||||||
|
|
||||||
|
if actor_role == WorkspaceMembership.Role.ADMIN:
|
||||||
|
if target_is_owner_role or target_is_canonical_owner:
|
||||||
|
return False
|
||||||
|
if new_role == WorkspaceMembership.Role.OWNER:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
if target_is_canonical_owner:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if new_role == WorkspaceMembership.Role.OWNER and workspace.owner_id != user.id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
1
apps/workspaces/tests/__init__.py
Normal file
1
apps/workspaces/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
258
apps/workspaces/tests/test_capabilities.py
Normal file
258
apps/workspaces/tests/test_capabilities.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.projects.models import Project, ProjectMembership
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api_client():
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _user(index: int) -> User:
|
||||||
|
return User.objects.create_user(
|
||||||
|
mobile=f"091255500{index:02d}",
|
||||||
|
password="secret123",
|
||||||
|
first_name=f"User{index}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def owner(db):
|
||||||
|
return _user(1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(db):
|
||||||
|
return _user(2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def member(db):
|
||||||
|
return _user(3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def guest(db):
|
||||||
|
return _user(4)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def extra_owner(db):
|
||||||
|
return _user(5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def workspace(owner, admin, member, guest):
|
||||||
|
workspace = Workspace.objects.create(name="Ops", description="", owner=owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=admin,
|
||||||
|
role=WorkspaceMembership.Role.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=guest,
|
||||||
|
role=WorkspaceMembership.Role.GUEST,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def project(workspace, owner, member):
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Alpha", description="")
|
||||||
|
ProjectMembership.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=owner,
|
||||||
|
role=ProjectMembership.Role.MANAGER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectMembership.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=member,
|
||||||
|
role=ProjectMembership.Role.MANAGER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
|
||||||
|
client = Client.objects.create(workspace=workspace, name="Existing Client", notes="")
|
||||||
|
api_client.force_authenticate(user=member)
|
||||||
|
|
||||||
|
client_response = api_client.post(
|
||||||
|
"/api/clients/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "Acme", "notes": ""},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_client_response = api_client.patch(
|
||||||
|
f"/api/clients/{client.id}/",
|
||||||
|
{"name": "Updated"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_client_response = api_client.delete(f"/api/clients/{client.id}/")
|
||||||
|
project_response = api_client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{"workspace": str(workspace.id), "name": "Beta", "description": "", "client": None},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_project_response = api_client.patch(
|
||||||
|
f"/api/projects/{project.id}/",
|
||||||
|
{"description": "Blocked edit"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
|
||||||
|
delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
|
||||||
|
membership_response = api_client.post(
|
||||||
|
"/api/memberships/",
|
||||||
|
{
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"user_id": str(workspace.owner_id),
|
||||||
|
"role": ProjectMembership.Role.MEMBER,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client_response.status_code == 403
|
||||||
|
assert update_client_response.status_code == 403
|
||||||
|
assert delete_client_response.status_code == 403
|
||||||
|
assert project_response.status_code == 403
|
||||||
|
assert update_project_response.status_code == 403
|
||||||
|
assert archive_project_response.status_code == 403
|
||||||
|
assert delete_project_response.status_code == 403
|
||||||
|
assert membership_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
|
||||||
|
tag = Tag.objects.create(workspace=workspace, name="Existing", color="#000000")
|
||||||
|
api_client.force_authenticate(user=member)
|
||||||
|
|
||||||
|
create_tag_response = api_client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "New Tag", "color": "#ffffff"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_tag_response = api_client.patch(
|
||||||
|
f"/api/tags/{tag.id}/",
|
||||||
|
{"name": "Changed"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_tag_response = api_client.delete(f"/api/tags/{tag.id}/")
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
create_entry_response = api_client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"end_time": (now + timedelta(hours=1)).isoformat(),
|
||||||
|
"description": "Focus block",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert create_tag_response.status_code == 201
|
||||||
|
assert update_tag_response.status_code == 403
|
||||||
|
assert delete_tag_response.status_code == 403
|
||||||
|
assert create_entry_response.status_code == 201
|
||||||
|
|
||||||
|
entry_id = create_entry_response.data["id"]
|
||||||
|
update_entry_response = api_client.patch(
|
||||||
|
f"/api/time-entries/{entry_id}/",
|
||||||
|
{"description": "Updated focus block"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_entry_response = api_client.delete(f"/api/time-entries/{entry_id}/")
|
||||||
|
|
||||||
|
assert update_entry_response.status_code == 200
|
||||||
|
assert delete_entry_response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, workspace, project):
|
||||||
|
Client.objects.create(workspace=workspace, name="Visible Client", notes="")
|
||||||
|
Tag.objects.create(workspace=workspace, name="Visible Tag", color="#123456")
|
||||||
|
|
||||||
|
api_client.force_authenticate(user=guest)
|
||||||
|
|
||||||
|
list_clients_response = api_client.get(f"/api/clients/?workspace={workspace.id}")
|
||||||
|
list_projects_response = api_client.get(f"/api/projects/?workspace={workspace.id}")
|
||||||
|
create_tag_response = api_client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"workspace_id": str(workspace.id), "name": "Blocked", "color": "#ffffff"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
create_entry_response = api_client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"start_time": timezone.now().isoformat(),
|
||||||
|
"description": "Blocked guest entry",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
edit_project_response = api_client.patch(
|
||||||
|
f"/api/projects/{project.id}/",
|
||||||
|
{"description": "Blocked"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_clients_response.status_code == 200
|
||||||
|
assert list_projects_response.status_code == 200
|
||||||
|
assert create_tag_response.status_code == 403
|
||||||
|
assert create_entry_response.status_code == 403
|
||||||
|
assert edit_project_response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_project_manager_cannot_edit_project(api_client, member, project):
|
||||||
|
api_client.force_authenticate(user=member)
|
||||||
|
|
||||||
|
response = api_client.patch(
|
||||||
|
f"/api/projects/{project.id}/",
|
||||||
|
{"description": "Still blocked"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
|
||||||
|
api_client, owner, admin, extra_owner, workspace
|
||||||
|
):
|
||||||
|
extra_owner_membership = WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=extra_owner,
|
||||||
|
role=WorkspaceMembership.Role.OWNER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_client.force_authenticate(user=admin)
|
||||||
|
admin_response = api_client.patch(
|
||||||
|
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
||||||
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_client.force_authenticate(user=owner)
|
||||||
|
owner_response = api_client.patch(
|
||||||
|
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
||||||
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert admin_response.status_code == 403
|
||||||
|
assert owner_response.status_code == 200
|
||||||
Reference in New Issue
Block a user