From a5a347e8394084523e65c057f7766f57bc053601 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 6 May 2026 10:10:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20Tasker=20workspace=20adapter=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/nodedc_workspace_adapter.py | 186 ++++++++++++++++++ plane-src/apps/api/plane/urls.py | 14 ++ 2 files changed, 200 insertions(+) create mode 100644 plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py new file mode 100644 index 0000000..dcc1db1 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py @@ -0,0 +1,186 @@ +import json + +from django.db import transaction +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized +from plane.db.models import ExternalIdentityLink, Profile, User, Workspace, WorkspaceMember + + +OIDC_PROVIDER = "authentik" +ROLE_VALUES = { + "guest": 5, + "viewer": 5, + "member": 15, + "admin": 20, + "owner": 20, + 5: 5, + 15: 15, + 20: 20, +} + + +def internal_unauthorized_response(): + return JsonResponse({"ok": False, "error": "internal_access_unauthorized"}, status=401) + + +def parse_json_body(request): + if not request.body: + return {} + + try: + return json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + + +def normalize_email(value): + return value.strip().lower() if isinstance(value, str) else "" + + +def resolve_workspace(payload): + workspace_id = payload.get("workspaceId") or payload.get("workspace_id") + workspace_slug = payload.get("workspaceSlug") or payload.get("workspace_slug") or payload.get("slug") + + queryset = Workspace.objects.filter(deleted_at__isnull=True) + if workspace_id: + return queryset.filter(id=workspace_id).first() + if workspace_slug: + return queryset.filter(slug=workspace_slug).first() + return None + + +def resolve_user(payload): + plane_user_id = payload.get("planeUserId") or payload.get("plane_user_id") + subject = payload.get("subject") + email = normalize_email(payload.get("email")) + + if plane_user_id: + user = User.objects.filter(id=plane_user_id, is_bot=False).first() + if user: + return user + + if subject: + link = ExternalIdentityLink.objects.filter( + provider=OIDC_PROVIDER, + subject=subject, + status=ExternalIdentityLink.Status.ACTIVE, + ).select_related("user").first() + if link: + return link.user + + if email: + link = ExternalIdentityLink.objects.filter( + provider=OIDC_PROVIDER, + email__iexact=email, + status=ExternalIdentityLink.Status.ACTIVE, + ).select_related("user").first() + if link: + return link.user + return User.objects.filter(email__iexact=email, is_bot=False).first() + + return None + + +def normalize_role(value): + return ROLE_VALUES.get(value, 15) + + +def serialize_workspace(workspace): + return { + "id": str(workspace.id), + "slug": workspace.slug, + "name": workspace.name, + "ownerEmail": workspace.owner.email if workspace.owner_id else None, + "memberCount": WorkspaceMember.objects.filter( + workspace=workspace, + deleted_at__isnull=True, + is_active=True, + member__is_bot=False, + ).count(), + } + + +def serialize_membership(membership, created): + return { + "created": created, + "workspace": serialize_workspace(membership.workspace), + "member": { + "id": str(membership.member.id), + "email": membership.member.email, + "displayName": membership.member.display_name, + }, + "role": membership.role, + "isActive": membership.is_active, + "isBanned": membership.is_banned, + } + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCInternalWorkspaceListEndpoint(View): + def get(self, request): + if not is_internal_logout_request_authorized(request): + return internal_unauthorized_response() + + workspaces = Workspace.objects.filter(deleted_at__isnull=True).select_related("owner").order_by("name") + return JsonResponse({"ok": True, "workspaces": [serialize_workspace(workspace) for workspace in workspaces]}) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View): + def post(self, request): + if not is_internal_logout_request_authorized(request): + return internal_unauthorized_response() + + payload = parse_json_body(request) + if payload is None: + return JsonResponse({"ok": False, "error": "invalid_json"}, status=400) + + workspace = resolve_workspace(payload) + if workspace is None: + return JsonResponse({"ok": False, "error": "workspace_not_found"}, status=404) + + user = resolve_user(payload) + if user is None: + return JsonResponse({"ok": False, "error": "user_not_found"}, status=404) + + role = normalize_role(payload.get("role")) + company_role = payload.get("companyRole") or payload.get("company_role") + set_last_workspace = payload.get("setLastWorkspace", True) is not False + + with transaction.atomic(): + membership = WorkspaceMember.objects.filter( + workspace=workspace, + member=user, + deleted_at__isnull=True, + ).first() + created = membership is None + + if membership is None: + membership = WorkspaceMember.objects.create( + workspace=workspace, + member=user, + role=role, + company_role=company_role if isinstance(company_role, str) else None, + is_active=True, + is_banned=False, + ) + else: + membership.role = role + if isinstance(company_role, str): + membership.company_role = company_role + membership.is_active = True + membership.is_banned = False + membership.banned_at = None + membership.banned_until = None + membership.save(update_fields=["role", "company_role", "is_active", "is_banned", "banned_at", "banned_until", "updated_at"]) + + if set_last_workspace: + profile, _ = Profile.objects.get_or_create(user=user) + profile.last_workspace_id = workspace.id + profile.save(update_fields=["last_workspace_id", "updated_at"]) + + return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)}) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 0975316..d8ee12c 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -15,6 +15,10 @@ from plane.authentication.views.nodedc_logout import ( NodeDCFrontChannelLogoutEndpoint, NodeDCInternalSessionLogoutEndpoint, ) +from plane.authentication.views.nodedc_workspace_adapter import ( + NodeDCInternalWorkspaceListEndpoint, + NodeDCInternalWorkspaceMembershipEnsureEndpoint, +) handler404 = "plane.app.views.error_404.custom_404_view" @@ -24,6 +28,16 @@ urlpatterns = [ NodeDCInternalSessionLogoutEndpoint.as_view(), name="nodedc-internal-session-logout", ), + path( + "api/internal/nodedc/workspaces/", + NodeDCInternalWorkspaceListEndpoint.as_view(), + name="nodedc-internal-workspaces", + ), + path( + "api/internal/nodedc/workspace-memberships/ensure/", + NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(), + name="nodedc-internal-workspace-membership-ensure", + ), path("api/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")),