Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 105 additions & 2 deletions apps/api/plane/app/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

# Third part imports
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.exceptions import APIException, MethodNotAllowed
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
Expand All @@ -45,6 +45,25 @@ def initial(self, request, *args, **kwargs):
timezone.deactivate()


# Actions that DRF's generic mixins implement for us. Authorization in this
# codebase lives on the concrete method — either an @allow_permission decorator
# or an inline role check — so an action served straight from a mixin runs with
# nothing but `permission_classes`. Where that is the bare default, the request
# is authenticated but not authorized at all.
_MIXIN_PROVIDED_ACTIONS = frozenset({"list", "retrieve", "create", "update", "partial_update", "destroy"})

# Permission classes that authorize nothing: neither says anything about what
# the caller may touch. `IsAuthenticated` only establishes that there is a
# caller; `AllowAny` does not even do that. A viewset carrying only these has
# delegated all of its authorization to per-method checks, so a mixin-served
# action has none.
#
# Membership-tested rather than compared against the default, so a declaration
# that is *weaker* than the default — `[AllowAny]`, or an empty list — is not
# mistaken for a deliberate restrictive one.
_NON_AUTHORIZING_PERMISSIONS = frozenset({IsAuthenticated, AllowAny})


class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator):
model = None

Expand All @@ -67,6 +86,90 @@ def get_queryset(self):
log_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)

@classmethod
def _action_owner(cls, action):
"""
Return the class in the MRO that actually implements `action`.

Used to tell "we wrote this method" from "DRF's generic mixin supplied
it". Inheriting the method from another class in this codebase counts as
ours: the authorization check travels with the implementation.
"""
for klass in cls.__mro__:
if action in vars(klass):
return klass
return None

def _resolved_action_is_authorized(self):
"""
Whether the action this request resolved to carries any authorization.

False only when a router mapped a verb to an action the viewset does not
implement, so the request is about to be served by a DRF mixin under the
bare default permission class. Every other shape is left alone.
"""
action = getattr(self, "action", None)

# No action resolved (e.g. an OPTIONS probe) — DRF handles it.
if action is None:
return True

# A custom @action is always explicitly written, so it carries whatever
# check its author put on it.
if action not in _MIXIN_PROVIDED_ACTIONS:
return True

# A genuinely restrictive class-level permission class authorizes every
# action uniformly, including mixin-served ones.
if any(permission not in _NON_AUTHORIZING_PERMISSIONS for permission in self.permission_classes):
return True

# Overriding perform_create is the documented way to ride
# CreateModelMixin.create while controlling what gets saved. Treat the
# override as the implementation, so this does not reject a deliberate
# pattern. NOTE: perform_create is a save hook, not an authorization
# hook — a viewset using it still needs its own permission check.
if action == "create" and self._action_owner("perform_create") is not None:
return True

# DRF's partial_update delegates to self.update(), so a viewset that
# implements update() authorizes PATCH transitively even without its own
# partial_update. No viewset in the tree does this today, but the guard
# must not punish it if one appears.
if action == "partial_update":
update_owner = self._action_owner("update")
if update_owner is not None and not update_owner.__module__.startswith("rest_framework"):
return True

owner = self._action_owner(action)
return owner is not None and not owner.__module__.startswith("rest_framework")

def initial(self, request, *args, **kwargs):
# Deliberately after super(), which runs authentication and the
# permission classes. Whatever they would have rejected is still
# rejected first and with their own status — an unauthenticated caller
# gets 401 from IsAuthenticated rather than learning from a 405 that the
# route exists.
super().initial(request, *args, **kwargs)

if not self._resolved_action_is_authorized():
# The route exists but nothing authorizes it. Refuse rather than let
# the generic mixin operate on whatever get_queryset() happens to
# return. Reported as "method not allowed" because the correct state
# of the world is that this verb was never meant to be routed here.
# Logged as a warning without a traceback: the refusal is expected
# and carries no stack worth capturing, and anyone can trigger it by
# calling a dead route in a loop. A full logger.exception() here
# would hand them unbounded error-log amplification.
log_exception(
Exception(
f"Refused unauthorized mixin-served action: "
f"{type(self).__name__}.{self.action} via {request.method} {request.path}"
),
warning=True,
)
raise MethodNotAllowed(request.method)

