SEC - TASKER: enforce workspace-scoped agent access
This commit is contained in:
@@ -12,7 +12,7 @@ from rest_framework.response import Response
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
|
||||
from plane.db.models import Project, Workspace
|
||||
from plane.db.models import Project, ProjectMember, Workspace, WorkspaceMember
|
||||
|
||||
|
||||
def get_gateway_config():
|
||||
@@ -80,16 +80,35 @@ def require_workspace(slug):
|
||||
)
|
||||
|
||||
|
||||
def validate_project_in_workspace(workspace, project_id):
|
||||
def is_workspace_admin(user, workspace):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
def validate_project_in_workspace(workspace, project_id, user):
|
||||
workspace_admin = is_workspace_admin(user, workspace)
|
||||
if not project_id:
|
||||
if not workspace_admin:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "project_required",
|
||||
"message": "Workspace members must select a concrete project for Codex Agent grants.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
exists = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).exists()
|
||||
project = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).first()
|
||||
except ValidationError:
|
||||
exists = False
|
||||
project = None
|
||||
|
||||
if not exists:
|
||||
if project is None:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -98,6 +117,25 @@ def validate_project_in_workspace(workspace, project_id):
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if workspace_admin:
|
||||
return None
|
||||
|
||||
if not ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
role__gte=ROLE.MEMBER.value,
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "project_access_denied",
|
||||
"message": "Codex Agent grants are limited to projects where this user is an active project member.",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -147,7 +185,7 @@ class CodexAgentEntitledEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -155,7 +193,7 @@ class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents")
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -177,7 +215,7 @@ class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
|
||||
class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -185,7 +223,7 @@ class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}")
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def patch(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -200,7 +238,7 @@ class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
|
||||
class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -214,7 +252,7 @@ class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
|
||||
class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -222,7 +260,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants")
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -233,7 +271,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||
return workspace_error
|
||||
|
||||
project_id = request.data.get("project_id")
|
||||
project_error = validate_project_in_workspace(workspace, project_id)
|
||||
project_error = validate_project_in_workspace(workspace, project_id, request.user)
|
||||
if project_error is not None:
|
||||
return project_error
|
||||
|
||||
@@ -247,7 +285,7 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
|
||||
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -255,7 +293,7 @@ class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens")
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -269,7 +307,7 @@ class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
|
||||
class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, agent_id, token_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
@@ -283,7 +321,7 @@ class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
|
||||
|
||||
|
||||
class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from html import escape
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
@@ -9,6 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from plane.app.realtime.issue_events import publish_issue_event_on_commit
|
||||
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
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
@@ -244,6 +246,33 @@ def validate_internal_request(request):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_agent_owner(request):
|
||||
identity = get_agent_identity(request)
|
||||
if identity is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return User.objects.filter(id=identity["owner_user_id"], is_active=True).first()
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_agent_workspace_entitlement(request, workspace_slug):
|
||||
if not workspace_slug:
|
||||
return validation_error("workspace_slug_required")
|
||||
|
||||
owner = resolve_agent_owner(request)
|
||||
if owner is None:
|
||||
return validation_error("agent_owner_not_found", status=403)
|
||||
|
||||
workspace_policy = get_nodedc_workspace_creation_policy(owner, workspace_slug=workspace_slug)
|
||||
service_modules = workspace_policy.get("service_modules") or {}
|
||||
if service_modules.get("codex_agents") is not True:
|
||||
return validation_error("codex_agents_not_entitled", status=403)
|
||||
|
||||
return 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>"
|
||||
@@ -319,6 +348,9 @@ 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:
|
||||
@@ -358,6 +390,10 @@ 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
|
||||
|
||||
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")
|
||||
members = (
|
||||
@@ -392,6 +428,10 @@ 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
|
||||
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(project=project)
|
||||
.select_related("workspace", "project", "state")
|
||||
@@ -416,6 +456,10 @@ 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
|
||||
|
||||
title = payload.get("title")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return validation_error("title_required")
|
||||
@@ -466,6 +510,10 @@ 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
|
||||
|
||||
detail_layout = (
|
||||
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
|
||||
if "structured_blocks" in payload
|
||||
@@ -519,6 +567,10 @@ 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
|
||||
|
||||
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
|
||||
if state is None:
|
||||
return validation_error("state_not_found", status=404)
|
||||
@@ -555,6 +607,10 @@ 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
|
||||
|
||||
body = payload.get("body")
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
return validation_error("body_required")
|
||||
@@ -592,6 +648,10 @@ 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
|
||||
|
||||
label_ids = payload.get("label_ids")
|
||||
if not isinstance(label_ids, list):
|
||||
return validation_error("label_ids_required")
|
||||
@@ -625,6 +685,10 @@ 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
|
||||
|
||||
member_ids = payload.get("member_ids")
|
||||
if not isinstance(member_ids, list):
|
||||
return validation_error("member_ids_required")
|
||||
|
||||
Reference in New Issue
Block a user