[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:
Dheeraj Kumar Ketireddy
2026-04-22 13:58:58 +05:30
committed by GitHub
parent 19663b0050
commit e0ec00e6c6
31 changed files with 2610 additions and 222 deletions
+19 -17
View File
@@ -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,
+9 -57
View File
@@ -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")
+2 -1
View File
@@ -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())
+21
View File
@@ -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
+28
View File
@@ -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 {}
+24 -6
View File
@@ -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
+17
View File
@@ -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
+42 -32
View File
@@ -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,
+40
View File
@@ -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"
+104
View File
@@ -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}."
)
+108
View File
@@ -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
+80
View File
@@ -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)
+20
View File
@@ -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 25 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).
+30 -30
View File
@@ -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).
+28 -28
View File
@@ -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`. |
---
+69 -51
View File
@@ -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)