def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
Expand Down
10 changes: 10 additions & 0 deletions apps/api/plane/app/views/issue/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ def get_queryset(self):
.distinct()
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id, comment_id):
# Declared explicitly so the routed GET carries the same project-role
# check as create() below. Without it the action is served by DRF's
# generic list mixin, which runs under the bare default permission
# class: authenticated but not authorized. get_queryset() already scopes
# to active project members, so this states that guarantee in the
# authorization layer instead of relying on a queryset filter to hold.
return super().list(request, slug=slug, project_id=project_id, comment_id=comment_id)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, comment_id):
try:
Expand Down
10 changes: 10 additions & 0 deletions apps/api/plane/app/views/issue/reaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ def get_queryset(self):
.distinct()
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id, issue_id):
# Declared explicitly so the routed GET carries the same project-role
# check as create() below. Without it the action is served by DRF's
# generic list mixin, which runs under the bare default permission
# class: authenticated but not authorized. get_queryset() already scopes
# to active project members, so this states that guarantee in the
# authorization layer instead of relying on a queryset filter to hold.
return super().list(request, slug=slug, project_id=project_id, issue_id=issue_id)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
Expand Down
10 changes: 10 additions & 0 deletions apps/api/plane/app/views/state/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ def get_queryset(self):
.distinct()
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
# Declared explicitly so the routed GET carries the same project-role
# check as list() below. Without it the action is served by DRF's
# generic retrieve mixin under the bare default permission class:
# authenticated but not authorized. No client calls this today, but the
# route is live and a caller is already wired up one layer away, so it
# gets a real implementation rather than being left to fail closed.
return super().retrieve(request, slug=slug, project_id=project_id, pk=pk)

@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
Expand Down
23 changes: 23 additions & 0 deletions apps/api/plane/app/views/view/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ class WorkspaceViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView

@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def create(self, request, slug):
# Declared explicitly to put a workspace-membership check in front of
# the create. perform_create below resolves the workspace from the URL
# slug and saves, but it is a save hook, not an authorization hook: as
# an inherited generic create this ran under the bare default permission
# class, so any authenticated account could create a view in any
# workspace. Workspace slugs are human-readable, so no identifier had to
# be guessed. The role set matches list() — the defect is that
# non-members could write here at all, not which member roles may.
return super().create(request, slug=slug)

def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id, owned_by=self.request.user)
Expand Down Expand Up @@ -263,6 +275,17 @@ class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView

@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id):
# Declared explicitly to put a project-membership check in front of the
# create. perform_create below trusts project_id straight from the URL,
# and as an inherited generic create it ran under the bare default
# permission class — so any authenticated account holding a project
# identifier could create a view inside that project. The role set
# matches list()/retrieve(); the defect is that non-members could write
# here at all, not which member roles may.
return super().create(request, slug=slug, project_id=project_id)

def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"), owned_by=self.request.user)

Expand Down
12 changes: 12 additions & 0 deletions apps/api/plane/app/views/workspace/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,18 @@ def get_queryset(self):
super().get_queryset().filter(email=self.request.user.email).select_related("workspace")
)

def list(self, request):
# Declared explicitly rather than served by DRF's generic list mixin.
# This route is self-service and carries no `slug`, so the project/
# workspace role decorators cannot apply here: the authorization
# boundary IS the `email=request.user.email` filter in get_queryset()
# above, exactly as it is for create() below. Stating the action
# explicitly keeps that boundary from being silently widened if the
# queryset is ever refactored, and keeps this route out of the
# unauthorized-mixin-action class. The response carries the invite token,
# so the predicate is credential-grade and must not be relaxed.
return super().list(request)

@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
def create(self, request):
Expand Down
Loading
Loading