FEAT - TASKER CODEX: user-scoped multi-workspace grants
This commit is contained in:
@@ -8,7 +8,9 @@ from plane.app.views import (
|
||||
CodexAgentDetailEndpoint,
|
||||
CodexAgentGrantListEndpoint,
|
||||
CodexAgentGrantReplaceEndpoint,
|
||||
CodexAgentGrantReplaceProjectsEndpoint,
|
||||
CodexAgentListEndpoint,
|
||||
CodexAgentProjectAccessEndpoint,
|
||||
CodexAgentRevokeEndpoint,
|
||||
CodexAgentSetupEndpoint,
|
||||
CodexAgentTokenListEndpoint,
|
||||
@@ -17,6 +19,11 @@ from plane.app.views import (
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/codex-agent-api/project-access/",
|
||||
CodexAgentProjectAccessEndpoint.as_view(),
|
||||
name="codex-agent-api-project-access",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/codex-agent-api/agents/",
|
||||
CodexAgentListEndpoint.as_view(),
|
||||
@@ -42,6 +49,11 @@ urlpatterns = [
|
||||
CodexAgentGrantReplaceEndpoint.as_view(),
|
||||
name="codex-agent-api-agent-grants-replace",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/grants/replace-projects/",
|
||||
CodexAgentGrantReplaceProjectsEndpoint.as_view(),
|
||||
name="codex-agent-api-agent-grants-replace-projects",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/",
|
||||
CodexAgentTokenListEndpoint.as_view(),
|
||||
|
||||
@@ -178,7 +178,9 @@ from .codex_agents import (
|
||||
CodexAgentDetailEndpoint,
|
||||
CodexAgentGrantListEndpoint,
|
||||
CodexAgentGrantReplaceEndpoint,
|
||||
CodexAgentGrantReplaceProjectsEndpoint,
|
||||
CodexAgentListEndpoint,
|
||||
CodexAgentProjectAccessEndpoint,
|
||||
CodexAgentRevokeEndpoint,
|
||||
CodexAgentSetupEndpoint,
|
||||
CodexAgentTokenListEndpoint,
|
||||
|
||||
@@ -86,6 +86,8 @@ def is_workspace_admin(user, workspace):
|
||||
member=user,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
is_banned=False,
|
||||
deleted_at__isnull=True,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -104,7 +106,12 @@ def validate_project_in_workspace(workspace, project_id, user):
|
||||
return None
|
||||
|
||||
try:
|
||||
project = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).first()
|
||||
project = Project.objects.filter(
|
||||
id=project_id,
|
||||
workspace=workspace,
|
||||
archived_at__isnull=True,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
except ValidationError:
|
||||
project = None
|
||||
|
||||
@@ -126,6 +133,7 @@ def validate_project_in_workspace(workspace, project_id, user):
|
||||
member=user,
|
||||
role__gte=ROLE.MEMBER.value,
|
||||
is_active=True,
|
||||
deleted_at__isnull=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{
|
||||
@@ -174,6 +182,111 @@ def validate_projects_in_workspace(workspace, project_ids, user):
|
||||
return normalized_project_ids
|
||||
|
||||
|
||||
def get_accessible_projects_for_workspace(workspace, user):
|
||||
queryset = Project.objects.filter(
|
||||
workspace=workspace,
|
||||
archived_at__isnull=True,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
if is_workspace_admin(user, workspace):
|
||||
return queryset.order_by("name")
|
||||
|
||||
project_ids = ProjectMember.objects.filter(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
role__gte=ROLE.MEMBER.value,
|
||||
is_active=True,
|
||||
deleted_at__isnull=True,
|
||||
project__archived_at__isnull=True,
|
||||
project__deleted_at__isnull=True,
|
||||
).values_list("project_id", flat=True)
|
||||
|
||||
return queryset.filter(id__in=project_ids).order_by("name")
|
||||
|
||||
|
||||
def serialize_accessible_project(project):
|
||||
return {
|
||||
"id": str(project.id),
|
||||
"workspace_slug": project.workspace.slug,
|
||||
"name": project.name,
|
||||
"identifier": project.identifier,
|
||||
}
|
||||
|
||||
|
||||
def serialize_accessible_workspace(workspace_member, projects):
|
||||
workspace = workspace_member.workspace
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"slug": workspace.slug,
|
||||
"name": workspace.name,
|
||||
"role": workspace_member.role,
|
||||
"projects": [serialize_accessible_project(project) for project in projects],
|
||||
}
|
||||
|
||||
|
||||
def normalize_project_grants(raw_grants):
|
||||
if not isinstance(raw_grants, list) or len(raw_grants) == 0:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "grants_required",
|
||||
"message": "Select at least one workspace/project grant.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
normalized_grants = []
|
||||
seen_keys = set()
|
||||
for raw_grant in raw_grants:
|
||||
if not isinstance(raw_grant, dict):
|
||||
continue
|
||||
workspace_slug = str(raw_grant.get("workspace_slug") or "").strip()
|
||||
project_id = str(raw_grant.get("project_id") or "").strip()
|
||||
if not workspace_slug or not project_id:
|
||||
continue
|
||||
|
||||
grant_key = f"{workspace_slug}:{project_id}"
|
||||
if grant_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(grant_key)
|
||||
normalized_grants.append(
|
||||
{
|
||||
"workspace_slug": workspace_slug,
|
||||
"project_id": project_id,
|
||||
}
|
||||
)
|
||||
|
||||
if not normalized_grants:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "grants_required",
|
||||
"message": "Select at least one workspace/project grant.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return normalized_grants
|
||||
|
||||
|
||||
def validate_project_grants(grants, user):
|
||||
for grant in grants:
|
||||
workspace, workspace_error = require_workspace(grant["workspace_slug"])
|
||||
if workspace_error is not None:
|
||||
return workspace_error
|
||||
|
||||
_, entitlement_error = require_codex_agent_entitlement(user, workspace.slug)
|
||||
if entitlement_error is not None:
|
||||
return entitlement_error
|
||||
|
||||
project_error = validate_project_in_workspace(workspace, grant["project_id"], user)
|
||||
if project_error is not None:
|
||||
return project_error
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def gateway_request(method, path, payload=None):
|
||||
config, error_response = require_gateway_config()
|
||||
if error_response is not None:
|
||||
@@ -249,6 +362,38 @@ class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
|
||||
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents", payload)
|
||||
|
||||
|
||||
class CodexAgentProjectAccessEndpoint(CodexAgentEntitledEndpoint):
|
||||
@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:
|
||||
return entitlement_error
|
||||
|
||||
workspace_members = (
|
||||
WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
is_banned=False,
|
||||
deleted_at__isnull=True,
|
||||
workspace__deleted_at__isnull=True,
|
||||
)
|
||||
.select_related("workspace")
|
||||
.order_by("workspace__name")
|
||||
)
|
||||
|
||||
workspaces = []
|
||||
for workspace_member in workspace_members:
|
||||
workspace = workspace_member.workspace
|
||||
_, workspace_entitlement_error = require_codex_agent_entitlement(request.user, workspace.slug)
|
||||
if workspace_entitlement_error is not None:
|
||||
continue
|
||||
|
||||
projects = list(get_accessible_projects_for_workspace(workspace, request.user))
|
||||
workspaces.append(serialize_accessible_workspace(workspace_member, projects))
|
||||
|
||||
return Response({"ok": True, "workspaces": workspaces})
|
||||
|
||||
|
||||
class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
@@ -347,6 +492,33 @@ class CodexAgentGrantReplaceEndpoint(CodexAgentEntitledEndpoint):
|
||||
)
|
||||
|
||||
|
||||
class CodexAgentGrantReplaceProjectsEndpoint(CodexAgentEntitledEndpoint):
|
||||
@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:
|
||||
return entitlement_error
|
||||
|
||||
grants_or_error = normalize_project_grants(request.data.get("grants"))
|
||||
if isinstance(grants_or_error, Response):
|
||||
return grants_or_error
|
||||
|
||||
validation_response = validate_project_grants(grants_or_error, request.user)
|
||||
if validation_response is not None:
|
||||
return validation_response
|
||||
|
||||
payload = {
|
||||
"grants": grants_or_error,
|
||||
"scopes": request.data.get("scopes") or [],
|
||||
"mode": request.data.get("mode") or "voluntary",
|
||||
}
|
||||
return gateway_request(
|
||||
"POST",
|
||||
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants/replace-projects",
|
||||
payload,
|
||||
)
|
||||
|
||||
|
||||
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user