diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 7a7213d..9337d76 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -74,6 +74,8 @@ x-app-env: &app-env PLANE_NODEDC_GLOBAL_LOGOUT_URL: ${PLANE_NODEDC_GLOBAL_LOGOUT_URL:-http://launcher.local.nodedc/auth/logout?global=1&returnTo=/} PLANE_NODEDC_HANDOFF_URL: ${PLANE_NODEDC_HANDOFF_URL:-http://launcher.local.nodedc/api/internal/handoff/consume} PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3} + PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check} + PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3} GUNICORN_WORKERS: 1 POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} POSTHOG_HOST: ${POSTHOG_HOST:-} diff --git a/plane-app/plane.env b/plane-app/plane.env index 2a3877c..1d6148c 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -113,3 +113,5 @@ PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/ PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/ PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consume PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3 +PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check +PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3 diff --git a/plane-src/apps/api/plane/app/urls/workspace.py b/plane-src/apps/api/plane/app/urls/workspace.py index 437ce14..82c3a1c 100644 --- a/plane-src/apps/api/plane/app/urls/workspace.py +++ b/plane-src/apps/api/plane/app/urls/workspace.py @@ -7,6 +7,7 @@ from django.urls import path from plane.app.views import ( UserWorkspaceInvitationsViewSet, + NodeDCWorkspaceCreationPolicyEndpoint, WorkSpaceViewSet, WorkspaceJoinEndpoint, WorkSpaceMemberViewSet, @@ -49,6 +50,11 @@ urlpatterns = [ WorkSpaceAvailabilityCheckEndpoint.as_view(), name="workspace-availability", ), + path( + "nodedc/workspace-policy/", + NodeDCWorkspaceCreationPolicyEndpoint.as_view(), + name="nodedc-workspace-policy", + ), path( "workspaces/", WorkSpaceViewSet.as_view({"get": "list", "post": "create"}), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index a49bcf1..0ffb664 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -37,6 +37,7 @@ from .base import BaseAPIView, BaseViewSet from .workspace.base import ( WorkSpaceViewSet, UserWorkSpacesEndpoint, + NodeDCWorkspaceCreationPolicyEndpoint, WorkSpaceAvailabilityCheckEndpoint, UserWorkspaceDashboardEndpoint, WorkspaceThemeViewSet, diff --git a/plane-src/apps/api/plane/app/views/workspace/base.py b/plane-src/apps/api/plane/app/views/workspace/base.py index d78fac2..e106c0b 100644 --- a/plane-src/apps/api/plane/app/views/workspace/base.py +++ b/plane-src/apps/api/plane/app/views/workspace/base.py @@ -34,6 +34,7 @@ from plane.app.permissions import ( # Module imports from plane.app.serializers import WorkSpaceSerializer, WorkspaceThemeSerializer from plane.app.views.base import BaseAPIView, BaseViewSet +from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy from plane.db.models import ( Issue, IssueActivity, @@ -83,6 +84,17 @@ class WorkSpaceViewSet(BaseViewSet): def create(self, request): try: + workspace_policy = get_nodedc_workspace_creation_policy(request.user) + if not workspace_policy["can_create_workspace"]: + return Response( + { + "error": "nodedc_workspace_creation_denied", + "reason": workspace_policy["reason"], + "workspace_policy": workspace_policy, + }, + status=status.HTTP_403_FORBIDDEN, + ) + (DISABLE_WORKSPACE_CREATION,) = get_configuration_value( [ { @@ -243,6 +255,11 @@ class UserWorkSpacesEndpoint(BaseAPIView): return Response(workspaces, status=status.HTTP_200_OK) +class NodeDCWorkspaceCreationPolicyEndpoint(BaseAPIView): + def get(self, request): + return Response(get_nodedc_workspace_creation_policy(request.user), status=status.HTTP_200_OK) + + class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): def get(self, request): slug = request.GET.get("slug", False) diff --git a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py new file mode 100644 index 0000000..9fdd3b0 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py @@ -0,0 +1,88 @@ +import os + +import requests + +from plane.authentication.views.nodedc_logout import get_nodedc_internal_token +from plane.db.models import ExternalIdentityLink + + +OIDC_PROVIDER = "authentik" + + +def get_nodedc_workspace_creation_policy(user): + check_url = ( + os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_URL", "").strip() + or os.environ.get("PLANE_NODEDC_ACCESS_CHECK_URL", "").strip() + ) + token = get_nodedc_internal_token() + + if not check_url or not token: + return { + "enabled": False, + "can_create_workspace": True, + "mode": "standalone", + "reason": "NODE.DC workspace policy is not configured.", + } + + link = ExternalIdentityLink.objects.filter( + provider=OIDC_PROVIDER, + user=user, + status=ExternalIdentityLink.Status.ACTIVE, + ).first() + + if link is None: + enforce_unlinked = is_truthy(os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_ENFORCE_UNLINKED", "0")) + return { + "enabled": True, + "can_create_workspace": not enforce_unlinked, + "mode": "unlinked", + "reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.", + } + + try: + response = requests.post( + check_url, + json={ + "serviceSlug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager"), + "subject": link.subject, + "email": link.email or user.email, + "userId": None, + }, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS", "3") or "3"), + ) + response.raise_for_status() + payload = response.json() + except (ValueError, requests.RequestException): + return { + "enabled": True, + "can_create_workspace": False, + "mode": "unavailable", + "reason": "NODE.DC workspace policy is unavailable.", + } + + workspace_policy = payload.get("workspacePolicy") if isinstance(payload.get("workspacePolicy"), dict) else {} + access_allowed = bool(payload.get("allowed")) + if not workspace_policy: + return { + "enabled": True, + "can_create_workspace": access_allowed, + "mode": "legacy_access_check", + "reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.", + } + + can_create_workspace = access_allowed and bool(workspace_policy.get("canCreateWorkspace")) + + return { + "enabled": True, + "can_create_workspace": can_create_workspace, + "mode": workspace_policy.get("mode") or "unknown", + "reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.", + } + + +def is_truthy(value): + return str(value).strip().lower() in {"1", "true", "yes", "on"}