mirror of
https://github.com/makeplane/plane.git
synced 2026-06-14 03:30:00 +00:00
[WEB-7008] feat(permissions): authorized listing foundation + backend developer guide (#6832)
* fix(permissions): scope workspace work item list to accessible projects WorkItemListWorkspaceEndpoint previously returned every issue in the workspace to any workspace member. Apply an inline data-level filter: workspace owner/admin short-circuit via the workitem:* wildcard grant at workspace scope; everyone else is scoped to projects where they hold workitem:view, with the guest relation narrowed to own issues — mirroring the creator conditional grant on the project-scoped sibling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(permissions): authorized listing foundation + fix broken workspace listings Introduces the canonical pattern for workspace- and project-scoped listing endpoints that handles conditional grants (workitem:view+creator) correctly across direct memberships and teamspace link relations. Replaces the ad-hoc `get_accessible_resources` + rel == "guest" approach that silently dropped guests and under-returned in mixed-path scenarios. Pattern: class WorkItemListWorkspaceEndpoint(AuthorizedListingView, BaseAPIView): @can(WorkspacePermissions.VIEW, resource_param="workspace_id") def get(self, request, slug): queryset = Issue.issue_objects.filter(workspace__slug=slug) queryset = queryset.authorized_for(request, WorkitemPermissions.VIEW) return self.paginate(queryset) - New engine primitive `get_accessible_resources_with_conditions` returns `AccessibleResource(id, relation, conditions)` per scope tuple. Per- resource merge across paths: deny wins, unconditional upgrades conditional, multiple conditionals union. Lazy `get_conditions` call avoids redundant role lookups when unconditional grants apply. - Model-side `PermissionMeta` on Issue and IssueView declares `scope_map` (permission class → ScopeSpec) and `condition_fields` (Condition → Django field path). Single source of truth — the engine's per-resource `ConditionEvaluator` reads from the same meta (with hardcoded fallback for models not yet migrated). - `AuthorizationQuerySetMixin` adds `.authorized_for(request, permission)` and `.authorization_not_required(request)` to every queryset via `SoftDeletionQuerySet`. Resolves scope spec BEFORE the workspace-admin fast path so misconfig surfaces for admins too. - `AuthorizedListingView` mixin enforces via `finalize_response` that `.authorized_for()` was called. Returns a structured 500 Response (code=listing_authorization_misconfigured) instead of raising, so the response survives BaseAPIView.dispatch's outer exception wrapper with renderer + headers intact. Status-code guard skips the check on 4xx so query-param validation errors aren't masked. - Migrated WorkItemListWorkspaceEndpoint and WorkspaceViewIssuesViewSet to the new pattern. Supersedes the partial fix from the prior commit. - Contract fixture (`listing_auth` + `authorized_listing_roles` parametrize) covers the full matrix: owner, admin, contributor, guest with own issues, guest without own issues, workspace-member-no-project, true outsider (403 via scope gate). Assertions include total_count and total_results, not just count, so total_count_queryset divergence is caught. 327 tests pass across permission unit + listing contract suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(permissions): fix ruff lint errors - Move `AuthorizationQuerySetMixin` import to top of `db/mixins.py` (E402) - Remove unused `uuid4` import from `test_authorized_for_queryset.py` (F401) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(permissions): add PERMISSION_BACKEND_GUIDE.md developer guide Task-oriented backend-only guide (591 lines) organized around "I want to...": - Protect a single-resource / listing endpoint - Check a permission programmatically - Return _permissions on API responses - Grant / revoke (GAC) - Test a permission-gated endpoint - Add a new resource type end-to-end - Add or modify a role / permission scheme - Extend with a new condition Plus architecture-at-a-glance, glossary, and a troubleshooting playbook. Frontend audience is explicitly pointed at the existing PERMISSION_FE_REFACTOR_GUIDE.md. Leaves the existing PERMISSION_SYSTEM.md untouched — this doc is additive, not a replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
19663b0050
commit
e0ec00e6c6
@@ -34,6 +34,7 @@ from django.views.decorators.gzip import gzip_page
|
||||
from plane.app.views import BaseAPIView
|
||||
from plane.db.models import CycleIssue, FileAsset, Issue, IssueAssignee, IssueLabel, IssueLink, ModuleIssue
|
||||
from plane.permissions import (
|
||||
AuthorizedListingView,
|
||||
can,
|
||||
get_permission_conditions,
|
||||
WorkitemPermissions,
|
||||
@@ -270,18 +271,17 @@ class WorkItemListProjectEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class WorkItemListWorkspaceEndpoint(WorkItemListProjectEndpoint):
|
||||
"""Paginated work-item list with inline custom property values.
|
||||
class WorkItemListWorkspaceEndpoint(AuthorizedListingView, WorkItemListProjectEndpoint):
|
||||
"""Paginated work-item list across a workspace.
|
||||
|
||||
Returns up to 100 issues per page. Each issue includes all base
|
||||
fields plus a `property_values` dict keyed by property ID.
|
||||
Uses the canonical authorized-listing pattern:
|
||||
- @can(WorkspacePermissions.VIEW, ...) gates on workspace membership
|
||||
- .authorized_for(request, WorkitemPermissions.VIEW) narrows rows to
|
||||
projects the caller can view workitems in, with guest-relation
|
||||
projects further narrowed to issues the caller created
|
||||
|
||||
Query strategy (optimized to ~3 DB queries per request):
|
||||
1. Issues — filtered, annotated, ordered, paginated
|
||||
2. Property values — prefetched with joined property definitions
|
||||
and option names via Prefetch + select_related
|
||||
3. Property definitions — lightweight values_list query to build
|
||||
the type→properties map for default-filling
|
||||
AuthorizedListingView's finalize_response check enforces that
|
||||
.authorized_for() was called; omitting it returns a structured 500.
|
||||
"""
|
||||
|
||||
filter_backends = (
|
||||
@@ -368,13 +368,15 @@ class WorkItemListWorkspaceEndpoint(WorkItemListProjectEndpoint):
|
||||
@method_decorator(gzip_page)
|
||||
@can(WorkspacePermissions.VIEW, resource_param="workspace_id")
|
||||
def get(self, request, slug):
|
||||
# 1. Base queryset with eager-loaded property values
|
||||
# - Prefetch with to_attr: loads all property values in a single
|
||||
# query, joining property definitions and option names via
|
||||
# select_related("property", "value_option")
|
||||
issue_queryset = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
)
|
||||
# Canonical variable order for authorized listings:
|
||||
# 1. Build base queryset.
|
||||
# 2. Apply .authorized_for() FIRST — before any filters, annotations,
|
||||
# or prefetches. This ensures the snapshot for total_count_queryset
|
||||
# (below) inherits the authorization filter and the exposed
|
||||
# total_count / total_results reflects only rows the caller can see.
|
||||
# 3. THEN apply user-supplied filters, annotate, prefetch, order.
|
||||
issue_queryset = Issue.issue_objects.filter(workspace__slug=slug)
|
||||
issue_queryset = issue_queryset.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
|
||||
spreadsheet_custom_property_flag = check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.SPREADSHEET_CUSTOM_PROPERTIES,
|
||||
|
||||
@@ -37,6 +37,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.permissions import (
|
||||
AuthorizedListingView,
|
||||
can,
|
||||
WorkspacePermissions,
|
||||
WorkitemPermissions,
|
||||
@@ -162,7 +163,7 @@ class WorkspaceViewViewSet(PermissionMixin, BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(PermissionMixin, BaseViewSet):
|
||||
class WorkspaceViewIssuesViewSet(AuthorizedListingView, PermissionMixin, BaseViewSet):
|
||||
use_read_replica = True
|
||||
|
||||
filter_backends = (
|
||||
@@ -171,48 +172,6 @@ class WorkspaceViewIssuesViewSet(PermissionMixin, BaseViewSet):
|
||||
)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def _get_accessible_projects(self) -> dict:
|
||||
"""
|
||||
Get accessible projects where user can view issues.
|
||||
Cached per request to avoid duplicate queries.
|
||||
|
||||
Note: We query project tuples but check issue:view permission,
|
||||
because we're listing issues, not projects.
|
||||
"""
|
||||
if not hasattr(self, "_project_relations"):
|
||||
self._project_relations = self.get_accessible_resources(
|
||||
resource_type="project",
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
include_relations=True,
|
||||
)
|
||||
return self._project_relations
|
||||
|
||||
def _get_project_permission_filters(self):
|
||||
"""
|
||||
Get permission filters based on user's relation per project.
|
||||
Uses ResourcePermission tuples as source of truth.
|
||||
"""
|
||||
project_relations = self._get_accessible_projects()
|
||||
|
||||
# Separate projects by relation (guest vs non-guest)
|
||||
guest_project_ids = [pid for pid, rel in project_relations.items() if rel == "guest"]
|
||||
non_guest_project_ids = [pid for pid, rel in project_relations.items() if rel != "guest"]
|
||||
|
||||
# Handle empty lists - return a Q that matches nothing if no projects accessible
|
||||
if not guest_project_ids and not non_guest_project_ids:
|
||||
return Q(pk__in=[]) # Matches nothing
|
||||
|
||||
return Q(
|
||||
# Non-guest projects: show all issues
|
||||
Q(project_id__in=non_guest_project_ids)
|
||||
|
|
||||
# Guest projects: only user's own issues
|
||||
Q(
|
||||
project_id__in=guest_project_ids,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_order_by_field(self, order_by_param):
|
||||
"""
|
||||
Validate if the order_by parameter is a valid sortable field.
|
||||
@@ -392,18 +351,16 @@ class WorkspaceViewIssuesViewSet(PermissionMixin, BaseViewSet):
|
||||
return issues
|
||||
|
||||
def get_queryset(self):
|
||||
# Use permission engine to get accessible projects (replaces accessible_to)
|
||||
accessible_project_ids = list(self._get_accessible_projects().keys())
|
||||
|
||||
return Issue.issue_objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id__in=accessible_project_ids,
|
||||
)
|
||||
# Base queryset — authorization is applied in list() via .authorized_for().
|
||||
return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@can(WorkspacePermissions.VIEW, resource_param="workspace_id")
|
||||
def list(self, request, slug):
|
||||
issue_queryset = self.get_queryset()
|
||||
# Canonical variable order: authorize FIRST (before filters,
|
||||
# annotations, and the total_count_queryset snapshot) so the exposed
|
||||
# total_count / total_results reflects only rows the caller can see.
|
||||
issue_queryset = self.get_queryset().authorized_for(request, WorkitemPermissions.VIEW)
|
||||
|
||||
query_params = request.query_params.copy()
|
||||
sub_issue = query_params.get("sub_issue", None)
|
||||
@@ -424,12 +381,7 @@ class WorkspaceViewIssuesViewSet(PermissionMixin, BaseViewSet):
|
||||
if sub_issue and sub_issue == "false":
|
||||
issue_queryset = issue_queryset.filter(Q(parent__isnull=True) | (Q(parent__type__is_epic=True)))
|
||||
|
||||
# Get common project permission filters
|
||||
permission_filters = self._get_project_permission_filters()
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
|
||||
# Base query for the counts
|
||||
# Base query for the counts — inherits the authorization filter.
|
||||
total_issue_count_queryset = copy.deepcopy(issue_queryset)
|
||||
total_issue_count_queryset = total_issue_count_queryset.only("id")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.deletion_task import soft_delete_related_objects
|
||||
from plane.permissions.queryset import AuthorizationQuerySetMixin
|
||||
|
||||
# Relative imports
|
||||
from .signals import post_bulk_create, post_bulk_update
|
||||
@@ -106,7 +107,7 @@ class BulkOperationHooks:
|
||||
return objs
|
||||
|
||||
|
||||
class SoftDeletionQuerySet(BulkOperationHooks, models.QuerySet):
|
||||
class SoftDeletionQuerySet(AuthorizationQuerySetMixin, BulkOperationHooks, models.QuerySet):
|
||||
def delete(self, soft=True):
|
||||
if soft:
|
||||
return self.update(deleted_at=timezone.now())
|
||||
|
||||
@@ -26,6 +26,9 @@ from django.utils import timezone
|
||||
from plane.db.mixins import ChangeTrackerMixin, IssueActivityMixin, update_issue_last_activity_at
|
||||
from plane.db.models.project import ProjectManager
|
||||
from plane.db.signals import post_bulk_create, post_bulk_update
|
||||
from plane.permissions import WorkitemPermissions
|
||||
from plane.permissions.definitions import Condition
|
||||
from plane.permissions.meta import ScopeSpec
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
# Third party imports
|
||||
@@ -208,6 +211,24 @@ class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
issue_objects = IssueManager()
|
||||
issue_and_epics_objects = IssueAndEpicsManager()
|
||||
|
||||
class PermissionMeta:
|
||||
"""Listing-authorization meta for Issue.
|
||||
|
||||
Consumed by plane.permissions.meta.resolve_scope_spec via the
|
||||
.authorized_for() queryset verb. Declares which scope tuples the
|
||||
engine should query for `workitem:view` (project tuples) and which
|
||||
field on Issue joins to that scope (`project_id`). The creator
|
||||
condition maps to `created_by` — the same field the engine's per-
|
||||
resource ConditionEvaluator reads, so there's one source of truth.
|
||||
"""
|
||||
|
||||
scope_map = {
|
||||
WorkitemPermissions: ScopeSpec(resource_type="project", fk="project_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Store original values of semantic fields for change tracking
|
||||
|
||||
@@ -15,6 +15,12 @@ from django.db import models
|
||||
|
||||
# Module import
|
||||
from .project import ProjectOptionalBaseModel
|
||||
from plane.permissions import (
|
||||
WorkitemViewPermissions,
|
||||
WorkspaceWorkitemViewPermissions,
|
||||
)
|
||||
from plane.permissions.definitions import Condition
|
||||
from plane.permissions.meta import ScopeSpec
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.db.mixins import FiltersMixin
|
||||
|
||||
@@ -85,6 +91,28 @@ class IssueView(ProjectOptionalBaseModel, FiltersMixin):
|
||||
db_table = "issue_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
class PermissionMeta:
|
||||
"""Listing-authorization meta for IssueView — multi-scope.
|
||||
|
||||
IssueView represents both project views (project_id set) and
|
||||
workspace views (project_id null). Each flavor is gated by a
|
||||
different permission class, so the scope_map carries both entries:
|
||||
|
||||
- WorkitemViewPermissions → listed at project scope via project_id
|
||||
- WorkspaceWorkitemViewPermissions → listed at workspace scope via workspace_id
|
||||
|
||||
.authorized_for() picks the right entry based on which permission
|
||||
the caller passes.
|
||||
"""
|
||||
|
||||
scope_map = {
|
||||
WorkitemViewPermissions: ScopeSpec(resource_type="project", fk="project_id"),
|
||||
WorkspaceWorkitemViewPermissions: ScopeSpec(resource_type="workspace", fk="workspace_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
query_params = self.filters
|
||||
self.query = issue_filters(query_params, "POST") if query_params else {}
|
||||
|
||||
@@ -151,17 +151,35 @@ Ceiling: `enforce_project_role_ceiling()` — workspace guests capped at comment
|
||||
|
||||
### Design Principle
|
||||
|
||||
**Always check the specific resource permission, not the parent resource.**
|
||||
**For single-resource endpoints, check the specific resource permission at the decorator.**
|
||||
|
||||
```python
|
||||
# CORRECT: workitem:view for listing issues
|
||||
@can(WorkitemPermissions.VIEW, resource_param='project_id')
|
||||
# CORRECT: workitem:view for reading a specific issue
|
||||
@can(WorkitemPermissions.VIEW, resource_param='pk')
|
||||
def retrieve(self, request, pk): ...
|
||||
|
||||
# WRONG: project:view for listing issues
|
||||
@can(ProjectPermissions.VIEW, resource_param='project_id')
|
||||
# WRONG: project:view for a specific-issue endpoint
|
||||
@can(ProjectPermissions.VIEW, resource_param='pk')
|
||||
def retrieve(self, request, pk): ...
|
||||
```
|
||||
|
||||
This enables future roles with parent access but not child access (e.g., project metadata without issue access).
|
||||
**For listing endpoints, the specific permission is checked at the queryset layer via `.authorized_for()`; the decorator is the scope-membership gate.**
|
||||
|
||||
```python
|
||||
# CORRECT: scope-membership gate + .authorized_for() for row filtering
|
||||
class WorkItemListWorkspaceEndpoint(AuthorizedListingView, BaseAPIView):
|
||||
@can(WorkspacePermissions.VIEW, resource_param="workspace_id")
|
||||
def get(self, request, slug):
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug)
|
||||
queryset = queryset.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
return self.paginate(queryset)
|
||||
```
|
||||
|
||||
Rationale: `workitem:view` does not exist at workspace scope for non-admin roles — project contributors hold it on project tuples via their project role. Checking `WorkitemPermissions.VIEW` at workspace scope would reject every non-admin. The scope-membership gate (`WorkspacePermissions.VIEW` or `ProjectPermissions.VIEW`) confirms the caller participates in the scope; `.authorized_for()` then filters rows using accessible-resource traversal — the only mechanism that correctly handles conditional grants (creator, lead) across multiple scope resources.
|
||||
|
||||
`AuthorizedListingView` enforces at `finalize_response` time that `.authorized_for(request, ...)` or `.authorization_not_required(request)` was called. Missing the call produces a structured 500 with `code="listing_authorization_misconfigured"`, not a silent over-return.
|
||||
|
||||
See `designs/permissions/design-authorized-listing-pattern.md` for the full design.
|
||||
|
||||
### @can Decorator
|
||||
|
||||
|
||||
@@ -114,6 +114,13 @@ from .inheritance import (
|
||||
PROJECT_RESOURCE_TYPES,
|
||||
)
|
||||
from .engine import permission_engine, PermissionEngine
|
||||
from .engine.accessible_resource import AccessibleResource
|
||||
from .exceptions import (
|
||||
ListingAuthorizationConfigurationError,
|
||||
PermissionConfigurationError,
|
||||
)
|
||||
from .meta import ScopeSpec, resolve_condition_field, resolve_scope_spec
|
||||
from .view_mixin import AuthorizedListingView
|
||||
from .grants import Grant
|
||||
from .context import AccessResult, PermissionContext, PermissionScopeType
|
||||
from .sync import PermissionSyncMixin
|
||||
@@ -159,7 +166,17 @@ __all__ = [
|
||||
# Engine
|
||||
"permission_engine",
|
||||
"PermissionEngine",
|
||||
"AccessibleResource",
|
||||
"AccessResult",
|
||||
# Exceptions
|
||||
"ListingAuthorizationConfigurationError",
|
||||
"PermissionConfigurationError",
|
||||
# Meta
|
||||
"ScopeSpec",
|
||||
"resolve_condition_field",
|
||||
"resolve_scope_spec",
|
||||
# View mixins
|
||||
"AuthorizedListingView",
|
||||
"PermissionContext",
|
||||
"PermissionScopeType",
|
||||
# Grants
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""
|
||||
AccessibleResource — per-scope-resource authorization state for the listing
|
||||
primitive get_accessible_resources_with_conditions.
|
||||
|
||||
Conditions is an empty tuple for unconditional access. Multiple conditions
|
||||
are OR'd (matching the engine resolver at engine/resolver.py:172): a role
|
||||
that grants workitem:view+creator AND workitem:view+lead produces
|
||||
conditions=("creator", "lead") meaning access applies when the user is the
|
||||
creator OR the lead.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessibleResource:
|
||||
resource_id: UUID
|
||||
relation: str
|
||||
conditions: tuple[str, ...]
|
||||
|
||||
def is_unconditional(self) -> bool:
|
||||
return not self.conditions
|
||||
@@ -13,7 +13,14 @@
|
||||
Condition Evaluation
|
||||
|
||||
Evaluates conditional grants (e.g., "creator", "lead") against resource data.
|
||||
Each condition checks that a field matches the user AND the user has active membership.
|
||||
Each condition checks that a field matches the user AND the user has active
|
||||
membership on the resource.
|
||||
|
||||
Field resolution order for each condition:
|
||||
1. Model-declared `PermissionMeta.condition_fields` — per-model override.
|
||||
2. Hardcoded default (`created_by_id` for creator, `lead_id` for lead) —
|
||||
preserves backward compatibility for models that haven't declared
|
||||
PermissionMeta yet.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -26,6 +33,33 @@ from ..resource_models import get_model_for_resource
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Hardcoded fallbacks when a model has no PermissionMeta.condition_fields
|
||||
# entry. Django accepts both "created_by" and "created_by_id" for FK
|
||||
# filtering; the _id suffix matches the legacy behavior.
|
||||
_DEFAULT_CONDITION_FIELDS = {
|
||||
"creator": "created_by_id",
|
||||
"lead": "lead_id",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_field(model, condition: str) -> Optional[str]:
|
||||
"""Read the Django field path for `condition` from model.PermissionMeta.
|
||||
|
||||
Falls back to the hardcoded default when the model has no PermissionMeta
|
||||
or no entry for the condition. Returns None only when neither source
|
||||
yields a field (unknown condition on a model without PermissionMeta).
|
||||
"""
|
||||
if model is not None:
|
||||
try:
|
||||
from plane.permissions.exceptions import PermissionConfigurationError
|
||||
from plane.permissions.meta import resolve_condition_field
|
||||
|
||||
return resolve_condition_field(model, condition)
|
||||
except PermissionConfigurationError:
|
||||
pass
|
||||
return _DEFAULT_CONDITION_FIELDS.get(condition)
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""Evaluates conditional grants (creator, lead)."""
|
||||
|
||||
@@ -41,19 +75,15 @@ class ConditionEvaluator:
|
||||
hierarchy_chain: Optional[list] = None,
|
||||
direct_tuples: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Evaluate a condition against a specific resource.
|
||||
|
||||
Args:
|
||||
condition: The condition name (e.g., "creator", "lead")
|
||||
roles: RoleLookup instance for membership checks
|
||||
"""
|
||||
handler = getattr(self, f"_eval_condition_{condition}", None)
|
||||
if handler is None:
|
||||
"""Evaluate a condition against a specific resource."""
|
||||
model = resource_model or get_model_for_resource(resource_type)
|
||||
field = _resolve_field(model, condition)
|
||||
if field is None:
|
||||
logger.warning("[PERM] Unknown condition: %s", condition)
|
||||
return False
|
||||
return handler(
|
||||
user_id, resource_type, resource_id, workspace_id, roles, resource_model,
|
||||
return self._check_field_condition(
|
||||
field, user_id, resource_type, resource_id, workspace_id, roles,
|
||||
resource_model=model,
|
||||
hierarchy_chain=hierarchy_chain, direct_tuples=direct_tuples,
|
||||
)
|
||||
|
||||
@@ -94,23 +124,3 @@ class ConditionEvaluator:
|
||||
if not membership:
|
||||
logger.debug("[PERM] condition membership check failed for %s", resource_type)
|
||||
return membership
|
||||
|
||||
def _eval_condition_creator(
|
||||
self, user_id, resource_type, resource_id, workspace_id, roles, resource_model=None,
|
||||
hierarchy_chain=None, direct_tuples=None,
|
||||
):
|
||||
"""Check if user created the resource (created_by_id == user_id)."""
|
||||
return self._check_field_condition(
|
||||
"created_by_id", user_id, resource_type, resource_id, workspace_id, roles, resource_model,
|
||||
hierarchy_chain=hierarchy_chain, direct_tuples=direct_tuples,
|
||||
)
|
||||
|
||||
def _eval_condition_lead(
|
||||
self, user_id, resource_type, resource_id, workspace_id, roles, resource_model=None,
|
||||
hierarchy_chain=None, direct_tuples=None,
|
||||
):
|
||||
"""Check if user is the lead of the resource (lead_id == user_id)."""
|
||||
return self._check_field_condition(
|
||||
"lead_id", user_id, resource_type, resource_id, workspace_id, roles, resource_model,
|
||||
hierarchy_chain=hierarchy_chain, direct_tuples=direct_tuples,
|
||||
)
|
||||
|
||||
@@ -617,6 +617,13 @@ class PermissionEngine:
|
||||
def get_accessible_resources(self, user, resource_type, workspace_id, permission=None, include_relations=False):
|
||||
return self._queries.get_accessible_resources(user, resource_type, workspace_id, permission, include_relations)
|
||||
|
||||
def get_accessible_resources_with_conditions(
|
||||
self, user, permission, scope_resource_type, workspace_id,
|
||||
):
|
||||
return self._queries.get_accessible_resources_with_conditions(
|
||||
user, permission, scope_resource_type, workspace_id,
|
||||
)
|
||||
|
||||
def get_role_permission_list(self, relation, resource_type, workspace_id=None):
|
||||
return self._queries.get_role_permission_list(relation, resource_type, workspace_id)
|
||||
|
||||
|
||||
@@ -133,6 +133,164 @@ class PermissionQueries:
|
||||
return result
|
||||
return list(result.keys())
|
||||
|
||||
def get_accessible_resources_with_conditions(
|
||||
self,
|
||||
user,
|
||||
permission: Union[Permission, str],
|
||||
scope_resource_type: Union[ResourceType, str],
|
||||
workspace_id: ResourceID,
|
||||
) -> list:
|
||||
"""Like get_accessible_resources but preserves conditional-grant info.
|
||||
|
||||
Returns one AccessibleResource per scope-tuple that grants `permission`,
|
||||
either unconditionally (conditions=()) or via conditional grants such
|
||||
as workitem:view+creator (conditions=("creator",)). Multiple conditions
|
||||
on the same role are OR'd: conditions=("creator", "lead") means access
|
||||
applies when the user is the creator OR the lead.
|
||||
|
||||
Resolution order mirrors the engine resolver (engine/resolver.py:163):
|
||||
explicit deny > inline grant > role unconditional > role conditional.
|
||||
Link-relation traversal (e.g., teamspace → project) propagates
|
||||
conditions from the linked role the same way.
|
||||
|
||||
The legacy get_accessible_resources filters out conditional grants
|
||||
because RoleLookup.has_permission is documented as unconditional-only.
|
||||
This primitive consults both has_permission AND get_conditions so
|
||||
guests (with workitem:view+creator only) are not silently dropped.
|
||||
"""
|
||||
from plane.db.models import ResourcePermission
|
||||
from .accessible_resource import AccessibleResource
|
||||
|
||||
resource_type_str = str(scope_resource_type)
|
||||
user_id = user.id if hasattr(user, "id") else user
|
||||
permission_str = str(permission) if not isinstance(permission, str) else permission
|
||||
|
||||
results: dict[UUID, AccessibleResource] = {}
|
||||
# Explicit deny applies PER RESOURCE across all paths. A tuple with
|
||||
# permissions_deny=[permission_str] on resource X marks X denied; any
|
||||
# tuple that would grant X (direct or via link relation) is suppressed.
|
||||
# Deny does NOT short-circuit the whole call — other resources remain
|
||||
# accessible.
|
||||
denied_ids: set[UUID] = set()
|
||||
|
||||
def _merge(existing: AccessibleResource | None, new: AccessibleResource) -> AccessibleResource:
|
||||
"""Merge two grants for the same resource.
|
||||
|
||||
Rules:
|
||||
- If either path is unconditional, the result is unconditional.
|
||||
Multiple paths to the same resource with one unconditional
|
||||
means the caller has unconditional access — the conditional
|
||||
path adds no restriction.
|
||||
- Otherwise both paths are conditional: the conditions are
|
||||
UNIONed. "Access via path A when creator" OR "access via path
|
||||
B when lead" → access when creator OR lead. This matches
|
||||
engine/resolver.py's per-resource resolution.
|
||||
- Relation is kept from the first grant seen (relation is not
|
||||
used by the helper's Q-building; only conditions + scope_fk
|
||||
matter).
|
||||
"""
|
||||
if existing is None:
|
||||
return new
|
||||
if existing.is_unconditional() or new.is_unconditional():
|
||||
return AccessibleResource(
|
||||
resource_id=existing.resource_id,
|
||||
relation=existing.relation if existing.is_unconditional() else new.relation,
|
||||
conditions=(),
|
||||
)
|
||||
merged_conditions = tuple(sorted(set(existing.conditions) | set(new.conditions)))
|
||||
return AccessibleResource(
|
||||
resource_id=existing.resource_id,
|
||||
relation=existing.relation,
|
||||
conditions=merged_conditions,
|
||||
)
|
||||
|
||||
def _process_tuples(tuples_by_relation: dict[str, list[dict]]) -> None:
|
||||
"""Walk collected tuples, evaluate grants, merge into results."""
|
||||
_missing = object()
|
||||
for relation, tuples in tuples_by_relation.items():
|
||||
role_has = self._roles.has_permission(
|
||||
relation, permission_str, resource_type_str, workspace_id
|
||||
)
|
||||
role_conditions = _missing # lazy — only fetch when unconditional doesn't apply
|
||||
for tup in tuples:
|
||||
rid = tup["resource_id"]
|
||||
if rid in denied_ids:
|
||||
continue
|
||||
if permission_str in (tup["permissions_grant"] or []) or role_has:
|
||||
new_grant = AccessibleResource(
|
||||
resource_id=rid, relation=relation, conditions=(),
|
||||
)
|
||||
else:
|
||||
if role_conditions is _missing:
|
||||
role_conditions = self._roles.get_conditions(
|
||||
relation, permission_str, resource_type_str, workspace_id
|
||||
)
|
||||
if not role_conditions:
|
||||
continue
|
||||
new_grant = AccessibleResource(
|
||||
resource_id=rid, relation=relation,
|
||||
conditions=tuple(role_conditions),
|
||||
)
|
||||
results[rid] = _merge(results.get(rid), new_grant)
|
||||
|
||||
# --- Direct tuples ---
|
||||
direct_perms = (
|
||||
ResourcePermission.objects.filter(
|
||||
subject_type="user",
|
||||
subject_id=user_id,
|
||||
resource_type=resource_type_str,
|
||||
workspace_id=workspace_id,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()))
|
||||
.values("resource_id", "relation", "permissions_grant", "permissions_deny")
|
||||
)
|
||||
|
||||
by_relation: dict[str, list[dict]] = {}
|
||||
for tup in direct_perms:
|
||||
if permission_str in (tup["permissions_deny"] or []):
|
||||
denied_ids.add(tup["resource_id"])
|
||||
by_relation.setdefault(tup["relation"], []).append(tup)
|
||||
_process_tuples(by_relation)
|
||||
|
||||
# --- Link-relation traversal (e.g., teamspace → project) ---
|
||||
# We do NOT exclude resources already in `results` here — the link
|
||||
# path may upgrade a conditional direct grant to unconditional (or
|
||||
# add a different conditional alternative), and the merge logic
|
||||
# above takes care of combining them.
|
||||
for lr in get_link_relations(resource_type_str):
|
||||
user_link_subquery = (
|
||||
ResourcePermission.objects.filter(
|
||||
subject_type="user",
|
||||
subject_id=user_id,
|
||||
resource_type=lr.source_type,
|
||||
workspace_id=workspace_id,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()))
|
||||
.values("resource_id")
|
||||
)
|
||||
linked = (
|
||||
ResourcePermission.objects.filter(
|
||||
subject_type=lr.source_type,
|
||||
subject_id__in=user_link_subquery,
|
||||
resource_type=resource_type_str,
|
||||
workspace_id=workspace_id,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.values("resource_id", "relation", "permissions_grant", "permissions_deny")
|
||||
)
|
||||
link_by_relation: dict[str, list[dict]] = {}
|
||||
for tup in linked:
|
||||
if permission_str in (tup["permissions_deny"] or []):
|
||||
denied_ids.add(tup["resource_id"])
|
||||
# Remove any previously-added grant — deny wins.
|
||||
results.pop(tup["resource_id"], None)
|
||||
link_by_relation.setdefault(tup["relation"], []).append(tup)
|
||||
_process_tuples(link_by_relation)
|
||||
|
||||
return list(results.values())
|
||||
|
||||
def get_role_permission_list(
|
||||
self,
|
||||
relation: str,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Permission-system exceptions.
|
||||
|
||||
PermissionConfigurationError — programmer error surfaced at call-site when
|
||||
a model lacks PermissionMeta or the given permission isn't in its scope_map.
|
||||
Not an APIException; it signals a bug, not a client error.
|
||||
|
||||
ListingAuthorizationConfigurationError — raised at response finalize time
|
||||
when AuthorizedListingView detects that .authorized_for() was not called
|
||||
for a listing endpoint. APIException subclass so DRF's exception handler
|
||||
converts it to a structured 500 response with code on the ErrorDetail.
|
||||
"""
|
||||
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
class PermissionConfigurationError(Exception):
|
||||
"""Raised when the permission system is misconfigured at a call site."""
|
||||
|
||||
|
||||
class ListingAuthorizationConfigurationError(APIException):
|
||||
"""Raised by AuthorizedListingView when a listing endpoint fails to call
|
||||
queryset.authorized_for(request, permission) before responding.
|
||||
"""
|
||||
|
||||
status_code = 500
|
||||
default_detail = (
|
||||
"Listing endpoint did not call queryset.authorized_for(request, permission)."
|
||||
)
|
||||
default_code = "listing_authorization_misconfigured"
|
||||
@@ -0,0 +1,104 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Model-side permission meta — ScopeSpec and PermissionMeta discovery.
|
||||
|
||||
Models declare their listing authorization shape via a nested `PermissionMeta`
|
||||
class. Example:
|
||||
|
||||
class Issue(models.Model):
|
||||
class PermissionMeta:
|
||||
scope_map = {
|
||||
WorkitemPermissions: ScopeSpec(resource_type="project", fk="project_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
|
||||
`resolve_scope_spec` finds the ScopeSpec for a (model, permission_instance) by
|
||||
looking up the permission's container class in `scope_map`. The container
|
||||
class is resolved from the permission's resource_type via the existing
|
||||
_PERMISSION_CLASSES registry.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from plane.permissions.definitions import Permission, _PERMISSION_CLASSES
|
||||
from plane.permissions.exceptions import PermissionConfigurationError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeSpec:
|
||||
"""Where and how a model joins to a scope resource for authorization.
|
||||
|
||||
resource_type: the scope type the engine should query tuples for
|
||||
(e.g., "project" when listing workitems).
|
||||
fk: the Django field path on the model that joins to the scope resource
|
||||
(e.g., "project_id").
|
||||
"""
|
||||
|
||||
resource_type: str
|
||||
fk: str
|
||||
|
||||
|
||||
def _permission_container_class(permission: Permission):
|
||||
"""Resolve a Permission instance to its container class via the engine's
|
||||
_PERMISSION_CLASSES registry (keyed by ResourceType).
|
||||
"""
|
||||
return _PERMISSION_CLASSES.get(permission.resource_type)
|
||||
|
||||
|
||||
def resolve_scope_spec(model_cls, permission: Permission) -> ScopeSpec:
|
||||
"""Return the ScopeSpec declaring how `model_cls` joins to the scope
|
||||
resource for `permission`.
|
||||
|
||||
Raises PermissionConfigurationError if `model_cls` has no PermissionMeta
|
||||
or the permission's container class isn't in `scope_map`.
|
||||
"""
|
||||
meta = getattr(model_cls, "PermissionMeta", None)
|
||||
if meta is None:
|
||||
raise PermissionConfigurationError(
|
||||
f"{model_cls.__name__} has no PermissionMeta; cannot authorize "
|
||||
f"listing for permission {permission}."
|
||||
)
|
||||
scope_map = getattr(meta, "scope_map", None) or {}
|
||||
container = _permission_container_class(permission)
|
||||
if container is None or container not in scope_map:
|
||||
container_name = container.__name__ if container else str(permission.resource_type)
|
||||
raise PermissionConfigurationError(
|
||||
f"{model_cls.__name__}.PermissionMeta.scope_map does not contain "
|
||||
f"{container_name} — cannot authorize listing for permission {permission}."
|
||||
)
|
||||
return scope_map[container]
|
||||
|
||||
|
||||
def resolve_condition_field(model_cls, condition: str) -> str:
|
||||
"""Return the Django field path mapped to `condition` in the model's
|
||||
PermissionMeta.condition_fields.
|
||||
|
||||
Raises PermissionConfigurationError if meta is missing or the condition
|
||||
isn't mapped.
|
||||
"""
|
||||
meta = getattr(model_cls, "PermissionMeta", None)
|
||||
if meta is None:
|
||||
raise PermissionConfigurationError(
|
||||
f"{model_cls.__name__} has no PermissionMeta; cannot evaluate "
|
||||
f"condition {condition!r}."
|
||||
)
|
||||
condition_fields = getattr(meta, "condition_fields", None) or {}
|
||||
for key, field in condition_fields.items():
|
||||
key_str = key.value if hasattr(key, "value") else str(key)
|
||||
if key_str == condition:
|
||||
return field
|
||||
raise PermissionConfigurationError(
|
||||
f"{model_cls.__name__}.PermissionMeta.condition_fields does not map "
|
||||
f"condition {condition!r}."
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""AuthorizationQuerySetMixin — the canonical .authorized_for() listing verb.
|
||||
|
||||
Adds two methods to any queryset class it's mixed into:
|
||||
|
||||
queryset.authorized_for(request, permission)
|
||||
Filter rows to what the caller may view under `permission`. Uses the
|
||||
model's PermissionMeta to find the scope + FK + condition fields.
|
||||
Applies the engine's workspace-admin fast path, then the accessible-
|
||||
resources-with-conditions primitive, then builds an OR'd Q across
|
||||
unconditional and conditional buckets.
|
||||
|
||||
queryset.authorization_not_required(request)
|
||||
Explicit bypass for genuinely public listings. Sets the same request
|
||||
flag as .authorized_for() so AuthorizedListingView's finalize_response
|
||||
check passes.
|
||||
|
||||
Both methods set `request._authorized_for_called = True`.
|
||||
"""
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from plane.permissions.context import PermissionContext
|
||||
from plane.permissions.definitions import Permission
|
||||
from plane.permissions.engine.core import permission_engine
|
||||
from plane.permissions.meta import resolve_condition_field, resolve_scope_spec
|
||||
|
||||
|
||||
class AuthorizationQuerySetMixin:
|
||||
"""Listing-authorization methods for Django QuerySet classes."""
|
||||
|
||||
def authorized_for(self, request, permission: Permission):
|
||||
"""Narrow the queryset to rows the caller can view under `permission`."""
|
||||
request._authorized_for_called = True
|
||||
user = request.user
|
||||
workspace_id = request.workspace_id
|
||||
|
||||
# Resolve the scope spec FIRST — before the admin fast path — so
|
||||
# misconfiguration (missing PermissionMeta, permission not in
|
||||
# scope_map) surfaces as PermissionConfigurationError regardless of
|
||||
# the caller's role. Otherwise admins would silently bypass the
|
||||
# validation and the misconfig would only show up for non-admins.
|
||||
spec = resolve_scope_spec(self.model, permission)
|
||||
|
||||
# Workspace-scope fast path: owner/admin wildcard grants bypass the
|
||||
# per-project tuple walk. This is the common case for admin actions.
|
||||
if permission_engine.check(
|
||||
user=user,
|
||||
permission=permission,
|
||||
context=PermissionContext.workspace(workspace_id),
|
||||
):
|
||||
return self
|
||||
|
||||
accessible = permission_engine.get_accessible_resources_with_conditions(
|
||||
user=user,
|
||||
permission=permission,
|
||||
scope_resource_type=spec.resource_type,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
if not accessible:
|
||||
return self.none()
|
||||
|
||||
unconditional_ids = [ar.resource_id for ar in accessible if ar.is_unconditional()]
|
||||
conditional = [ar for ar in accessible if not ar.is_unconditional()]
|
||||
|
||||
q = Q()
|
||||
matched_any = False
|
||||
|
||||
if unconditional_ids:
|
||||
q |= Q(**{f"{spec.fk}__in": unconditional_ids})
|
||||
matched_any = True
|
||||
|
||||
for ar in conditional:
|
||||
# Conditions on one relation are OR'd: `+creator` AND `+lead` on the
|
||||
# same role means access applies when the user is the creator OR
|
||||
# the lead. Matches engine/resolver.py:172.
|
||||
cond_q = Q()
|
||||
for condition in ar.conditions:
|
||||
field = resolve_condition_field(self.model, condition)
|
||||
cond_q |= Q(**{field: user})
|
||||
q |= Q(**{spec.fk: ar.resource_id}) & cond_q
|
||||
matched_any = True
|
||||
|
||||
if not matched_any:
|
||||
return self.none()
|
||||
|
||||
return self.filter(q)
|
||||
|
||||
def authorization_not_required(self, request):
|
||||
"""Explicit bypass for public listings with no per-row authorization.
|
||||
|
||||
Sets request._authorized_for_called = True so AuthorizedListingView
|
||||
passes its check. Call sites are searchable: grep for the method name
|
||||
to audit every bypass.
|
||||
"""
|
||||
request._authorized_for_called = True
|
||||
return self
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""AuthorizedListingView — view mixin that enforces .authorized_for() on
|
||||
listing endpoints.
|
||||
|
||||
Opt-in: only views that mix this in are checked. Non-listing paginated
|
||||
endpoints are unaffected.
|
||||
|
||||
Fires only on successful responses (status < 400) to avoid masking a valid
|
||||
early 400 with a configuration-error 500. Matches the existing deferred-
|
||||
condition guard in BaseAPIView.finalize_response.
|
||||
|
||||
Enforcement strategy: instead of RAISING from finalize_response (which would
|
||||
be caught by BaseAPIView.dispatch's outer try/except and result in an
|
||||
unfinalized error response — no accepted_renderer, broken content
|
||||
negotiation), we SWAP the response for a structured 500 Response BEFORE
|
||||
calling super().finalize_response(). Super then finalizes the error
|
||||
response normally, attaching the renderer + headers the same way it would
|
||||
for any other response. The client sees a proper JSON 500 with a
|
||||
machine-readable `code`.
|
||||
|
||||
MRO: list this mixin BEFORE BaseAPIView / BaseViewSet in the class bases so
|
||||
its finalize_response runs before the base's.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CONFIGURATION_ERROR_CODE = "listing_authorization_misconfigured"
|
||||
|
||||
|
||||
class AuthorizedListingView:
|
||||
"""Enforce that listing endpoints call .authorized_for() or
|
||||
.authorization_not_required() on their queryset.
|
||||
"""
|
||||
|
||||
_authorized_listing_actions = frozenset({"list", "get"})
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
# Evaluate the check BEFORE super finalizes. If it fails we swap in
|
||||
# a structured 500 Response so super can attach the renderer +
|
||||
# headers the normal way. Raising here would break because Plane's
|
||||
# BaseAPIView.dispatch catches exceptions and returns the result of
|
||||
# handle_exception WITHOUT re-finalizing (see app/views/base.py:146).
|
||||
if response.status_code < 400:
|
||||
action = getattr(self, "action", None) or request.method.lower()
|
||||
if action in self._authorized_listing_actions and not getattr(
|
||||
request, "_authorized_for_called", False
|
||||
):
|
||||
logger.error(
|
||||
"[PERM] %s.%s did not call .authorized_for()",
|
||||
type(self).__name__, action,
|
||||
)
|
||||
response = Response(
|
||||
data={
|
||||
"detail": (
|
||||
f"{type(self).__name__}.{action} did not call "
|
||||
f"queryset.authorized_for(request, permission) or "
|
||||
f"queryset.authorization_not_required(request). "
|
||||
"Listing endpoints must authorize their queryset explicitly."
|
||||
),
|
||||
"code": CONFIGURATION_ERROR_CODE,
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
return super().finalize_response(request, response, *args, **kwargs)
|
||||
@@ -0,0 +1,65 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Contract tests for WorkItemListWorkspaceEndpoint post-migration.
|
||||
|
||||
Covers the full role matrix via the shared listing-auth fixture — if
|
||||
.authorized_for() is ever missed or the filter regresses, this fixture
|
||||
catches it. Assertions include total_count / total_results (not `count`,
|
||||
which is current-page length) to catch total_count_queryset divergence.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.tests.contract.conftest_listing_authorization import (
|
||||
EXPECTED_FORBIDDEN,
|
||||
authorized_listing_roles,
|
||||
expected_ids_from_fixtures,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
class TestWorkItemListWorkspaceAuthorized:
|
||||
@authorized_listing_roles
|
||||
def test_role_matrix(
|
||||
self, role, expected_ids_key, listing_auth, api_client,
|
||||
):
|
||||
user = listing_auth.users[role]
|
||||
api_client.force_authenticate(user=user)
|
||||
url = f"/api/workspaces/{listing_auth.workspace.slug}/work-items/"
|
||||
response = api_client.get(url)
|
||||
|
||||
if expected_ids_key == EXPECTED_FORBIDDEN:
|
||||
# Scope-membership gate: callers outside the workspace fail
|
||||
# @can(WorkspacePermissions.VIEW) → 403. This exercises the
|
||||
# decorator path separately from .authorized_for()'s row filter.
|
||||
assert response.status_code == 403, (
|
||||
f"Role {role!r}: expected 403, got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
return
|
||||
|
||||
assert response.status_code == 200, (
|
||||
f"Expected 200 for role {role!r}, got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
expected = expected_ids_from_fixtures(listing_auth, expected_ids_key)
|
||||
returned = {row["id"] for row in response.data["results"]}
|
||||
expected_str = {str(i) for i in expected}
|
||||
assert returned == expected_str, (
|
||||
f"Role {role!r}: expected {expected_str} got {returned}"
|
||||
)
|
||||
# total_count / total_results are the authorization-sensitive totals
|
||||
# (count is just current-page length). Both must reflect the
|
||||
# authorized row set.
|
||||
assert response.data["total_count"] == len(expected), (
|
||||
f"Role {role!r}: total_count {response.data['total_count']} != expected {len(expected)}"
|
||||
)
|
||||
assert response.data["total_results"] == len(expected)
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Contract tests for WorkspaceViewIssuesViewSet post-migration.
|
||||
|
||||
Endpoint: GET /api/workspaces/<slug>/issues/ (the workspace-view issues
|
||||
listing previously broken by the same `get_accessible_resources` /
|
||||
rel == "guest" bug as WorkItemListWorkspaceEndpoint).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.tests.contract.conftest_listing_authorization import (
|
||||
EXPECTED_FORBIDDEN,
|
||||
authorized_listing_roles,
|
||||
expected_ids_from_fixtures,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
class TestWorkspaceViewIssuesAuthorized:
|
||||
@authorized_listing_roles
|
||||
def test_role_matrix(
|
||||
self, role, expected_ids_key, listing_auth, api_client,
|
||||
):
|
||||
user = listing_auth.users[role]
|
||||
api_client.force_authenticate(user=user)
|
||||
url = f"/api/workspaces/{listing_auth.workspace.slug}/issues/"
|
||||
response = api_client.get(url)
|
||||
|
||||
if expected_ids_key == EXPECTED_FORBIDDEN:
|
||||
assert response.status_code == 403, (
|
||||
f"Role {role!r}: expected 403, got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
return
|
||||
|
||||
assert response.status_code == 200, (
|
||||
f"Expected 200 for role {role!r}, got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
expected = expected_ids_from_fixtures(listing_auth, expected_ids_key)
|
||||
# This endpoint's response uses issue_on_results which returns raw
|
||||
# UUID objects (vs string UUIDs elsewhere). Normalize both sides.
|
||||
results = response.data.get("results", [])
|
||||
returned = {str(row["id"]) for row in results}
|
||||
expected_str = {str(i) for i in expected}
|
||||
assert returned == expected_str, (
|
||||
f"Role {role!r}: expected {expected_str} got {returned}"
|
||||
)
|
||||
assert response.data["total_count"] == len(expected)
|
||||
assert response.data["total_results"] == len(expected)
|
||||
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
# Re-export shared listing-authorization fixtures so every contract test
|
||||
# module (app/, api/) gets them via pytest's conftest auto-discovery.
|
||||
from plane.tests.contract.conftest_listing_authorization import ( # noqa: F401
|
||||
EXPECTED_EMPTY,
|
||||
EXPECTED_FORBIDDEN,
|
||||
authorized_listing_roles,
|
||||
expected_ids_from_fixtures,
|
||||
listing_auth,
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Shared contract-test fixture for listing-authorization behavior.
|
||||
|
||||
Builds a workspace with two projects and issues distributed across users
|
||||
holding different roles. The `authorized_listing_roles` parametrize covers
|
||||
the full matrix — new listing endpoints adopt the fixture and gain role-
|
||||
coverage testing for free.
|
||||
|
||||
Usage in a contract test file:
|
||||
|
||||
from plane.tests.contract.conftest_listing_authorization import (
|
||||
authorized_listing_roles,
|
||||
expected_ids_from_fixtures,
|
||||
)
|
||||
|
||||
@authorized_listing_roles
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
def test_work_item_list_workspace(
|
||||
role, expected_ids_key, listing_auth, api_client,
|
||||
):
|
||||
api_client.force_authenticate(user=listing_auth.users[role])
|
||||
url = f"/api/workspaces/{listing_auth.workspace.slug}/work-items/"
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == 200
|
||||
expected = expected_ids_from_fixtures(listing_auth, expected_ids_key)
|
||||
assert {row["id"] for row in response.data["results"]} == expected
|
||||
assert response.data["total_count"] == len(expected)
|
||||
assert response.data["total_results"] == len(expected)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from crum import impersonate
|
||||
|
||||
from plane.db.models import (
|
||||
Issue, Project, ProjectMember, State, User, Workspace, WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListingAuthorizationFixtures:
|
||||
workspace: Workspace
|
||||
project_a: Project
|
||||
project_b: Project
|
||||
all_issue_ids: set
|
||||
project_a_issue_ids: set
|
||||
project_b_issue_ids: set
|
||||
guest_a_own_issue_ids: set
|
||||
users: dict
|
||||
|
||||
|
||||
def _mk_user(prefix: str) -> User:
|
||||
uid = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"{prefix}-{uid}@listing.test",
|
||||
username=f"{prefix}_{uid}",
|
||||
first_name=prefix.title(),
|
||||
last_name="User",
|
||||
)
|
||||
user.set_password("testpass")
|
||||
user.save(update_fields=["password"])
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listing_auth(db) -> ListingAuthorizationFixtures:
|
||||
"""Build the workspace/projects/issues/users for role-matrix testing.
|
||||
|
||||
Workspace has two projects; owner creates some issues; contributors
|
||||
and guests in project A create / see issues according to their roles;
|
||||
project B is off-limits to all but owner/admin.
|
||||
"""
|
||||
owner = _mk_user("owner")
|
||||
workspace = Workspace.objects.create(
|
||||
name="Listing Auth Test",
|
||||
slug=f"listing-auth-{uuid4().hex[:8]}",
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
# Users and their roles. Two user categories:
|
||||
# - Inside the workspace: owner, admin, contributor, guests, and a
|
||||
# workspace-member-with-no-project-access. These pass the scope gate
|
||||
# (@can(WorkspacePermissions.VIEW)) and are filtered at the row level.
|
||||
# - Outside the workspace: `outsider` has no workspace membership at
|
||||
# all and is expected to fail the scope gate with 403.
|
||||
users: dict[str, User] = {"owner": owner}
|
||||
for slug in (
|
||||
"admin",
|
||||
"contributor_a",
|
||||
"guest_a_created_some",
|
||||
"guest_a_created_none",
|
||||
"workspace_member_no_project",
|
||||
"outsider",
|
||||
):
|
||||
users[slug] = _mk_user(slug)
|
||||
|
||||
# Workspace memberships
|
||||
# Owner: role=20 already mapped to owner via Workspace.owner FK (on Business/Enterprise plans)
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=owner, role=20, is_active=True)
|
||||
# Admin: workspace role=20 (admin on Business/Enterprise plans; owner on Free/Pro/One)
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=users["admin"], role=20, is_active=True)
|
||||
# Workspace members (no admin): pass @can gate, filtered at row level
|
||||
for slug in (
|
||||
"contributor_a",
|
||||
"guest_a_created_some",
|
||||
"guest_a_created_none",
|
||||
"workspace_member_no_project",
|
||||
):
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=users[slug], role=15, is_active=True)
|
||||
# outsider: intentionally NOT a workspace member. Expected to 403.
|
||||
|
||||
# Projects
|
||||
with impersonate(owner):
|
||||
project_a = Project.objects.create(
|
||||
name="Project A", identifier=f"PA{uuid4().hex[:4].upper()}",
|
||||
workspace=workspace, network=2,
|
||||
)
|
||||
project_b = Project.objects.create(
|
||||
name="Project B", identifier=f"PB{uuid4().hex[:4].upper()}",
|
||||
workspace=workspace, network=2,
|
||||
)
|
||||
|
||||
# Project memberships in A
|
||||
ProjectMember.objects.create(
|
||||
project=project_a, workspace=workspace, member=users["contributor_a"],
|
||||
role=15, is_active=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project_a, workspace=workspace, member=users["guest_a_created_some"],
|
||||
role=5, is_active=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project_a, workspace=workspace, member=users["guest_a_created_none"],
|
||||
role=5, is_active=True,
|
||||
)
|
||||
|
||||
# Default states
|
||||
with impersonate(owner):
|
||||
state_a = State.objects.create(
|
||||
project=project_a, workspace=workspace, name="Backlog",
|
||||
group="backlog", default=True,
|
||||
)
|
||||
state_b = State.objects.create(
|
||||
project=project_b, workspace=workspace, name="Backlog",
|
||||
group="backlog", default=True,
|
||||
)
|
||||
|
||||
all_ids: set[UUID] = set()
|
||||
a_ids: set[UUID] = set()
|
||||
b_ids: set[UUID] = set()
|
||||
guest_own_ids: set[UUID] = set()
|
||||
|
||||
# 3 issues in A — created by owner, contributor, and guest-with-some
|
||||
creators_a = [owner, users["contributor_a"], users["guest_a_created_some"]]
|
||||
for i, creator in enumerate(creators_a):
|
||||
with impersonate(creator):
|
||||
issue = Issue.objects.create(
|
||||
project=project_a, workspace=workspace, state=state_a,
|
||||
name=f"Issue A{i}",
|
||||
)
|
||||
all_ids.add(issue.id)
|
||||
a_ids.add(issue.id)
|
||||
if creator is users["guest_a_created_some"]:
|
||||
guest_own_ids.add(issue.id)
|
||||
|
||||
# 2 issues in B — owner only
|
||||
for i in range(2):
|
||||
with impersonate(owner):
|
||||
issue = Issue.objects.create(
|
||||
project=project_b, workspace=workspace, state=state_b,
|
||||
name=f"Issue B{i}",
|
||||
)
|
||||
all_ids.add(issue.id)
|
||||
b_ids.add(issue.id)
|
||||
|
||||
return ListingAuthorizationFixtures(
|
||||
workspace=workspace,
|
||||
project_a=project_a,
|
||||
project_b=project_b,
|
||||
all_issue_ids=all_ids,
|
||||
project_a_issue_ids=a_ids,
|
||||
project_b_issue_ids=b_ids,
|
||||
guest_a_own_issue_ids=guest_own_ids,
|
||||
users=users,
|
||||
)
|
||||
|
||||
|
||||
# Parametrize marker — each row is (user-slug, fixture-attribute-key).
|
||||
# Contract tests decorate with this to cover the full role matrix.
|
||||
#
|
||||
# `expected_ids_key` values:
|
||||
# - Named set attribute → status 200, response row IDs must equal the set.
|
||||
# - "empty" → status 200, response row IDs empty.
|
||||
# - "forbidden" → status 403 (caller fails the scope-membership gate).
|
||||
authorized_listing_roles = pytest.mark.parametrize(
|
||||
"role,expected_ids_key",
|
||||
[
|
||||
("owner", "all_issue_ids"),
|
||||
("admin", "all_issue_ids"),
|
||||
("contributor_a", "project_a_issue_ids"),
|
||||
("guest_a_created_some", "guest_a_own_issue_ids"),
|
||||
("guest_a_created_none", "empty"),
|
||||
("workspace_member_no_project", "empty"),
|
||||
("outsider", "forbidden"),
|
||||
# Future: custom-role test (project:view without workitem:view) →
|
||||
# requires building a custom Role + PermissionScheme. Covered in a
|
||||
# follow-up once custom-role fixture utilities exist in conftest.
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
EXPECTED_FORBIDDEN = "forbidden"
|
||||
EXPECTED_EMPTY = "empty"
|
||||
|
||||
|
||||
def expected_ids_from_fixtures(fixtures: ListingAuthorizationFixtures, key: str) -> set:
|
||||
if key in (EXPECTED_EMPTY, EXPECTED_FORBIDDEN):
|
||||
return set()
|
||||
return getattr(fixtures, key)
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Smoke test — verifies the listing-auth fixture builds correctly and
|
||||
the role → expected-IDs lookup works.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
def test_fixture_builds(listing_auth):
|
||||
assert listing_auth.workspace is not None
|
||||
assert listing_auth.project_a.workspace_id == listing_auth.workspace.id
|
||||
assert listing_auth.project_b.workspace_id == listing_auth.workspace.id
|
||||
assert len(listing_auth.all_issue_ids) == 5
|
||||
assert len(listing_auth.project_a_issue_ids) == 3
|
||||
assert len(listing_auth.project_b_issue_ids) == 2
|
||||
assert len(listing_auth.guest_a_own_issue_ids) == 1
|
||||
assert listing_auth.all_issue_ids == (
|
||||
listing_auth.project_a_issue_ids | listing_auth.project_b_issue_ids
|
||||
)
|
||||
assert listing_auth.guest_a_own_issue_ids.issubset(listing_auth.project_a_issue_ids)
|
||||
# Users present — both workspace-member users (pass @can gate) and the
|
||||
# outsider (fails @can gate, tests the scope-membership path explicitly).
|
||||
for slug in (
|
||||
"owner", "admin", "contributor_a",
|
||||
"guest_a_created_some", "guest_a_created_none",
|
||||
"workspace_member_no_project", "outsider",
|
||||
):
|
||||
assert slug in listing_auth.users
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.permissions.engine.accessible_resource import AccessibleResource
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAccessibleResource:
|
||||
def test_unconditional_access(self):
|
||||
rid = uuid4()
|
||||
ar = AccessibleResource(resource_id=rid, relation="contributor", conditions=())
|
||||
assert ar.resource_id == rid
|
||||
assert ar.relation == "contributor"
|
||||
assert ar.conditions == ()
|
||||
assert ar.is_unconditional() is True
|
||||
|
||||
def test_single_condition(self):
|
||||
ar = AccessibleResource(resource_id=uuid4(), relation="guest", conditions=("creator",))
|
||||
assert ar.is_unconditional() is False
|
||||
assert ar.conditions == ("creator",)
|
||||
|
||||
def test_multiple_conditions(self):
|
||||
ar = AccessibleResource(
|
||||
resource_id=uuid4(), relation="custom_role", conditions=("creator", "lead")
|
||||
)
|
||||
assert ar.is_unconditional() is False
|
||||
assert set(ar.conditions) == {"creator", "lead"}
|
||||
|
||||
def test_is_frozen(self):
|
||||
ar = AccessibleResource(resource_id=uuid4(), relation="admin", conditions=())
|
||||
with pytest.raises(Exception):
|
||||
ar.resource_id = uuid4() # type: ignore[misc]
|
||||
@@ -0,0 +1,219 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Unit tests for PermissionEngine.get_accessible_resources_with_conditions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.db.models import ResourcePermission
|
||||
from plane.permissions import WorkitemPermissions
|
||||
from plane.permissions.engine.accessible_resource import AccessibleResource
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestGetAccessibleResourcesWithConditions:
|
||||
"""New primitive that handles conditional grants (unlike get_accessible_resources)."""
|
||||
|
||||
def test_contributor_returns_unconditional(
|
||||
self, engine, perm_workspace, perm_project, project_contributor, member_user,
|
||||
):
|
||||
"""Contributor holds workitem:view unconditionally — conditions=()."""
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=member_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
assert len(results) == 1
|
||||
ar = results[0]
|
||||
assert isinstance(ar, AccessibleResource)
|
||||
assert ar.resource_id == perm_project.id
|
||||
assert ar.relation == "contributor"
|
||||
assert ar.conditions == ()
|
||||
assert ar.is_unconditional() is True
|
||||
|
||||
def test_guest_returns_creator_condition(
|
||||
self, engine, perm_workspace, perm_project, project_guest, guest_user,
|
||||
):
|
||||
"""Project guest holds workitem:view+creator (conditional) — must be returned."""
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=guest_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
assert len(results) == 1
|
||||
ar = results[0]
|
||||
assert ar.resource_id == perm_project.id
|
||||
assert ar.relation == "guest"
|
||||
assert ar.conditions == ("creator",)
|
||||
assert ar.is_unconditional() is False
|
||||
|
||||
def test_admin_returns_unconditional_via_wildcard(
|
||||
self, engine, perm_workspace, perm_project, project_admin, admin_user,
|
||||
):
|
||||
"""Project admin holds workitem:* — wildcard is unconditional."""
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=admin_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0].relation == "admin"
|
||||
assert results[0].conditions == ()
|
||||
|
||||
def test_no_membership_returns_empty(
|
||||
self, engine, perm_workspace, perm_project, outsider_user,
|
||||
):
|
||||
"""User without any tuple returns nothing — no silent leak."""
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=outsider_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
assert results == []
|
||||
|
||||
def test_explicit_deny_excludes_resource(
|
||||
self, engine, perm_workspace, perm_project, project_contributor, member_user,
|
||||
):
|
||||
"""permissions_deny on the tuple takes precedence over role grant."""
|
||||
ResourcePermission.objects.filter(
|
||||
subject_type="user", subject_id=member_user.id,
|
||||
resource_type="project", resource_id=perm_project.id,
|
||||
).update(permissions_deny=["workitem:view"])
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=member_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
assert results == []
|
||||
|
||||
def test_inline_grant_is_unconditional(
|
||||
self, engine, perm_workspace, perm_project, project_guest, guest_user,
|
||||
):
|
||||
"""permissions_grant on the tuple makes the resource unconditional
|
||||
even when the role itself grants only conditional."""
|
||||
ResourcePermission.objects.filter(
|
||||
subject_type="user", subject_id=guest_user.id,
|
||||
resource_type="project", resource_id=perm_project.id,
|
||||
).update(permissions_grant=["workitem:view"])
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=guest_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
assert len(results) == 1
|
||||
# Inline grant overrides conditional → conditions=()
|
||||
assert results[0].conditions == ()
|
||||
|
||||
def test_teamspace_link_relation_unconditional(
|
||||
self, engine, perm_workspace, perm_project, perm_teamspace,
|
||||
teamspace_member_fixture, teamspace_project_link, member_user,
|
||||
):
|
||||
"""User in teamspace; teamspace grants project access at contributor level."""
|
||||
# teamspace_project_link creates (teamspace, member, project) tuple by default
|
||||
# member_user is in teamspace via teamspace_member_fixture
|
||||
# Teamspace 'member' role holds workitem:view unconditionally at the project scope
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=member_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
project_ids = [r.resource_id for r in results]
|
||||
assert perm_project.id in project_ids
|
||||
|
||||
def test_direct_conditional_merged_with_link_unconditional(
|
||||
self, engine, perm_workspace, perm_project, perm_teamspace,
|
||||
teamspace_member_fixture, teamspace_project_link, guest_user, ws_guest,
|
||||
):
|
||||
"""Multiple paths to the same resource must merge, with unconditional
|
||||
upgrading conditional. Regression for a bug where the link-path
|
||||
walker excluded resources already granted directly — a direct guest
|
||||
grant (conditions=('creator',)) suppressed the link-path contributor
|
||||
grant (unconditional), causing the listing helper to under-return.
|
||||
|
||||
Scenario:
|
||||
- guest_user is direct project guest on perm_project → grant is
|
||||
workitem:view+creator (conditional).
|
||||
- guest_user also belongs to perm_teamspace via an added teamspace
|
||||
membership; perm_teamspace is linked to perm_project with
|
||||
contributor relation → grant is workitem:view (unconditional).
|
||||
|
||||
Expected: get_accessible_resources_with_conditions returns one entry
|
||||
for perm_project with conditions=() (unconditional wins).
|
||||
"""
|
||||
from plane.db.models import ProjectMember
|
||||
from plane.ee.models import TeamspaceMember
|
||||
|
||||
# Direct project guest grant
|
||||
ProjectMember.objects.create(
|
||||
project=perm_project, workspace=perm_workspace,
|
||||
member=guest_user, role=5, is_active=True,
|
||||
)
|
||||
# Also add guest_user as a teamspace member so the link path fires
|
||||
TeamspaceMember.objects.create(
|
||||
workspace=perm_workspace, team_space=perm_teamspace, member=guest_user,
|
||||
)
|
||||
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=guest_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
matching = [r for r in results if r.resource_id == perm_project.id]
|
||||
assert len(matching) == 1, "Expected one merged entry for the project"
|
||||
# Link-path contributor grant (unconditional) must win over direct-path
|
||||
# guest grant (conditional).
|
||||
assert matching[0].conditions == (), (
|
||||
f"Expected unconditional merge; got conditions={matching[0].conditions!r}"
|
||||
)
|
||||
|
||||
def test_direct_deny_suppresses_link_path_grant(
|
||||
self, engine, perm_workspace, perm_project, perm_teamspace,
|
||||
teamspace_member_fixture, teamspace_project_link, member_user,
|
||||
):
|
||||
"""Direct deny on a resource applies across paths.
|
||||
|
||||
Even when a teamspace → project link would normally grant access,
|
||||
a direct tuple denying workitem:view on that project must suppress
|
||||
the grant. Explicit deny beats grant regardless of which path the
|
||||
grant arrived by.
|
||||
"""
|
||||
from plane.db.models import ProjectMember, ResourcePermission
|
||||
|
||||
# member_user is already in the teamspace; add a direct project tuple
|
||||
# with an explicit deny on workitem:view.
|
||||
ProjectMember.objects.create(
|
||||
project=perm_project, workspace=perm_workspace,
|
||||
member=member_user, role=5, is_active=True, # guest, doesn't matter
|
||||
)
|
||||
ResourcePermission.objects.filter(
|
||||
subject_type="user", subject_id=member_user.id,
|
||||
resource_type="project", resource_id=perm_project.id,
|
||||
).update(permissions_deny=["workitem:view"])
|
||||
|
||||
results = engine.get_accessible_resources_with_conditions(
|
||||
user=member_user,
|
||||
permission=WorkitemPermissions.VIEW,
|
||||
scope_resource_type="project",
|
||||
workspace_id=perm_workspace.id,
|
||||
)
|
||||
project_ids = [r.resource_id for r in results]
|
||||
assert perm_project.id not in project_ids, (
|
||||
"Direct deny must suppress the teamspace-link grant"
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
"""Tests for QuerySet.authorized_for(request, permission) and
|
||||
QuerySet.authorization_not_required(request).
|
||||
|
||||
Relies on the existing conftest fixtures (perm_workspace, perm_project,
|
||||
project_contributor, project_guest, project_admin, member_user, guest_user,
|
||||
admin_user, outsider_user, test_issue, default_state).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from crum import impersonate
|
||||
|
||||
from plane.db.models import Issue
|
||||
from plane.permissions import WorkitemPermissions
|
||||
from plane.permissions.exceptions import PermissionConfigurationError
|
||||
|
||||
|
||||
def _fake_request(user, workspace_id):
|
||||
"""Build a minimal request-like object carrying user + workspace_id.
|
||||
|
||||
SimpleNamespace matches request.user / request.workspace_id attribute
|
||||
access and also accepts setattr for _authorized_for_called.
|
||||
"""
|
||||
return SimpleNamespace(user=user, workspace_id=workspace_id)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestAuthorizedFor:
|
||||
def test_workspace_owner_sees_all_rows(
|
||||
self, perm_workspace, perm_project, ws_owner_member, test_issue, owner_user,
|
||||
):
|
||||
"""Workspace owner holds workitem:* at workspace scope → fast path, queryset unchanged."""
|
||||
request = _fake_request(user=owner_user, workspace_id=perm_workspace.id)
|
||||
qs = Issue.issue_objects.filter(workspace=perm_workspace)
|
||||
authorized = qs.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
ids = set(authorized.values_list("id", flat=True))
|
||||
assert test_issue.id in ids
|
||||
assert getattr(request, "_authorized_for_called", False) is True
|
||||
|
||||
def test_contributor_sees_issues_in_their_projects(
|
||||
self, perm_workspace, perm_project, project_contributor, test_issue, member_user,
|
||||
):
|
||||
request = _fake_request(user=member_user, workspace_id=perm_workspace.id)
|
||||
qs = Issue.issue_objects.filter(workspace=perm_workspace)
|
||||
authorized = qs.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
ids = set(authorized.values_list("id", flat=True))
|
||||
assert test_issue.id in ids
|
||||
|
||||
def test_guest_sees_only_own_issues(
|
||||
self, perm_workspace, perm_project, project_guest, default_state, guest_user, member_user,
|
||||
):
|
||||
"""Project guest holds workitem:view+creator — sees only their own issues."""
|
||||
# Issue created by someone else — guest should NOT see.
|
||||
with impersonate(member_user):
|
||||
other_issue = Issue.objects.create(
|
||||
project=perm_project, workspace=perm_workspace,
|
||||
name="Other's issue", state=default_state,
|
||||
)
|
||||
# Issue created by the guest — should see.
|
||||
with impersonate(guest_user):
|
||||
own_issue = Issue.objects.create(
|
||||
project=perm_project, workspace=perm_workspace,
|
||||
name="Guest's own issue", state=default_state,
|
||||
)
|
||||
request = _fake_request(user=guest_user, workspace_id=perm_workspace.id)
|
||||
qs = Issue.issue_objects.filter(workspace=perm_workspace)
|
||||
authorized = qs.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
ids = set(authorized.values_list("id", flat=True))
|
||||
assert own_issue.id in ids
|
||||
assert other_issue.id not in ids
|
||||
|
||||
def test_no_membership_returns_empty(
|
||||
self, perm_workspace, perm_project, test_issue, outsider_user,
|
||||
):
|
||||
request = _fake_request(user=outsider_user, workspace_id=perm_workspace.id)
|
||||
qs = Issue.issue_objects.filter(workspace=perm_workspace)
|
||||
authorized = qs.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
assert authorized.count() == 0
|
||||
|
||||
def test_sets_authorized_for_called_flag(
|
||||
self, perm_workspace, outsider_user,
|
||||
):
|
||||
request = _fake_request(user=outsider_user, workspace_id=perm_workspace.id)
|
||||
Issue.issue_objects.filter(workspace=perm_workspace).authorized_for(
|
||||
request, WorkitemPermissions.VIEW,
|
||||
)
|
||||
assert request._authorized_for_called is True
|
||||
|
||||
def test_admin_hits_config_validation(
|
||||
self, perm_workspace, ws_owner_member, owner_user,
|
||||
):
|
||||
"""Misconfiguration (unknown permission for this model) must raise
|
||||
even when the caller is a workspace owner/admin. Otherwise admin
|
||||
requests silently pass while non-admin requests fail — making the
|
||||
misconfig invisible until a non-admin happens to hit the endpoint.
|
||||
"""
|
||||
from plane.permissions import WorkspacePermissions # not in Issue.scope_map
|
||||
request = _fake_request(user=owner_user, workspace_id=perm_workspace.id)
|
||||
qs = Issue.issue_objects.filter(workspace=perm_workspace)
|
||||
with pytest.raises(PermissionConfigurationError):
|
||||
qs.authorized_for(request, WorkspacePermissions.VIEW)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestAuthorizationNotRequired:
|
||||
def test_sets_request_flag(self, perm_workspace, outsider_user):
|
||||
request = _fake_request(user=outsider_user, workspace_id=perm_workspace.id)
|
||||
qs = Issue.issue_objects.filter(workspace=perm_workspace)
|
||||
result = qs.authorization_not_required(request)
|
||||
assert request._authorized_for_called is True
|
||||
# Returned queryset is unchanged.
|
||||
assert str(result.query) == str(qs.query)
|
||||
@@ -0,0 +1,174 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.permissions.view_mixin import (
|
||||
CONFIGURATION_ERROR_CODE,
|
||||
AuthorizedListingView,
|
||||
)
|
||||
|
||||
|
||||
class _Base:
|
||||
"""Stand-in for BaseAPIView.finalize_response — returns the response."""
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
return response
|
||||
|
||||
|
||||
class _ListView(AuthorizedListingView, _Base):
|
||||
action = "list"
|
||||
|
||||
|
||||
class _RetrieveView(AuthorizedListingView, _Base):
|
||||
action = "retrieve"
|
||||
|
||||
|
||||
class _APIView(AuthorizedListingView, _Base):
|
||||
"""No .action attribute — mixin falls back to request.method."""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuthorizedListingView:
|
||||
def test_swaps_response_when_not_authorized_on_success(self):
|
||||
"""Missing .authorized_for() on a successful listing response swaps
|
||||
the response for a structured 500 — does NOT raise. Raising would
|
||||
break Plane's outer dispatch wrapper (which doesn't re-finalize
|
||||
exception responses), so the mixin returns a finalized error
|
||||
instead.
|
||||
"""
|
||||
request = SimpleNamespace(method="GET", _authorized_for_called=False)
|
||||
response = Response(status=200, data={"results": [{"id": 1}]})
|
||||
result = _ListView().finalize_response(request, response)
|
||||
assert result is not response
|
||||
assert result.status_code == 500
|
||||
assert result.data["code"] == CONFIGURATION_ERROR_CODE
|
||||
assert "authorized_for" in result.data["detail"]
|
||||
|
||||
def test_passes_when_authorized(self):
|
||||
request = SimpleNamespace(method="GET", _authorized_for_called=True)
|
||||
response = Response(status=200, data={"results": []})
|
||||
result = _ListView().finalize_response(request, response)
|
||||
assert result is response
|
||||
assert result.status_code == 200
|
||||
|
||||
def test_does_not_raise_on_4xx(self):
|
||||
"""Bad query params return 400 before the queryset is built — the
|
||||
mixin must not overwrite that with a 500."""
|
||||
request = SimpleNamespace(method="GET", _authorized_for_called=False)
|
||||
response = Response(status=400, data={"detail": "bad params"})
|
||||
result = _ListView().finalize_response(request, response)
|
||||
assert result is response
|
||||
assert result.status_code == 400
|
||||
|
||||
def test_does_not_raise_on_5xx_from_elsewhere(self):
|
||||
"""A 500 originating elsewhere isn't overridden by a configuration 500."""
|
||||
request = SimpleNamespace(method="GET", _authorized_for_called=False)
|
||||
response = Response(status=500, data={"error": "something else"})
|
||||
result = _ListView().finalize_response(request, response)
|
||||
assert result is response
|
||||
|
||||
def test_does_not_trigger_on_non_listing_action(self):
|
||||
request = SimpleNamespace(method="GET", _authorized_for_called=False)
|
||||
response = Response(status=200, data={"id": 1})
|
||||
result = _RetrieveView().finalize_response(request, response)
|
||||
assert result is response
|
||||
assert result.status_code == 200
|
||||
|
||||
def test_baseapi_get_method_triggers_check(self):
|
||||
"""For BaseAPIView (no .action), the HTTP method is used."""
|
||||
request = SimpleNamespace(method="GET", _authorized_for_called=False)
|
||||
response = Response(status=200, data={"results": []})
|
||||
result = _APIView().finalize_response(request, response)
|
||||
assert result.status_code == 500
|
||||
assert result.data["code"] == CONFIGURATION_ERROR_CODE
|
||||
|
||||
def test_baseapi_post_method_does_not_trigger(self):
|
||||
"""POST/PUT/DELETE aren't listing verbs — check shouldn't fire."""
|
||||
request = SimpleNamespace(method="POST", _authorized_for_called=False)
|
||||
response = Response(status=200, data={"id": 1})
|
||||
result = _APIView().finalize_response(request, response)
|
||||
assert result is response
|
||||
|
||||
def test_missing_flag_treated_as_false(self):
|
||||
"""getattr(..., False) default means a never-set attribute is False."""
|
||||
request = SimpleNamespace(method="GET") # no _authorized_for_called
|
||||
response = Response(status=200, data={"results": []})
|
||||
result = _ListView().finalize_response(request, response)
|
||||
assert result.status_code == 500
|
||||
assert result.data["code"] == CONFIGURATION_ERROR_CODE
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestAuthorizedListingViewRendering:
|
||||
"""Request-level render test: exercise the full DRF render pipeline to
|
||||
confirm the error Response can be serialized to JSON without errors.
|
||||
This is the guarantee the finalize-response-returns-Response strategy
|
||||
gives us that raise-from-finalize-response didn't.
|
||||
"""
|
||||
|
||||
def test_error_response_renders_through_json_renderer(self):
|
||||
"""Full stack: the swapped error Response must render through the
|
||||
standard DRF JSONRenderer without AttributeError (missing renderer)
|
||||
or other rendering failures."""
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
|
||||
class Buggy(AuthorizedListingView, APIView):
|
||||
"""View that forgets to call .authorized_for()."""
|
||||
|
||||
# Bypass default DRF auth/permission so the test exercises the
|
||||
# mixin path, not auth.
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
def get(self, request):
|
||||
return Response(status=200, data={"results": [{"id": 1}]})
|
||||
|
||||
view = Buggy.as_view()
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get("/whatever/", HTTP_ACCEPT="application/json")
|
||||
response = view(request)
|
||||
# Render the response — this is where unfinalized responses fail.
|
||||
response.render()
|
||||
assert response.status_code == 500
|
||||
import json
|
||||
body = json.loads(response.content.decode("utf-8"))
|
||||
assert body["code"] == CONFIGURATION_ERROR_CODE
|
||||
assert "authorized_for" in body["detail"]
|
||||
|
||||
def test_success_response_renders_normally(self):
|
||||
"""Successful listing (with .authorized_for() called) renders normally."""
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
|
||||
class Happy(AuthorizedListingView, APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
def get(self, request):
|
||||
# Simulate .authorized_for() being called on the queryset.
|
||||
request._authorized_for_called = True
|
||||
return Response(status=200, data={"results": [{"id": 1}]})
|
||||
|
||||
view = Happy.as_view()
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get("/whatever/", HTTP_ACCEPT="application/json")
|
||||
response = view(request)
|
||||
response.render()
|
||||
assert response.status_code == 200
|
||||
import json
|
||||
body = json.loads(response.content.decode("utf-8"))
|
||||
assert body == {"results": [{"id": 1}]}
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
import pytest
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from plane.permissions.exceptions import (
|
||||
ListingAuthorizationConfigurationError,
|
||||
PermissionConfigurationError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPermissionConfigurationError:
|
||||
def test_is_exception(self):
|
||||
assert issubclass(PermissionConfigurationError, Exception)
|
||||
|
||||
def test_accepts_message(self):
|
||||
err = PermissionConfigurationError("bad config")
|
||||
assert "bad config" in str(err)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListingAuthorizationConfigurationError:
|
||||
def test_is_api_exception(self):
|
||||
assert issubclass(ListingAuthorizationConfigurationError, APIException)
|
||||
|
||||
def test_status_code_is_500(self):
|
||||
assert ListingAuthorizationConfigurationError.status_code == 500
|
||||
|
||||
def test_default_code(self):
|
||||
assert (
|
||||
ListingAuthorizationConfigurationError.default_code
|
||||
== "listing_authorization_misconfigured"
|
||||
)
|
||||
|
||||
def test_detail_carries_code(self):
|
||||
err = ListingAuthorizationConfigurationError(
|
||||
detail="ViewName.list did not call .authorized_for()"
|
||||
)
|
||||
assert err.detail.code == "listing_authorization_misconfigured"
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.permissions import WorkitemPermissions, WorkspacePermissions
|
||||
from plane.permissions.definitions import Condition
|
||||
from plane.permissions.exceptions import PermissionConfigurationError
|
||||
from plane.permissions.meta import ScopeSpec, resolve_condition_field, resolve_scope_spec
|
||||
|
||||
|
||||
class _FakeModelWithMeta:
|
||||
class PermissionMeta:
|
||||
scope_map = {
|
||||
WorkitemPermissions: ScopeSpec(resource_type="project", fk="project_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
|
||||
|
||||
class _FakeModelWithoutMeta:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestScopeSpec:
|
||||
def test_fields_accessible(self):
|
||||
spec = ScopeSpec(resource_type="project", fk="project_id")
|
||||
assert spec.resource_type == "project"
|
||||
assert spec.fk == "project_id"
|
||||
|
||||
def test_is_frozen(self):
|
||||
spec = ScopeSpec(resource_type="project", fk="project_id")
|
||||
with pytest.raises(Exception):
|
||||
spec.resource_type = "workspace" # type: ignore[misc]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResolveScopeSpec:
|
||||
def test_resolves_when_permission_class_is_in_map(self):
|
||||
spec = resolve_scope_spec(_FakeModelWithMeta, WorkitemPermissions.VIEW)
|
||||
assert spec.resource_type == "project"
|
||||
assert spec.fk == "project_id"
|
||||
|
||||
def test_raises_when_meta_missing(self):
|
||||
with pytest.raises(PermissionConfigurationError, match="no PermissionMeta"):
|
||||
resolve_scope_spec(_FakeModelWithoutMeta, WorkitemPermissions.VIEW)
|
||||
|
||||
def test_raises_when_permission_class_not_in_scope_map(self):
|
||||
with pytest.raises(PermissionConfigurationError, match="scope_map"):
|
||||
resolve_scope_spec(_FakeModelWithMeta, WorkspacePermissions.VIEW)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResolveConditionField:
|
||||
def test_resolves_creator(self):
|
||||
field = resolve_condition_field(_FakeModelWithMeta, "creator")
|
||||
assert field == "created_by"
|
||||
|
||||
def test_raises_when_condition_missing(self):
|
||||
with pytest.raises(PermissionConfigurationError, match="condition"):
|
||||
resolve_condition_field(_FakeModelWithMeta, "lead")
|
||||
|
||||
def test_raises_when_meta_missing(self):
|
||||
with pytest.raises(PermissionConfigurationError, match="no PermissionMeta"):
|
||||
resolve_condition_field(_FakeModelWithoutMeta, "creator")
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.db.models import Issue, IssueView
|
||||
from plane.permissions import (
|
||||
WorkitemPermissions,
|
||||
WorkitemViewPermissions,
|
||||
WorkspaceWorkitemViewPermissions,
|
||||
)
|
||||
from plane.permissions.meta import resolve_condition_field, resolve_scope_spec
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssuePermissionMeta:
|
||||
def test_issue_has_permission_meta(self):
|
||||
assert hasattr(Issue, "PermissionMeta")
|
||||
|
||||
def test_scope_spec_for_workitem_view(self):
|
||||
spec = resolve_scope_spec(Issue, WorkitemPermissions.VIEW)
|
||||
assert spec.resource_type == "project"
|
||||
assert spec.fk == "project_id"
|
||||
|
||||
def test_creator_condition_field(self):
|
||||
field = resolve_condition_field(Issue, "creator")
|
||||
assert field == "created_by"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueViewPermissionMeta:
|
||||
def test_issueview_has_permission_meta(self):
|
||||
assert hasattr(IssueView, "PermissionMeta")
|
||||
|
||||
def test_project_scope_spec(self):
|
||||
spec = resolve_scope_spec(IssueView, WorkitemViewPermissions.VIEW)
|
||||
assert spec.resource_type == "project"
|
||||
assert spec.fk == "project_id"
|
||||
|
||||
def test_workspace_scope_spec(self):
|
||||
spec = resolve_scope_spec(IssueView, WorkspaceWorkitemViewPermissions.VIEW)
|
||||
assert spec.resource_type == "workspace"
|
||||
assert spec.fk == "workspace_id"
|
||||
|
||||
def test_creator_condition_field(self):
|
||||
field = resolve_condition_field(IssueView, "creator")
|
||||
assert field == "created_by"
|
||||
@@ -0,0 +1,632 @@
|
||||
# Permission System — Backend Developer Guide
|
||||
|
||||
> **Scope:** backend Python / Django / DRF development only. Frontend developers: `PERMISSION_FE_REFACTOR_GUIDE.md`.
|
||||
> **Reference data:** role × action matrix in `PERMISSION_MATRIX.md`; per-endpoint migration log in `PERMISSION_MIGRATION.md`.
|
||||
> **Deep-dive:** architecture essays in `permissions/CLAUDE.md` and the focused docs (`PERMISSION_LINK_RELATIONS.md`, `PERMISSION_MANAGEMENT_AUTHORITY.md`, `PERMISSION_TEAMSPACE_CONTENT_ACCESS.md`).
|
||||
|
||||
---
|
||||
|
||||
## 30-second overview
|
||||
|
||||
Plane's permission system is Zanzibar-inspired RBAC with conditional grants (`+creator`, `+lead`). Every permission check resolves against tuples of the form `(subject_type, subject_id, relation, resource_type, resource_id)` stored in `ResourcePermission`. Membership models (`WorkspaceMember`, `ProjectMember`, `TeamspaceMember`) sync to these tuples automatically via `PermissionSyncMixin`.
|
||||
|
||||
Resources are arranged in a three-level hierarchy — `workspace > project > {workitem, page, cycle, module, ...}` — so permissions inherit upward (a workspace admin sees everything inside it).
|
||||
|
||||
Backend authorization happens in two layers:
|
||||
|
||||
| Layer | Lives in | Decides |
|
||||
| -------------- | ------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| **Gate** | `@can(...)` decorator | _Can this caller even attempt this action?_ Returns 403 or proceeds. |
|
||||
| **Row filter** | `.authorized_for(request, permission)` queryset method | _Which rows of the collection can they actually see?_ For listings. |
|
||||
|
||||
Single-resource endpoints (`retrieve`, `partial_update`, `destroy`) need only the gate. Listing endpoints need both.
|
||||
|
||||
---
|
||||
|
||||
## When to use what
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Am I writing a view that reads/mutates… │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ …one specific resource (by pk/id)? │
|
||||
│ → @can(ItemPermission, resource_param='pk') │
|
||||
│ │
|
||||
│ …a collection of resources (list/search/feed)? │
|
||||
│ → @can(ScopePermission, resource_param='...') on the view │
|
||||
│ → .authorized_for(request, ItemPermission) on the queryset │
|
||||
│ → AuthorizedListingView on the class bases │
|
||||
│ │
|
||||
│ …a resource-free action (workspace settings, dashboards)? │
|
||||
│ → @can(ScopePermission, resource_param='workspace_id') │
|
||||
│ │
|
||||
│ Do I need to check permission inside a handler body? │
|
||||
│ → self.has_permission(permission, context) # PermissionMixin│
|
||||
│ │
|
||||
│ Am I running in a Celery task or management command? │
|
||||
│ → permission_engine.check(user=..., permission=...) │
|
||||
│ │
|
||||
│ Do I need to let the frontend render buttons conditionally? │
|
||||
│ → Add PermissionSerializerMixin to the serializer │
|
||||
│ │
|
||||
│ Am I granting/revoking a permission programmatically? │
|
||||
│ → grant_permission(granter, Grant(...)) / revoke_permission() │
|
||||
│ (but first confirm the Membership-model auto-sync doesn't │
|
||||
│ already cover your case — it usually does) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Protect a single-resource endpoint
|
||||
|
||||
**When to use.** `GET /issues/<pk>/`, `PATCH /issues/<pk>/`, `DELETE /issues/<pk>/` — anything where the URL identifies one resource.
|
||||
|
||||
**Pattern.**
|
||||
|
||||
```python
|
||||
from plane.permissions import can, WorkitemPermissions
|
||||
|
||||
class IssueDetailEndpoint(BaseAPIView):
|
||||
@can(WorkitemPermissions.VIEW, resource_param="pk")
|
||||
def get(self, request, slug, project_id, pk):
|
||||
issue = Issue.issue_objects.get(pk=pk)
|
||||
return Response(IssueSerializer(issue).data)
|
||||
|
||||
@can(WorkitemPermissions.EDIT, resource_param="pk")
|
||||
def patch(self, request, slug, project_id, pk):
|
||||
...
|
||||
|
||||
@can(WorkitemPermissions.DELETE, resource_param="pk")
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
...
|
||||
```
|
||||
|
||||
**What happens.**
|
||||
|
||||
- `@can(perm, resource_param="pk")` resolves the resource by ID from `kwargs['pk']`, walks up the hierarchy (issue → project → workspace) to validate the URL's `project_id` / `slug` match the resource's actual parents (**IDOR check**), then calls `permission_engine.check(user, perm, resource_id=pk)`.
|
||||
- On deny → `PermissionDenied` (403). On allow → the view runs.
|
||||
- Conditional grants (`workitem:edit+creator`) are evaluated against the specific resource: the engine checks if the caller is the issue's `created_by`. No extra work in the view.
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Using `resource_param="project_id"` for a per-issue endpoint — the gate then only checks project-level access, letting users edit issues across projects they can see. Always use the child-ID URL param when the permission is about the child resource.
|
||||
- Forgetting `@can` entirely — the request still succeeds (no implicit deny at the DRF level). There is no "fail closed" net below `@can`; the decorator is the fence.
|
||||
|
||||
---
|
||||
|
||||
### Protect a listing endpoint
|
||||
|
||||
**When to use.** `GET /issues/`, `GET /pages/` — any view returning a collection scoped by the caller's access. This is the _most common_ kind of view and the one that historically leaked rows.
|
||||
|
||||
**Pattern.**
|
||||
|
||||
```python
|
||||
from plane.permissions import (
|
||||
AuthorizedListingView,
|
||||
WorkitemPermissions,
|
||||
WorkspacePermissions,
|
||||
can,
|
||||
)
|
||||
|
||||
class WorkItemListWorkspaceEndpoint(AuthorizedListingView, BaseAPIView):
|
||||
@can(WorkspacePermissions.VIEW, resource_param="workspace_id")
|
||||
def get(self, request, slug):
|
||||
# Canonical variable order: authorize FIRST, snapshot total_count_queryset
|
||||
# SECOND, annotate / prefetch / order LAST. This ensures total_count
|
||||
# and total_results reflect only rows the caller can see.
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug)
|
||||
queryset = queryset.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
|
||||
total_count_queryset = queryset # snapshot AFTER authorize
|
||||
queryset = self._annotate(queryset)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
total_count_queryset=total_count_queryset,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Three cooperating pieces.**
|
||||
|
||||
1. **`@can(ScopePermission, ...)`** — scope-membership gate. Use `WorkspacePermissions.VIEW` (or `ProjectPermissions.VIEW` for project-scoped listings), **not** the item permission. Item permissions like `workitem:view` may not exist at workspace scope for non-admin roles.
|
||||
2. **`.authorized_for(request, ItemPermission)`** — row filter. Calls `permission_engine.get_accessible_resources_with_conditions(...)`, handles the workspace-admin fast path, merges grants across direct + teamspace-link paths (deny wins > unconditional upgrades conditional > conditionals union), and narrows rows in guest-relation projects to `created_by=request.user` via `workitem:view+creator`.
|
||||
3. **`AuthorizedListingView` mixin** — `finalize_response` check enforces that `.authorized_for()` was called. Omitting the call returns a structured 500 with `code="listing_authorization_misconfigured"`.
|
||||
|
||||
**Public listings (no per-row authorization).** Use the explicit bypass:
|
||||
|
||||
```python
|
||||
queryset = Project.objects.filter(network=ProjectNetwork.PUBLIC)
|
||||
queryset = queryset.authorization_not_required(request) # searchable, reviewable
|
||||
```
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Putting `.authorized_for()` _after_ annotations/filters so `total_count_queryset` snapshots an authorized set but the displayed queryset has already been annotated — or vice versa. Stick to the canonical variable order above.
|
||||
- Asserting on `response.data["count"]` in tests — that's current-page length. Use `total_count` / `total_results` for the authorization-sensitive total.
|
||||
- Using `.authorized_for()` on a model without `PermissionMeta` — raises `PermissionConfigurationError`. See the "Add a new resource type" task.
|
||||
|
||||
---
|
||||
|
||||
### Check a permission programmatically
|
||||
|
||||
**When to use.** Inside a handler body, when the gate permission isn't sufficient and you need a secondary check (e.g., "only show this field if the caller can manage the project").
|
||||
|
||||
**Pattern A — inside a DRF view (use `PermissionMixin`):**
|
||||
|
||||
```python
|
||||
from plane.permissions import PermissionContext, PermissionMixin, ProjectPermissions
|
||||
|
||||
class ProjectDashboardEndpoint(PermissionMixin, BaseAPIView):
|
||||
@can(ProjectPermissions.VIEW, resource_param="project_id")
|
||||
def get(self, request, slug, project_id):
|
||||
data = {...}
|
||||
# Show management section only to project admins
|
||||
if self.has_permission(
|
||||
ProjectPermissions.MANAGE,
|
||||
PermissionContext.project(project_id, request.workspace_id),
|
||||
):
|
||||
data["management"] = build_admin_section(...)
|
||||
return Response(data)
|
||||
```
|
||||
|
||||
**Pattern B — inside a Celery task / management command (no `self`):**
|
||||
|
||||
```python
|
||||
from plane.permissions import permission_engine, WorkitemPermissions
|
||||
|
||||
def some_celery_task(user_id, issue_id, workspace_id):
|
||||
result = permission_engine.check(
|
||||
user=user_id,
|
||||
permission=WorkitemPermissions.EDIT,
|
||||
resource_id=issue_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
if not result:
|
||||
return # deny
|
||||
...
|
||||
```
|
||||
|
||||
**Pattern C — raising check (use when you want to short-circuit with 403):**
|
||||
|
||||
```python
|
||||
self.check_can(ProjectPermissions.MANAGE, PermissionContext.project(project_id, ws_id))
|
||||
# Raises PermissionDenied if not allowed; returns True otherwise.
|
||||
```
|
||||
|
||||
**What happens.**
|
||||
|
||||
- `PermissionContext` factory methods build the scope: `PermissionContext.workspace(ws_id)`, `PermissionContext.project(project_id, ws_id)`, `PermissionContext.teamspace(teamspace_id, ws_id)`, `PermissionContext.resource(scope_id, workspace_id=..., project_id=..., resource_type=...)`.
|
||||
- `permission_engine.check()` returns an `AccessResult`. `bool(result)` is `True` for unconditional allow. Conditional results carry a `conditions` tuple — don't treat them as booleans without deciding how to handle them.
|
||||
- All checks are cached per-request (5-minute TTL, versioned). A repeated check on the same `(user, permission, context)` within a request hits the cache.
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Calling `permission_engine.check()` from a view when `self.has_permission()` would work — the mixin version is simpler and uses `self.request.user` automatically.
|
||||
- Building `PermissionContext` by hand with wrong `scope_id` / `workspace_id` / `project_id` — always use the factory methods.
|
||||
|
||||
---
|
||||
|
||||
### Return `_permissions` on an API response
|
||||
|
||||
**When to use.** The frontend needs to render buttons conditionally ("show Edit only if the user can edit this resource"). Computing permissions per-action client-side is wasteful; serializing them server-side is cheap and cached.
|
||||
|
||||
**Pattern.**
|
||||
|
||||
```python
|
||||
from plane.permissions.serializers import PermissionSerializerMixin
|
||||
|
||||
class ProjectSerializer(PermissionSerializerMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = (...)
|
||||
permission_resource_type = "project" # required
|
||||
include_permissions = True # default True
|
||||
```
|
||||
|
||||
**What happens.**
|
||||
|
||||
- `PermissionSerializerMixin` injects `_permissions` into the output: `{"relation": "contributor", "permission_grants": ["project:view", "workitem:view", ...]}`.
|
||||
- For list endpoints, `PermissionListSerializer` pre-computes permissions for the whole page in one batch query — O(1) per item, not N per-item round trips.
|
||||
- Users with no tuple on the resource get `{"relation": null, "permission_grants": []}`.
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Omitting `permission_resource_type` — the mixin silently skips and returns a response without `_permissions`. Always declare it on `Meta`.
|
||||
- Using the mixin on a serializer for a model that has no `ResourcePermission` tuples (e.g., a pure read-only analytics model). `_permissions` will always be empty. Fine, but prefer not adding the mixin.
|
||||
|
||||
---
|
||||
|
||||
### Grant / revoke a permission (GAC)
|
||||
|
||||
**When to use — rare.** In most cases you don't call this directly. Membership models (`WorkspaceMember`, `ProjectMember`, etc.) inherit `PermissionSyncMixin` and automatically call `grant_permission` on save and `revoke_permission` on delete. Manual grants are for inline overrides — e.g., temporarily giving a specific user `workitem:manage` on a specific project, beyond what their role allows.
|
||||
|
||||
**Pattern — grant:**
|
||||
|
||||
```python
|
||||
from plane.permissions import permission_engine
|
||||
from plane.permissions.grants import Grant, grant_permission
|
||||
|
||||
grant_permission(
|
||||
granter=request.user,
|
||||
grant_obj=Grant(
|
||||
subject_type="user",
|
||||
subject_id=target_user.id,
|
||||
relation="contributor",
|
||||
resource_type="project",
|
||||
resource_id=project.id,
|
||||
workspace_id=workspace.id,
|
||||
permissions_grant=["workitem:manage"], # inline grants on top of role
|
||||
permissions_deny=[], # or inline denies (wins over role)
|
||||
expires_at=None, # or a datetime for time-bounded access
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern — revoke:**
|
||||
|
||||
```python
|
||||
from plane.permissions.grants import revoke_permission
|
||||
|
||||
revoke_permission(
|
||||
revoker=request.user,
|
||||
subject_type="user",
|
||||
subject_id=target_user.id,
|
||||
resource_type="project",
|
||||
resource_id=project.id,
|
||||
workspace_id=workspace.id, # required
|
||||
)
|
||||
```
|
||||
|
||||
**What happens.**
|
||||
|
||||
- A `ResourcePermission` row is created or updated (via `update_or_create` on the subject×resource key).
|
||||
- A `PermissionAuditLog` row is written — every grant/revoke is auditable.
|
||||
- The per-user permission cache is invalidated (O(1) versioned key bump).
|
||||
- The response time is dominated by the audit write, not the permission math.
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Manually creating `ResourcePermission` rows via the ORM instead of `grant_permission` — skips the audit log, skips cache invalidation, leaves the system in a silently-wrong state.
|
||||
- Calling `grant_permission` when you should have set the caller's role via `ProjectMember.role` — the member model auto-syncs. Prefer model-level changes; drop to `grant_permission` only for genuine GAC overrides.
|
||||
|
||||
---
|
||||
|
||||
### Test a permission-gated endpoint
|
||||
|
||||
**When to use.** Every permission-gated endpoint needs role-matrix tests. For listings, use the shared `listing_auth` fixture + `authorized_listing_roles` parametrize.
|
||||
|
||||
**Pattern — contract test for a listing endpoint:**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from plane.tests.contract.conftest_listing_authorization import (
|
||||
EXPECTED_FORBIDDEN,
|
||||
authorized_listing_roles,
|
||||
expected_ids_from_fixtures,
|
||||
)
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
class TestMyListingAuthorized:
|
||||
@authorized_listing_roles
|
||||
def test_role_matrix(self, role, expected_ids_key, listing_auth, api_client):
|
||||
api_client.force_authenticate(user=listing_auth.users[role])
|
||||
response = api_client.get(f"/api/workspaces/{listing_auth.workspace.slug}/my-things/")
|
||||
|
||||
if expected_ids_key == EXPECTED_FORBIDDEN:
|
||||
assert response.status_code == 403
|
||||
return
|
||||
|
||||
assert response.status_code == 200
|
||||
expected = expected_ids_from_fixtures(listing_auth, expected_ids_key)
|
||||
returned = {str(row["id"]) for row in response.data["results"]}
|
||||
assert returned == {str(i) for i in expected}
|
||||
assert response.data["total_count"] == len(expected)
|
||||
assert response.data["total_results"] == len(expected)
|
||||
```
|
||||
|
||||
**Pattern — unit test for a single-resource gate (using permission fixtures):**
|
||||
|
||||
```python
|
||||
# Uses fixtures from plane/tests/unit/permissions/conftest.py
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
def test_contributor_can_edit_own_issue(
|
||||
engine, perm_project, project_contributor, test_issue, member_user,
|
||||
):
|
||||
from plane.permissions import WorkitemPermissions
|
||||
result = engine.check(
|
||||
user=member_user,
|
||||
permission=WorkitemPermissions.EDIT,
|
||||
resource_id=test_issue.id,
|
||||
workspace_id=perm_project.workspace_id,
|
||||
)
|
||||
assert bool(result) is True
|
||||
```
|
||||
|
||||
**What happens.**
|
||||
|
||||
- `listing_auth` fixture builds a workspace with two projects, 5 issues distributed across users with different roles (owner, admin, contributor, two guests, a workspace-member-with-no-project, an outsider).
|
||||
- `authorized_listing_roles` parametrize covers the full role matrix in one decorator.
|
||||
- Assertions use `total_count` and `total_results` (not `count`, which is current-page length) so `total_count_queryset` divergence is caught.
|
||||
- Pre-made unit fixtures in `plane/tests/unit/permissions/conftest.py`: `perm_workspace`, `perm_project`, `project_admin`, `project_contributor`, `project_guest`, `owner_user`, `member_user`, `guest_user`, `outsider_user`, `engine` (cache-disabled `PermissionEngine`).
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Only testing admin + regular member. The historical bugs all slipped through because guest / outsider / no-project-access cases weren't in the matrix. The shared parametrize covers them; use it.
|
||||
- Testing with `--reuse-db` and hitting stale migrations. Run `pytest --create-db` when adding new role / permission / migration code.
|
||||
|
||||
---
|
||||
|
||||
### Add a new resource type (end-to-end)
|
||||
|
||||
**When to use.** You're adding a new kind of object that participates in the permission system — e.g., a new "Campaign" resource under Project.
|
||||
|
||||
**The places you touch (in order):**
|
||||
|
||||
1. **`ResourceType` enum** — `permissions/definitions.py`:
|
||||
|
||||
```python
|
||||
class ResourceType(str, Enum):
|
||||
CAMPAIGN = "campaign"
|
||||
```
|
||||
|
||||
2. **Permission class** — same file:
|
||||
|
||||
```python
|
||||
class CampaignPermissions:
|
||||
VIEW = Permission(ResourceType.CAMPAIGN, Action.VIEW)
|
||||
CREATE = Permission(ResourceType.CAMPAIGN, Action.CREATE)
|
||||
EDIT = Permission(ResourceType.CAMPAIGN, Action.EDIT)
|
||||
DELETE = Permission(ResourceType.CAMPAIGN, Action.DELETE)
|
||||
```
|
||||
|
||||
(Registry `_PERMISSION_CLASSES` and derived `RESOURCE_ACTIONS` are auto-built — no manual entry needed.)
|
||||
|
||||
3. **Hierarchy registration** — `permissions/inheritance.py`, `_PARENT_DECLARATIONS`:
|
||||
|
||||
```python
|
||||
ResourceType.CAMPAIGN: (ResourceType.PROJECT, "project_id"),
|
||||
```
|
||||
|
||||
Children + scope groupings are auto-derived from this.
|
||||
|
||||
4. **Model resolution** — `permissions/resource_models.py`, add `ResourceType.CAMPAIGN → Campaign`. Used by `ConditionEvaluator` for creator/lead checks.
|
||||
|
||||
5. **Role grants** — `permissions/system_roles.py` and `permissions/permission_schemes.py`: add entries for each role × each permission. Use typed grants only:
|
||||
|
||||
```python
|
||||
CampaignPermissions.VIEW
|
||||
CampaignPermissions.DELETE & Condition.CREATOR
|
||||
WildcardGrant(ResourceType.CAMPAIGN)
|
||||
```
|
||||
|
||||
6. **Model `PermissionMeta`** — on the Django model itself, if the resource will be listed. Required for `.authorized_for()`:
|
||||
|
||||
```python
|
||||
class Campaign(ProjectBaseModel):
|
||||
class PermissionMeta:
|
||||
scope_map = {
|
||||
CampaignPermissions: ScopeSpec(resource_type="project", fk="project_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
```
|
||||
|
||||
7. **Exports** — `permissions/__init__.py`: add `CampaignPermissions` to the public API (auto-derived from `_PERMISSION_CLASSES`, so usually no change).
|
||||
|
||||
**Startup validator.** `validate_permission_system_consistency()` in `inheritance.py` runs at app start and catches missing `_PARENT_DECLARATIONS` entries, unmapped resource types, etc. If you forget step 3 or 4, the app refuses to boot.
|
||||
|
||||
**What you don't need to touch.** `ALL_PERMISSIONS`, `PERMISSION_MAP`, `RESOURCE_ACTIONS` — all auto-derived from the registry.
|
||||
|
||||
---
|
||||
|
||||
### Add or modify a role / permission scheme
|
||||
|
||||
**When to use.** Granting a new permission to an existing role, adding a role slug, changing a permission scheme's contents.
|
||||
|
||||
**Where permissions live.**
|
||||
|
||||
- **Permission schemes** (`permissions/permission_schemes.py`) — named, reusable bundles of permissions. One per system role. Customer-visible name; code changes here change the customer-facing role capability.
|
||||
- **System roles** (`permissions/system_roles.py`) — map slugs to permission schemes and levels. The slug is what appears in `ResourcePermission.relation`.
|
||||
|
||||
**Pattern — grant a new permission to contributors:**
|
||||
|
||||
```python
|
||||
# permission_schemes.py
|
||||
"contributor": {
|
||||
"name": "Project Contributor",
|
||||
"namespace": "project",
|
||||
"permissions": [
|
||||
...,
|
||||
WorkitemPermissions.ARCHIVE, # ← new entry
|
||||
...,
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
Then run the cache invalidation (normally auto via `Role.save()` signals; in dev: `cache.clear()`).
|
||||
|
||||
**Pattern — add a conditional grant:**
|
||||
|
||||
```python
|
||||
WorkitemPermissions.DELETE & Condition.CREATOR
|
||||
```
|
||||
|
||||
This uses the `&` operator overload that returns a `ConditionalGrant`. The engine's `ConditionEvaluator` resolves `creator` against the model's `PermissionMeta.condition_fields` (defaulting to `created_by_id` if not declared).
|
||||
|
||||
**Mandatory doc updates when you change a role grant:**
|
||||
|
||||
1. `docs/permissions/PERMISSION_MATRIX.md` — update the role access columns.
|
||||
2. `docs/permissions/PERMISSION_MIGRATION.md` — document the grant change.
|
||||
3. `designs/permissions/permission-role-alignment-review.md` — update per-role permission tables.
|
||||
|
||||
**Common mistakes.**
|
||||
|
||||
- Adding a raw string like `"workitem:archive"` instead of a typed `Permission` — violates the type-safety invariant (`system_roles.py` docstring: "No raw strings in role definitions"). The typed form catches typos at import time.
|
||||
- Forgetting to update the three docs — the migration workflow bakes this in (see `CLAUDE.md`).
|
||||
|
||||
---
|
||||
|
||||
### Custom roles (workspace-admin-authored)
|
||||
|
||||
**What exists today.** Workspace admins can create custom `Role` records (via `Role.objects.create(..., is_system=False, workspace=ws)`) that compose any mix of system + custom `PermissionScheme` entries. These roles behave identically to system roles at resolution time — `RoleLookup.has_permission` / `get_conditions` consult compiled system permissions first, then cached custom-role permissions from the DB.
|
||||
|
||||
**What the engine does with them.**
|
||||
|
||||
- Compiled system role permissions are in-memory O(1) lookups. Custom roles hit Redis (24-hour TTL, invalidated on `Role.save()` via `ChangeTrackerMixin`).
|
||||
- The permission engine treats both identically — no special casing in `check()` or `get_accessible_resources_with_conditions()`.
|
||||
|
||||
**What you don't need to do.** Nothing backend-side when a customer creates a custom role. The membership sync (`PermissionSyncMixin`) handles the relation string, the cache gets the perm set, and the engine resolves against it.
|
||||
|
||||
---
|
||||
|
||||
### Extend with a new condition (e.g., `+assignee`)
|
||||
|
||||
**When to use.** Adding a new conditional grant that isn't creator or lead.
|
||||
|
||||
**The places you touch:**
|
||||
|
||||
1. **`Condition` enum** — `permissions/definitions.py`:
|
||||
|
||||
```python
|
||||
class Condition(str, Enum):
|
||||
CREATOR = "creator"
|
||||
LEAD = "lead"
|
||||
ASSIGNEE = "assignee" # new
|
||||
```
|
||||
|
||||
2. **Engine evaluation** — `permissions/engine/conditions.py`:
|
||||
- For simple field-based conditions (like `creator`, which maps to `created_by`), the current `ConditionEvaluator` resolves via the model's `PermissionMeta.condition_fields`. Nothing to change if your condition can be expressed as "this field matches user".
|
||||
- For complex conditions (like `assignee`, where assignees live in a M2M table `IssueAssignee`), add a special-case `_eval_condition_assignee` method that does the right query.
|
||||
|
||||
3. **Queryset filter helper** — `permissions/queryset.py`:
|
||||
- Field-based conditions use the model's `condition_fields` + `Q(field=user)`.
|
||||
- M2M conditions (like `assignee`) need either a custom branch in `.authorized_for()` or a richer `condition_fields` value (e.g., a callable that returns a `Q` object). Prefer the callable approach to keep the system extensible.
|
||||
|
||||
4. **Role declarations** — grant the new condition in `permission_schemes.py`:
|
||||
|
||||
```python
|
||||
WorkitemPermissions.EDIT & Condition.ASSIGNEE
|
||||
```
|
||||
|
||||
5. **Model meta on affected models** — declare the field mapping:
|
||||
```python
|
||||
class Issue(ProjectBaseModel):
|
||||
class PermissionMeta:
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
Condition.ASSIGNEE: "issue_assignee__assignee", # M2M reverse path
|
||||
}
|
||||
```
|
||||
|
||||
**The engine-queryset parity invariant.** Whatever `ConditionEvaluator` decides for a single resource, `.authorized_for()` must produce the same set of rows when applied to a queryset. If they drift, a user can pass the gate but see different rows than the per-row check would allow (or vice versa). Write a regression test that exercises both paths for any new condition.
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ ResourcePermission│ (subject_type, subject_id, relation,
|
||||
│ tuple store │ resource_type, resource_id,
|
||||
└────────┬──────────┘ permissions_grant, permissions_deny,
|
||||
│ expires_at)
|
||||
writes │ via PermissionSyncMixin
|
||||
│
|
||||
┌─────────────┴──────────────┐
|
||||
│ WorkspaceMember / ProjectMember / TeamspaceMember │
|
||||
│ (auto-sync on save() via ChangeTrackerMixin) │
|
||||
└─────────────────────────────┘
|
||||
|
||||
reads via
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ PermissionEngine (facade) │
|
||||
│ ├── HierarchyResolver (parent chain, IDOR) │
|
||||
│ ├── TupleFetcher (direct + link tuples)│
|
||||
│ ├── RoleLookup (role→permissions) │
|
||||
│ ├── ConditionEvaluator (creator/lead) │
|
||||
│ ├── PermissionResolver (the Zanzibar loop) │
|
||||
│ └── PermissionQueries (accessible_resources)│
|
||||
└────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴───────────────────────────────────┐
|
||||
│ @can decorator PermissionMixin @serializer│
|
||||
│ (gate) .has_permission() _permissions│
|
||||
│ │
|
||||
│ .authorized_for(request, permission) │
|
||||
│ — reads model.PermissionMeta.scope_map, │
|
||||
│ calls get_accessible_resources_with_conditions│
|
||||
│ builds the row-filter Q. │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Resolution order** inside `PermissionEngine.check()`, per hierarchy level:
|
||||
|
||||
1. Resource-ownership validation (IDOR check — caller's `workspace_id`/`project_id` must match the resource's actual parents).
|
||||
2. Explicit `permissions_deny` on the tuple → **deny**.
|
||||
3. Explicit `permissions_grant` on the tuple → **allow** (unconditional).
|
||||
4. Role's unconditional permissions → **allow**.
|
||||
5. Role's conditional permissions → evaluate against the resource → allow if condition holds (or defer if `defer_conditions=True` was passed).
|
||||
6. Link-relation traversal (teamspace → project) — repeat 2–5 on linked tuples.
|
||||
7. Inherited from parent (project → workspace) — recurse up.
|
||||
8. Default → **deny**.
|
||||
|
||||
For deep-dives: `permissions/CLAUDE.md` covers the full engine design; `PERMISSION_LINK_RELATIONS.md` walks the teamspace traversal; `PERMISSION_MANAGEMENT_AUTHORITY.md` explains who can grant what.
|
||||
|
||||
---
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Meaning |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Permission string** | `"{resource_type}:{action}"`, e.g., `"workitem:view"`. The canonical string form; `WorkitemPermissions.VIEW` is the typed form. |
|
||||
| **Conditional grant** | Permission that only applies when a condition holds: `"workitem:view+creator"`. Compose via `Permission & Condition.CREATOR`. |
|
||||
| **Relation** | The role slug attached to a `ResourcePermission` tuple: `"admin"`, `"contributor"`, `"commenter"`, `"guest"`, or a custom role slug. |
|
||||
| **Scope (in PermissionContext)** | The resource layer a check happens at: `workspace`, `project`, `teamspace`, or `resource` (for specific-resource checks). |
|
||||
| **Resource type** | The Plane-defined kind of object: `workspace`, `project`, `workitem`, `page`, `cycle`, `module`, etc. Listed in `ResourceType` enum. |
|
||||
| **Subject type** | Who the tuple is about: `user` or `teamspace`. (Teamspace tuples enable link-relation traversal.) |
|
||||
| **IDOR check** | The parent-chain walk the engine does before returning `allow` — verifies the caller's URL parameters match the resource's actual parent chain, preventing cross-workspace / cross-project access via crafted URLs. |
|
||||
| **GAC** | Grant-Access Control — the inline `permissions_grant` / `permissions_deny` arrays on a `ResourcePermission` tuple. Wins over role defaults. |
|
||||
| **Link relation** | A tuple whose subject is a teamspace, granting members of that teamspace access to the resource transitively. Enables "grant a whole team access to project X". |
|
||||
| **`defer_conditions=True`** | Legacy flag on `@can` that stores conditions on the request and expects the view to filter by them manually. Superseded for listing endpoints by `.authorized_for()`. |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### My endpoint returns 403 but the user "should" have access
|
||||
|
||||
1. **Check the exact permission and scope.** Log the `permission_engine.check(...)` call with `PermissionContext` explicitly — is the context scope the one you expect? A `PermissionContext.workspace(ws_id)` check does not see a project tuple.
|
||||
2. **Check the role.** `Role.objects.filter(slug=..., workspace=...)` — does the user's `ProjectMember.role_ref` point to the role you think?
|
||||
3. **Check the tuple.** `ResourcePermission.objects.filter(subject_id=user.id, workspace_id=ws.id)` — is the tuple there? Did the `ProjectMember` save fire `PermissionSyncMixin`? (A `bulk_create`/`bulk_update` bypasses it — must dispatch manually.)
|
||||
4. **Check the permission_schemes.** `get_compiled_permissions(role_slug, namespace)` returns the in-memory set. If a permission is missing, the scheme is the source of truth.
|
||||
|
||||
### My listing returns empty, or raises `listing_authorization_misconfigured`
|
||||
|
||||
1. **Missing `.authorized_for()`.** Look at the response body — `code="listing_authorization_misconfigured"` means `AuthorizedListingView` fired. Add the call.
|
||||
2. **Wrong permission in `.authorized_for()`.** You're filtering with `WorkitemPermissions.VIEW` but the model's `PermissionMeta.scope_map` has a different permission class. Check the mapping.
|
||||
3. **Missing `PermissionMeta` on the model.** `PermissionConfigurationError` at request time. Add the nested meta class (see "Add a new resource type").
|
||||
4. **Scope mismatch.** The `.authorized_for()` call uses a permission whose parent (from `_PARENT_DECLARATIONS`) is `project`, but the model's `scope_map` declares `workspace` — engine walks projects, model expects workspace_id filter. Align them.
|
||||
|
||||
### My test passes locally but fails in CI with `role_ref_id column does not exist`
|
||||
|
||||
Stale test DB. Run `pytest --create-db` once to rebuild.
|
||||
|
||||
### My `@can` check passes but `.authorized_for()` returns empty rows
|
||||
|
||||
Gate/filter divergence — the engine's single-resource check says "yes", but the accessible-resources query says "no". Usually one of:
|
||||
|
||||
- A conditional grant that the gate evaluates against `resource_param` (pk) but the listing query doesn't see because the model's `condition_fields` doesn't map the condition.
|
||||
- Deny applied per-resource in the listing path but not evaluated by the gate (or vice versa).
|
||||
|
||||
Add a regression test asserting both paths agree for the same user and resource. The primitive `get_accessible_resources_with_conditions` is designed to match the engine resolver — divergences are bugs, not tradeoffs.
|
||||
|
||||
### The permissions cache seems stale after I changed a role
|
||||
|
||||
Role changes trigger cache invalidation via `ChangeTrackerMixin` on `Role.save()`. If you're mutating via raw SQL or `bulk_update`, the signal doesn't fire. Manual nuke: `cache.clear()` (fine in dev, terrible in prod).
|
||||
@@ -69,11 +69,11 @@ All actions check `workspace:view`. Stickies are user-scoped — queryset filter
|
||||
|
||||
### Workspace Issues — `WorkspaceViewIssuesViewSet`
|
||||
|
||||
| Action | Permission Checked | W-Owner | W-Admin | W-Member | W-Guest |
|
||||
| --------------------- | ------------------ | ------- | ------------------- | ------------------- | ------------------- |
|
||||
| List workspace issues | `workspace:view` | ✅ `*` | ✅ `workspace:view` | ✅ `workspace:view` | ✅ `workspace:view` |
|
||||
| Action | Permission Checked | W-Owner | W-Admin | W-Member | W-Guest |
|
||||
| --------------------- | ---------------------------------- | ------- | ------------------- | -------------------------------- | ---------------------------- |
|
||||
| List workspace issues | `workspace:view` + `workitem:view` | ✅ `*` | ✅ `workspace:view` | ✅ `workitem:view` per project ⁵ | ✅ `workitem:view+creator` ⁵ |
|
||||
|
||||
> **Note:** Data-level filtering uses `permission_engine.get_accessible_resources()` to scope issues to projects the user has access to.
|
||||
> ⁵ Outer decorator is `@can(WorkspacePermissions.VIEW, resource_param="workspace_id")` (scope-membership gate; outsiders get 403). Row filter is `.authorized_for(request, WorkitemPermissions.VIEW)` on the queryset, which calls `permission_engine.get_accessible_resources_with_conditions("project", ...)` and merges grants per-resource across direct + teamspace-link paths (deny wins > unconditional upgrades conditional > conditionals union). Project members see rows in projects they can view; project guests see only their own issues (via `workitem:view+creator`). Workspace admin/owner fast-path via the workspace-scope wildcard grant skips the per-project tuple walk.
|
||||
|
||||
### Workspace Views — `WorkspaceViewViewSet`
|
||||
|
||||
@@ -487,50 +487,50 @@ Workspace-scoped resource: `RELEASE`. New resource type with actions VIEW, CREAT
|
||||
|
||||
#### `WorkItemListProjectEndpoint`
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ------------------------------ | ------------------ | --------------- | ------------------ | ------------------ | ---------- | ------- | --------------- |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ------------------------------- | ------------------ | --------------- | ------------------ | ------------------ | ---------- | ------- | --------------- |
|
||||
| List work items (w/ properties) | `workitem:view` | ✅ `workitem:*` | ✅ `workitem:view` | ✅ `workitem:view` | +Creator ² | ✅ `*` | ✅ `workitem:*` |
|
||||
|
||||
> ² `defer_conditions=True` — guest sees only own issues via `created_by` queryset filter.
|
||||
|
||||
#### `WorkItemListWorkspaceEndpoint`
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ------------------------------- | ------------------ | ------- | ------------- | ----------- | ------- | ------- | ------- |
|
||||
| List work items (workspace-scope) | `workspace:view` | — | — | — | — | ✅ `*` | ✅ `workspace:view` |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| --------------------------------- | ------------------------------------ | ------------------ | ------------------ | ------------------ | ---------- | ------- | ------------------- |
|
||||
| List work items (workspace-scope) | `workspace:view` + `workitem:view` ³ | ✅ `workitem:view` | ✅ `workitem:view` | ✅ `workitem:view` | +Creator ³ | ✅ `*` | ✅ `workspace:view` |
|
||||
|
||||
> Gate on workspace membership (matches `IssueDetailIdentifierEndpoint`). Queryset is already filtered to the workspace; the decorator just verifies the user can see the workspace.
|
||||
> ³ Outer decorator is `@can(WorkspacePermissions.VIEW, resource_param="workspace_id")` — scope-membership gate; outsiders get 403. Row filter is `.authorized_for(request, WorkitemPermissions.VIEW)` on the queryset (via `AuthorizationQuerySetMixin`, mixed into every `SoftDeletionQuerySet`). The helper (a) fast-paths workspace owner/admin via `permission_engine.check(WorkitemPermissions.VIEW, PermissionContext.workspace(...))` — they hold `workitem:*` at workspace scope — and (b) for non-admins calls `permission_engine.get_accessible_resources_with_conditions("project", ...)` which preserves conditional grants (`workitem:view+creator` for project guests) so the helper narrows guest-relation projects to `created_by=request.user`. `AuthorizedListingView` mixin on the view enforces the `.authorized_for()` call at `finalize_response`; omitting it returns a structured 500. Canonical variable order in the view: authorize FIRST, snapshot `total_count_queryset` SECOND, annotate/prefetch/order LAST — so `total_count` / `total_results` reflect only rows the caller can see.
|
||||
|
||||
#### `IssueVoteEndpoint`
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ------------ | ------------------ | --------------- | ------------------ | ------------------ | ------------------ | ------- | --------------- |
|
||||
| List votes | `workitem:react` | ✅ `workitem:*` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `*` | ✅ `workitem:*` |
|
||||
| Cast vote | `workitem:react` | ✅ `workitem:*` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `*` | ✅ `workitem:*` |
|
||||
| Remove vote | `workitem:react` | ✅ `workitem:*` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `*` | ✅ `workitem:*` |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ----------- | ------------------ | --------------- | ------------------- | ------------------- | ------------------- | ------- | --------------- |
|
||||
| List votes | `workitem:react` | ✅ `workitem:*` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `*` | ✅ `workitem:*` |
|
||||
| Cast vote | `workitem:react` | ✅ `workitem:*` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `*` | ✅ `workitem:*` |
|
||||
| Remove vote | `workitem:react` | ✅ `workitem:*` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `workitem:react` | ✅ `*` | ✅ `workitem:*` |
|
||||
|
||||
#### `WorkItemStateDurationEndpoint`
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ------------------------------ | ------------------ | --------------- | ------------------ | ------------------ | ---------- | ------- | --------------- |
|
||||
| View state transition duration | `workitem:view` | ✅ `workitem:*` | ✅ `workitem:view` | ✅ `workitem:view` | +Creator | ✅ `*` | ✅ `workitem:*` |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ------------------------------ | ------------------ | --------------- | ------------------ | ------------------ | -------- | ------- | --------------- |
|
||||
| View state transition duration | `workitem:view` | ✅ `workitem:*` | ✅ `workitem:view` | ✅ `workitem:view` | +Creator | ✅ `*` | ✅ `workitem:*` |
|
||||
|
||||
> Inline guards additionally deny guests access to epics and non-owned items (preserved from legacy).
|
||||
|
||||
#### `WorkItemWorklogEndpoint` (External API — `api/views/worklog.py`)
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ----------------- | ------------------ | --------------- | ------------------ | ----------- | ------- | ------- | --------------- |
|
||||
| Create worklog | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| List worklogs | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| Update worklog | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| Delete worklog | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| -------------- | ------------------ | --------------- | ------------------ | ----------- | ------- | ------- | --------------- |
|
||||
| Create worklog | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| List worklogs | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| Update worklog | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| Delete worklog | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
|
||||
#### `ProjectWorklogAPIEndpoint` (External API — `api/views/worklog.py`)
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| -------------------------- | ------------------ | --------------- | ------------------ | ----------- | ------- | ------- | --------------- |
|
||||
| Project worklog summary | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest | W-Owner | W-Admin |
|
||||
| ----------------------- | ------------------ | --------------- | ------------------ | ----------- | ------- | ------- | --------------- |
|
||||
| Project worklog summary | `workitem:edit` | ✅ `workitem:*` | ✅ `workitem:edit` | ❌ | ❌ | ✅ `*` | ✅ `workitem:*` |
|
||||
|
||||
#### `IssueDetailEndpoint`
|
||||
|
||||
@@ -2635,9 +2635,9 @@ Added 2026-02-22.
|
||||
|
||||
Also gated by `@check_feature_flag(FeatureFlag.PROJECT_MEMBERS_IMPORT)`.
|
||||
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest |
|
||||
| -------------- | ------------------------ | --------------------- | ------------- | ----------- | ------- |
|
||||
| Import members | `project_member:invite` | ✅ `project_member:*` | ❌ | ❌ | ❌ |
|
||||
| Action | Permission Checked | P-Admin | P-Contributor | P-Commenter | P-Guest |
|
||||
| -------------- | ----------------------- | --------------------- | ------------- | ----------- | ------- |
|
||||
| Import members | `project_member:invite` | ✅ `project_member:*` | ❌ | ❌ | ❌ |
|
||||
|
||||
> W-Owner/W-Admin always have access via workspace-level `project_member:*` wildcard (omitted from project table).
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ This document tracks the migration from `@allow_permission` to `@can` decorator
|
||||
|
||||
**File:** `apps/api/plane/app/views/view/base.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | -------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `list` | `GET /workspaces/<slug>/issues/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")` | `@can(WorkspacePermissions.VIEW, resource_param="workspace_id")` | Now checks `workspace:view` permission (scope==resource, collapsed); data filtering uses permission engine |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | -------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list` | `GET /workspaces/<slug>/issues/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")` | `@can(WorkspacePermissions.VIEW, resource_param="workspace_id")` + `AuthorizedListingView` mixin + `.authorized_for(request, WorkitemPermissions.VIEW)` | Uses the canonical authorized-listing pattern. Scope-membership gate (`workspace:view`) decides 200 vs 403 for outsiders. `.authorized_for()` on the queryset handles per-project row filtering via the engine's `get_accessible_resources_with_conditions` primitive — correctly merging direct + teamspace-link paths (deny wins > unconditional upgrades conditional > conditionals union), so project guests with `workitem:view+creator` see only their own issues. `AuthorizedListingView` enforces the `.authorized_for()` call at `finalize_response`; omitting it returns a structured 500 (`code="listing_authorization_misconfigured"`). |
|
||||
|
||||
**Additional Changes:**
|
||||
|
||||
@@ -4787,62 +4787,62 @@ All workspace roles (Owner, Admin, Member, Guest) have `workspace:view`.
|
||||
|
||||
**File:** `apps/api/plane/app/views/issue/work_item.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | ---------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | ---------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/work-items/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])` | `@can(WorkitemPermissions.VIEW, resource_param="project_id", defer_conditions=True)` | Guest access via `workitem:view+creator` conditional grant with `defer_conditions=True` — guest sees only own issues via inline `created_by` queryset filter. |
|
||||
|
||||
### WorkItemListWorkspaceEndpoint
|
||||
|
||||
**File:** `apps/api/plane/app/views/issue/work_item.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | -------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/work-items/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")` | `@can(WorkspacePermissions.VIEW, resource_param="workspace_id")` | Gate on workspace membership (matches `IssueDetailIdentifierEndpoint`). Queryset already filters by workspace. |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | ------------------------------------ | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/work-items/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")` | `@can(WorkspacePermissions.VIEW, resource_param="workspace_id")` + `AuthorizedListingView` mixin + `.authorized_for(request, WorkitemPermissions.VIEW)` | Uses the canonical authorized-listing pattern. Scope-membership gate (`workspace:view`) decides 200 vs 403 for outsiders. `.authorized_for()` on the queryset handles per-project row filtering via `get_accessible_resources_with_conditions` — correctly handles `workitem:view+creator` conditional grants (project guests see only their own issues), merges direct + teamspace-link paths per resource (deny wins > unconditional upgrades conditional > conditionals union), and fast-paths workspace owner/admin via the workspace-scope wildcard grant. `AuthorizedListingView` enforces the `.authorized_for()` call at `finalize_response`; omitting it returns a structured 500 (`code="listing_authorization_misconfigured"`). Canonical variable order in the view: authorize FIRST, snapshot `total_count_queryset` SECOND, annotate / prefetch / order LAST — so `total_count` / `total_results` reflect only rows the caller can see. |
|
||||
|
||||
### IssueVoteEndpoint
|
||||
|
||||
**File:** `apps/api/plane/app/views/issue/vote.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| -------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/votes/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")` | `@can(WorkitemPermissions.REACT, resource_param="work_item_id")` | Matches `IssueReactionViewSet` pattern. Guests retain access via base role grant. |
|
||||
| `post` | `POST /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/votes/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")` | `@can(WorkitemPermissions.REACT, resource_param="work_item_id")` | Same. |
|
||||
| `delete` | `DELETE /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/votes/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")` | `@can(WorkitemPermissions.REACT, resource_param="work_item_id")` | Same. |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| -------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/votes/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")` | `@can(WorkitemPermissions.REACT, resource_param="work_item_id")` | Matches `IssueReactionViewSet` pattern. Guests retain access via base role grant. |
|
||||
| `post` | `POST /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/votes/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")` | `@can(WorkitemPermissions.REACT, resource_param="work_item_id")` | Same. |
|
||||
| `delete` | `DELETE /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/votes/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")` | `@can(WorkitemPermissions.REACT, resource_param="work_item_id")` | Same. |
|
||||
|
||||
### WorkItemStateDurationEndpoint
|
||||
|
||||
**File:** `apps/api/plane/app/views/issue/state_duration.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/state-duration/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])` | `@can(WorkitemPermissions.VIEW, resource_param="work_item_id")` | Per-item `workitem:view` check (matches `issue/comment.py`, `issue/version.py`). Inline guest/epic business guards preserved — they remain equal-or-more-restrictive than the engine's `workitem:view+creator` grant. |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/work-items/<work_item_id>/state-duration/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])` | `@can(WorkitemPermissions.VIEW, resource_param="work_item_id")` | Per-item `workitem:view` check (matches `issue/comment.py`, `issue/version.py`). Inline guest/epic business guards preserved — they remain equal-or-more-restrictive than the engine's `workitem:view+creator` grant. |
|
||||
|
||||
### WorkItemWorklogEndpoint (External API)
|
||||
|
||||
**File:** `apps/api/plane/api/views/worklog.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| -------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `post` | `POST .../work-items/<issue_id>/worklogs/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Admin + Contributor have `workitem:edit` unconditionally; guests lack it. Parity with legacy ADMIN/MEMBER gate. |
|
||||
| `get` | `GET .../work-items/<issue_id>/worklogs/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Same. |
|
||||
| `patch` | `PATCH .../work-items/<issue_id>/worklogs/<pk>/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Same. |
|
||||
| `delete` | `DELETE .../work-items/<issue_id>/worklogs/<pk>/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Same. |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| -------- | ------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `post` | `POST .../work-items/<issue_id>/worklogs/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Admin + Contributor have `workitem:edit` unconditionally; guests lack it. Parity with legacy ADMIN/MEMBER gate. |
|
||||
| `get` | `GET .../work-items/<issue_id>/worklogs/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Same. |
|
||||
| `patch` | `PATCH .../work-items/<issue_id>/worklogs/<pk>/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Same. |
|
||||
| `delete` | `DELETE .../work-items/<issue_id>/worklogs/<pk>/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="issue_id")` | Same. |
|
||||
|
||||
### ProjectWorklogAPIEndpoint (External API)
|
||||
|
||||
**File:** `apps/api/plane/api/views/worklog.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | ---------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/total-worklogs/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="project_id")` | Project-scope summary endpoint. Parity with legacy ADMIN/MEMBER gate. |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | -------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `get` | `GET /workspaces/<slug>/projects/<project_id>/total-worklogs/` | `@allow_permission([ROLE.ADMIN, ROLE.MEMBER])` | `@can(WorkitemPermissions.EDIT, resource_param="project_id")` | Project-scope summary endpoint. Parity with legacy ADMIN/MEMBER gate. |
|
||||
|
||||
### ProjectMembersImportEndpoint
|
||||
|
||||
**File:** `apps/api/plane/ee/views/app/project/user_import.py`
|
||||
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | --------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `post` | `POST /workspaces/<slug>/projects/<project_id>/member-imports/` | `@allow_permission(allowed_roles=[ROLE.ADMIN], level="PROJECT")` | `@can(ProjectMemberPermissions.INVITE, resource_param="project_id")` | Project admin required. New permission model: project admin has `project_member:*` wildcard; others lack `project_member:invite`. |
|
||||
| Method | URL Pattern | Old Permission | New Permission | Differences |
|
||||
| ------ | --------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `post` | `POST /workspaces/<slug>/projects/<project_id>/member-imports/` | `@allow_permission(allowed_roles=[ROLE.ADMIN], level="PROJECT")` | `@can(ProjectMemberPermissions.INVITE, resource_param="project_id")` | Project admin required. New permission model: project admin has `project_member:*` wildcard; others lack `project_member:invite`. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -819,75 +819,93 @@ permission_engine.revoke(
|
||||
)
|
||||
```
|
||||
|
||||
### 7. Filtering Querysets by Accessible Resources
|
||||
### 7. Authorized Listings — `.authorized_for(request, permission)`
|
||||
|
||||
For list views that need to filter results based on user permissions, use `PermissionMixin`:
|
||||
For listing endpoints (any view returning a collection of rows scoped by the caller's access), use the **authorized listing pattern**. Three parts on every such view:
|
||||
|
||||
```python
|
||||
from plane.permissions import can, WorkspacePermissions, WorkitemPermissions, PermissionMixin
|
||||
from plane.permissions import AuthorizedListingView, can, WorkspacePermissions, WorkitemPermissions
|
||||
|
||||
class WorkspaceViewIssuesViewSet(PermissionMixin, BaseViewSet):
|
||||
"""List all issues across projects the user can access."""
|
||||
|
||||
@property
|
||||
def workspace_id(self):
|
||||
"""Required for PermissionMixin methods."""
|
||||
return self.request.workspace_id
|
||||
|
||||
def get_queryset(self):
|
||||
# Get projects where user can VIEW ISSUES (not just view projects)
|
||||
# We query project tuples but check issue:view permission
|
||||
accessible_project_ids = self.get_accessible_resources(
|
||||
resource_type="project",
|
||||
permission=WorkitemPermissions.VIEW, # Check issue:view, not project:view
|
||||
)
|
||||
|
||||
return Issue.objects.filter(
|
||||
workspace_id=self.request.workspace_id,
|
||||
project_id__in=accessible_project_ids,
|
||||
class WorkItemListWorkspaceEndpoint(AuthorizedListingView, BaseAPIView):
|
||||
@can(WorkspacePermissions.VIEW, resource_param="workspace_id")
|
||||
def get(self, request, slug):
|
||||
# Canonical variable order: authorize FIRST, snapshot total_count_queryset
|
||||
# SECOND, annotate / prefetch / order LAST — so total_count and
|
||||
# total_results reflect only rows the caller can see.
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug)
|
||||
queryset = queryset.authorized_for(request, WorkitemPermissions.VIEW)
|
||||
total_count_queryset = queryset # snapshot AFTER authorize
|
||||
queryset = self._annotate(queryset)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
total_count_queryset=total_count_queryset,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Important**: The `permission` parameter specifies which permission to check, and can differ from `resource_type`. When listing issues, check `issue:view` even though querying project tuples. This follows the design principle: _"Always check the specific resource permission, not the parent resource."_
|
||||
Three cooperating pieces:
|
||||
|
||||
**With relations (for role-specific filtering):**
|
||||
1. **`@can(ScopePermission, ...)` scope-membership gate.** Decides 403 vs 200. Use the scope permission (`WorkspacePermissions.VIEW`, `ProjectPermissions.VIEW`), not the item permission — item permissions like `workitem:view` may not exist at workspace scope for non-admin roles.
|
||||
|
||||
2. **`.authorized_for(request, ItemPermission)` row filter.** Lives on every `SoftDeletionQuerySet` via `AuthorizationQuerySetMixin`. Resolves the scope via the model's `PermissionMeta.scope_map` (falls back to `_PARENT_DECLARATIONS` hierarchy when missing), calls `permission_engine.get_accessible_resources_with_conditions(...)`, and builds a per-resource `Q` that:
|
||||
- Fast-paths workspace owner/admin via workspace-scope wildcard check (no tuple walk).
|
||||
- Merges grants across direct memberships + teamspace link relations: **deny wins > unconditional upgrades conditional > multiple conditionals union**.
|
||||
- For conditional grants (e.g., project guest with `workitem:view+creator`), narrows the filter to the mapped field (`created_by=request.user`).
|
||||
|
||||
3. **`AuthorizedListingView` mixin** enforces via `finalize_response` that `.authorized_for()` (or the explicit `.authorization_not_required(request)` bypass) was called. Omitting the call returns a structured 500 with `code="listing_authorization_misconfigured"` — the response is swapped before super finalizes so it survives `BaseAPIView.dispatch`'s outer exception wrapper with renderer + headers intact.
|
||||
|
||||
**Model-side declaration** (only needed when the model participates in listing authorization):
|
||||
|
||||
```python
|
||||
# Get accessible projects WITH their relations (e.g., for guest filtering)
|
||||
project_relations = self.get_accessible_resources(
|
||||
resource_type="project",
|
||||
permission=WorkitemPermissions.VIEW, # Check issue:view permission
|
||||
include_relations=True, # Returns dict instead of list
|
||||
)
|
||||
# Returns: {project_id: "admin", project_id: "member", project_id: "guest", ...}
|
||||
|
||||
# Separate by role for different filtering logic
|
||||
guest_project_ids = [pid for pid, rel in project_relations.items() if rel == "guest"]
|
||||
non_guest_project_ids = [pid for pid, rel in project_relations.items() if rel != "guest"]
|
||||
|
||||
# Apply role-specific filters (e.g., guests only see their own issues)
|
||||
queryset = Issue.objects.filter(
|
||||
Q(project_id__in=non_guest_project_ids)
|
||||
| Q(project_id__in=guest_project_ids, project__guest_view_all_features=True)
|
||||
| Q(project_id__in=guest_project_ids, project__guest_view_all_features=False,
|
||||
created_by=request.user)
|
||||
)
|
||||
class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
class PermissionMeta:
|
||||
scope_map = {
|
||||
WorkitemPermissions: ScopeSpec(resource_type="project", fk="project_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
```
|
||||
|
||||
**PermissionMixin methods:**
|
||||
Multi-scope models (e.g., `IssueView` has project-scoped and workspace-scoped flavors) declare multiple entries:
|
||||
|
||||
```python
|
||||
class IssueView(ProjectOptionalBaseModel, FiltersMixin):
|
||||
class PermissionMeta:
|
||||
scope_map = {
|
||||
WorkitemViewPermissions: ScopeSpec("project", "project_id"),
|
||||
WorkspaceWorkitemViewPermissions: ScopeSpec("workspace", "workspace_id"),
|
||||
}
|
||||
condition_fields = {
|
||||
Condition.CREATOR: "created_by",
|
||||
}
|
||||
```
|
||||
|
||||
The `condition_fields` map is also the single source of truth read by the engine's per-resource `ConditionEvaluator` — one declaration serves both the queryset filter path and the single-resource check path.
|
||||
|
||||
**Bypass** for the rare genuinely-public listing:
|
||||
|
||||
```python
|
||||
queryset = Project.objects.filter(workspace__slug=slug, network=ProjectNetwork.PUBLIC)
|
||||
queryset = queryset.authorization_not_required(request) # explicit, greppable
|
||||
```
|
||||
|
||||
**Engine primitive** powering the verb:
|
||||
|
||||
| Method | Returns | Purpose |
|
||||
| ----------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `permission_engine.get_accessible_resources_with_conditions(user, permission, scope_type, ws_id)` | `list[AccessibleResource]` | Scope tuples the user can access; each carries `(resource_id, relation, conditions)`. Handles direct + link paths. |
|
||||
| `permission_engine.get_accessible_resources(user, resource_type, ws_id, permission=..., include_relations=False)` | `list[UUID]` or `dict[UUID, str]` | Legacy — unconditional grants only. Prefer the `_with_conditions` variant for listing endpoints. |
|
||||
|
||||
**`PermissionMixin` methods** (still useful for single-resource checks inside handlers):
|
||||
|
||||
| Method | Returns | Purpose |
|
||||
| ---------------------------------------------------------- | ---------------------------------- | ------------------------------------------- |
|
||||
| `check_can(permission, resource_id)` | `bool` (raises `PermissionDenied`) | Check single resource permission |
|
||||
| `has_permission(permission, resource_id)` | `bool` | Check single resource permission (no raise) |
|
||||
| `get_user_permissions(resource_type, resource_id)` | `dict[str, bool]` | Get all permissions for a resource |
|
||||
| `get_accessible_resources(resource_type, permission, ...)` | `list[UUID]` or `dict[UUID, str]` | Get all accessible resource IDs |
|
||||
|
||||
**What `get_accessible_resources()` handles:**
|
||||
|
||||
1. **Direct tuples**: `user → project#{relation}` (ProjectMember records)
|
||||
2. **Link relations**: `user → teamspace#member` + `teamspace → project#teamspace` (Teamspace access)
|
||||
3. **Permission validation**: Only returns resources where the user's role grants the specified permission
|
||||
| `get_accessible_resources(resource_type, permission, ...)` | `list[UUID]` or `dict[UUID, str]` | Legacy accessible-resource query |
|
||||
|
||||
### 8. Permission Sync (Membership → ResourcePermission)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user