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
@@ -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):