FEAT - TASKER CODEX: user-scoped multi-workspace grants

This commit is contained in:
DCCONSTRUCTIONS
2026-05-16 14:22:33 +03:00
parent 8f87f03ee6
commit 491a2b52c8
7 changed files with 544 additions and 125 deletions
@@ -10,6 +10,7 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.app.permissions import ROLE
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body
@@ -347,6 +348,92 @@ def validate_agent_workspace_entitlement(request, workspace_slug):
return None
def get_agent_owner_workspace_membership(request, workspace):
owner = resolve_agent_owner(request)
if owner is None:
return None, validation_error("agent_owner_not_found", status=403)
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace,
member=owner,
is_active=True,
is_banned=False,
deleted_at__isnull=True,
).first()
if workspace_member is None:
return None, validation_error("workspace_access_denied", status=403)
return workspace_member, None
def validate_agent_project_access(request, project):
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
workspace_member, membership_error = get_agent_owner_workspace_membership(request, project.workspace)
if membership_error is not None:
return membership_error
if workspace_member.role == ROLE.ADMIN.value:
return None
owner = resolve_agent_owner(request)
if owner is None:
return validation_error("agent_owner_not_found", status=403)
if ProjectMember.objects.filter(
project=project,
member=owner,
role__gte=ROLE.MEMBER.value,
is_active=True,
deleted_at__isnull=True,
).exists():
return None
return validation_error("project_access_denied", status=403)
def get_agent_accessible_projects(request, workspace_slug):
if not workspace_slug:
return [], validation_error("workspace_slug_required")
entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug)
if entitlement_error is not None:
return [], entitlement_error
project_queryset = Project.objects.filter(
workspace__slug=workspace_slug,
deleted_at__isnull=True,
archived_at__isnull=True,
).select_related("workspace")
first_project = project_queryset.first()
if first_project is None:
return [], None
workspace_member, membership_error = get_agent_owner_workspace_membership(request, first_project.workspace)
if membership_error is not None:
return [], membership_error
if workspace_member.role == ROLE.ADMIN.value:
return list(project_queryset), None
owner = resolve_agent_owner(request)
if owner is None:
return [], validation_error("agent_owner_not_found", status=403)
project_ids = ProjectMember.objects.filter(
workspace=first_project.workspace,
member=owner,
role__gte=ROLE.MEMBER.value,
is_active=True,
deleted_at__isnull=True,
project__deleted_at__isnull=True,
project__archived_at__isnull=True,
).values_list("project_id", flat=True)
return list(project_queryset.filter(id__in=project_ids)), None
def html_from_text(value):
text = value.strip() if isinstance(value, str) else ""
return f"<p>{escape(text)}</p>" if text else "<p></p>"
@@ -586,9 +673,6 @@ class NodeDCAgentProjectResolveEndpoint(View):
project_id = grant.get("project_id")
if not isinstance(workspace_slug, str) or not workspace_slug:
continue
entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug)
if entitlement_error is not None:
continue
if isinstance(project_id, str) and project_id:
project_filters.append((workspace_slug, project_id))
else:
@@ -596,16 +680,14 @@ class NodeDCAgentProjectResolveEndpoint(View):
projects = []
if workspace_slugs:
projects.extend(
Project.objects.filter(
workspace__slug__in=workspace_slugs,
deleted_at__isnull=True,
archived_at__isnull=True,
).select_related("workspace")
)
for workspace_slug in workspace_slugs:
accessible_projects, access_error = get_agent_accessible_projects(request, workspace_slug)
if access_error is not None:
continue
projects.extend(accessible_projects)
for workspace_slug, project_id in project_filters:
project = resolve_project(project_id, workspace_slug)
if project is not None:
if project is not None and validate_agent_project_access(request, project) is None:
projects.append(project)
unique_projects = {str(project.id): project for project in projects}
@@ -628,9 +710,9 @@ class NodeDCAgentProjectContextEndpoint(View):
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence")
labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order")
@@ -666,9 +748,9 @@ class NodeDCAgentProjectLabelsEnsureEndpoint(View):
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
@@ -702,9 +784,9 @@ class NodeDCAgentIssueListEndpoint(View):
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
queryset = (
Issue.issue_objects.filter(project=project)
@@ -730,9 +812,9 @@ class NodeDCAgentIssueListEndpoint(View):
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
title = payload.get("title")
if not isinstance(title, str) or not title.strip():
@@ -784,9 +866,9 @@ class NodeDCAgentIssueUpdateEndpoint(View):
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
detail_layout = (
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
@@ -841,9 +923,9 @@ class NodeDCAgentIssueMoveEndpoint(View):
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
if state is None:
@@ -881,9 +963,9 @@ class NodeDCAgentIssueCommentEndpoint(View):
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
body = payload.get("body")
if not isinstance(body, str) or not body.strip():
@@ -922,9 +1004,9 @@ class NodeDCAgentIssueLabelsEndpoint(View):
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
label_ids = payload.get("label_ids")
if not isinstance(label_ids, list):
@@ -959,9 +1041,9 @@ class NodeDCAgentIssueAssigneesEndpoint(View):
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
member_ids = payload.get("member_ids")
if not isinstance(member_ids, list):