566 lines
20 KiB
Python
566 lines
20 KiB
Python
# Python imports
|
|
import os
|
|
from urllib.parse import quote
|
|
|
|
# Third party imports
|
|
import requests
|
|
from django.core.exceptions import ValidationError
|
|
from rest_framework import status
|
|
from rest_framework.response import Response
|
|
|
|
# Module imports
|
|
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, ProjectMember, Workspace, WorkspaceMember
|
|
|
|
|
|
def get_gateway_config():
|
|
base_url = (
|
|
os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_URL", "").strip()
|
|
or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_URL", "").strip()
|
|
or os.environ.get("NODEDC_AGENT_GATEWAY_URL", "").strip()
|
|
).rstrip("/")
|
|
token = (
|
|
os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TOKEN", "").strip()
|
|
or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN", "").strip()
|
|
)
|
|
timeout = float(os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS", "5") or "5")
|
|
return base_url, token, timeout
|
|
|
|
|
|
def owner_path(user):
|
|
return quote(str(user.id), safe="")
|
|
|
|
|
|
def agent_path(agent_id):
|
|
return quote(str(agent_id), safe="")
|
|
|
|
|
|
def token_path(token_id):
|
|
return quote(str(token_id), safe="")
|
|
|
|
|
|
def require_gateway_config():
|
|
base_url, token, timeout = get_gateway_config()
|
|
if not base_url or not token:
|
|
return None, Response(
|
|
{
|
|
"ok": False,
|
|
"error": "codex_agent_gateway_not_configured",
|
|
"message": "NODE.DC Codex Agent Gateway URL/token is not configured.",
|
|
},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
return (base_url, token, timeout), None
|
|
|
|
|
|
def require_codex_agent_entitlement(user, slug):
|
|
workspace_policy = get_nodedc_workspace_creation_policy(user, workspace_slug=slug)
|
|
service_modules = workspace_policy.get("service_modules") or {}
|
|
if not service_modules.get("codex_agents"):
|
|
return None, Response(
|
|
{
|
|
"ok": False,
|
|
"error": "codex_agents_not_entitled",
|
|
"message": "Codex Agent API is not enabled for this NODE.DC user/workspace.",
|
|
},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
return workspace_policy, None
|
|
|
|
|
|
def require_workspace(slug):
|
|
try:
|
|
return Workspace.objects.get(slug=slug), None
|
|
except Workspace.DoesNotExist:
|
|
return None, Response(
|
|
{"ok": False, "error": "workspace_not_found"},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
|
|
def is_workspace_admin(user, workspace):
|
|
return WorkspaceMember.objects.filter(
|
|
workspace=workspace,
|
|
member=user,
|
|
role=ROLE.ADMIN.value,
|
|
is_active=True,
|
|
is_banned=False,
|
|
deleted_at__isnull=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:
|
|
project = Project.objects.filter(
|
|
id=project_id,
|
|
workspace=workspace,
|
|
archived_at__isnull=True,
|
|
deleted_at__isnull=True,
|
|
).first()
|
|
except ValidationError:
|
|
project = None
|
|
|
|
if project is None:
|
|
return Response(
|
|
{
|
|
"ok": False,
|
|
"error": "project_not_found",
|
|
"message": "Project is not available in this workspace.",
|
|
},
|
|
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,
|
|
deleted_at__isnull=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
|
|
|
|
|
|
def validate_projects_in_workspace(workspace, project_ids, user):
|
|
if not isinstance(project_ids, list) or len(project_ids) == 0:
|
|
return Response(
|
|
{
|
|
"ok": False,
|
|
"error": "project_ids_required",
|
|
"message": "Select at least one project for Codex Agent grants.",
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
normalized_project_ids = []
|
|
for project_id in project_ids:
|
|
project_id = str(project_id or "").strip()
|
|
if not project_id or project_id in normalized_project_ids:
|
|
continue
|
|
|
|
project_error = validate_project_in_workspace(workspace, project_id, user)
|
|
if project_error is not None:
|
|
return project_error
|
|
normalized_project_ids.append(project_id)
|
|
|
|
if not normalized_project_ids:
|
|
return Response(
|
|
{
|
|
"ok": False,
|
|
"error": "project_ids_required",
|
|
"message": "Select at least one project for Codex Agent grants.",
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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:
|
|
return error_response
|
|
|
|
base_url, token, timeout = config
|
|
try:
|
|
response = requests.request(
|
|
method,
|
|
f"{base_url}{path}",
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json",
|
|
},
|
|
json=payload,
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException:
|
|
return Response(
|
|
{
|
|
"ok": False,
|
|
"error": "codex_agent_gateway_unavailable",
|
|
"message": "NODE.DC Codex Agent Gateway is unavailable.",
|
|
},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
data = {
|
|
"ok": False,
|
|
"error": "codex_agent_gateway_invalid_response",
|
|
"message": "NODE.DC Codex Agent Gateway returned a non-JSON response.",
|
|
}
|
|
|
|
return Response(data, status=response.status_code)
|
|
|
|
|
|
class CodexAgentEntitledEndpoint(BaseAPIView):
|
|
def require_entitlement(self, request, slug):
|
|
_, entitlement_error = require_codex_agent_entitlement(request.user, slug)
|
|
return entitlement_error
|
|
|
|
|
|
class CodexAgentListEndpoint(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
|
|
|
|
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents")
|
|
|
|
@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:
|
|
return entitlement_error
|
|
|
|
display_name = str(request.data.get("display_name") or "").strip()
|
|
if not display_name:
|
|
return Response(
|
|
{"ok": False, "error": "display_name_required"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
payload = {
|
|
"display_name": display_name,
|
|
"owner_email": request.user.email or None,
|
|
"avatar_url": request.data.get("avatar_url") or None,
|
|
}
|
|
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):
|
|
entitlement_error = self.require_entitlement(request, slug)
|
|
if entitlement_error is not None:
|
|
return entitlement_error
|
|
|
|
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}")
|
|
|
|
@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:
|
|
return entitlement_error
|
|
|
|
payload = {"actor_user_id": str(request.user.id)}
|
|
if "display_name" in request.data:
|
|
payload["display_name"] = request.data.get("display_name")
|
|
if "avatar_url" in request.data:
|
|
payload["avatar_url"] = request.data.get("avatar_url") or None
|
|
return gateway_request("PATCH", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}", payload)
|
|
|
|
|
|
class CodexAgentRevokeEndpoint(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
|
|
|
|
return gateway_request(
|
|
"POST",
|
|
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/revoke",
|
|
{"actor_user_id": str(request.user.id)},
|
|
)
|
|
|
|
|
|
class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
|
@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:
|
|
return entitlement_error
|
|
|
|
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, 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
|
|
|
|
workspace, workspace_error = require_workspace(slug)
|
|
if workspace_error is not None:
|
|
return workspace_error
|
|
|
|
project_id = request.data.get("project_id")
|
|
project_error = validate_project_in_workspace(workspace, project_id, request.user)
|
|
if project_error is not None:
|
|
return project_error
|
|
|
|
payload = {
|
|
"workspace_slug": slug,
|
|
"project_id": str(project_id) if project_id else None,
|
|
"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", payload)
|
|
|
|
|
|
class CodexAgentGrantReplaceEndpoint(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
|
|
|
|
workspace, workspace_error = require_workspace(slug)
|
|
if workspace_error is not None:
|
|
return workspace_error
|
|
|
|
project_ids_or_error = validate_projects_in_workspace(workspace, request.data.get("project_ids"), request.user)
|
|
if isinstance(project_ids_or_error, Response):
|
|
return project_ids_or_error
|
|
|
|
payload = {
|
|
"workspace_slug": slug,
|
|
"project_ids": project_ids_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",
|
|
payload,
|
|
)
|
|
|
|
|
|
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):
|
|
entitlement_error = self.require_entitlement(request, slug)
|
|
if entitlement_error is not None:
|
|
return entitlement_error
|
|
|
|
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, 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
|
|
|
|
payload = {
|
|
"name": request.data.get("name") or "Local Codex token",
|
|
"expires_at": request.data.get("expires_at") or None,
|
|
}
|
|
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens", payload)
|
|
|
|
|
|
class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
|
|
@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:
|
|
return entitlement_error
|
|
|
|
return gateway_request(
|
|
"POST",
|
|
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens/{token_path(token_id)}/revoke",
|
|
{"actor_user_id": str(request.user.id)},
|
|
)
|
|
|
|
|
|
class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
|
|
@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:
|
|
return entitlement_error
|
|
|
|
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")
|