ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: self-service workspace invite approval

This commit is contained in:
DCCONSTRUCTIONS
2026-05-09 22:57:07 +03:00
parent 42bc0fb0e6
commit 0be8f01283
22 changed files with 707 additions and 77 deletions
@@ -0,0 +1,140 @@
import os
import requests
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
ROLE_SLUGS = {
5: "guest",
15: "member",
20: "admin",
}
def get_nodedc_workspace_invite_request_url():
launcher_base_url = os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "http://launcher.local.nodedc").rstrip("/")
return (
os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_URL", "").strip()
or f"{launcher_base_url}/api/internal/tasker/invite-requests"
)
def get_nodedc_workspace_invite_cancel_url():
launcher_base_url = os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "http://launcher.local.nodedc").rstrip("/")
return (
os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_CANCEL_URL", "").strip()
or f"{launcher_base_url}/api/internal/tasker/invite-requests/cancel"
)
def request_nodedc_workspace_invite_approval(request, workspace, invitation):
request_url = get_nodedc_workspace_invite_request_url()
token = get_nodedc_internal_token()
if not request_url or not token:
raise RuntimeError("NODE.DC invite approval is not configured")
response = requests.post(
request_url,
json={
"taskerInviteId": str(invitation.id),
"workspace": {
"id": str(workspace.id),
"slug": workspace.slug,
"name": workspace.name,
},
"invitee": {
"email": invitation.email,
"role": ROLE_SLUGS.get(invitation.role, "member"),
},
"inviter": {
"planeUserId": str(request.user.id),
"subject": get_nodedc_subject(request.user),
"email": request.user.email,
"name": get_user_display_name(request.user),
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def cancel_nodedc_workspace_invite_approval(request, invitation):
request_url = get_nodedc_workspace_invite_cancel_url()
token = get_nodedc_internal_token()
if not request_url or not token:
raise RuntimeError("NODE.DC invite approval cancellation is not configured")
response = requests.post(
request_url,
json={
"taskerInviteId": str(invitation.id),
"requestId": invitation.nodedc_approval_request_id,
"workspaceSlug": invitation.workspace.slug,
"inviteeEmail": invitation.email,
"cancelledBy": {
"planeUserId": str(request.user.id),
"subject": get_nodedc_subject(request.user),
"email": request.user.email,
"name": get_user_display_name(request.user),
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def cancel_nodedc_workspace_member_access(request, workspace_member):
request_url = get_nodedc_workspace_invite_cancel_url()
token = get_nodedc_internal_token()
if not request_url or not token:
raise RuntimeError("NODE.DC workspace member cancellation is not configured")
response = requests.post(
request_url,
json={
"workspaceSlug": workspace_member.workspace.slug,
"inviteeEmail": workspace_member.member.email,
"comment": "Пользователь удалён из workspace Operational Core.",
"cancelledBy": {
"planeUserId": str(request.user.id),
"subject": get_nodedc_subject(request.user),
"email": request.user.email,
"name": get_user_display_name(request.user),
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_INVITE_REQUEST_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def get_user_display_name(user):
display_name = getattr(user, "display_name", None)
if display_name:
return display_name
name = " ".join([value for value in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if value]).strip()
return name or user.email
def get_nodedc_subject(user):
link = user.external_identity_links.filter(provider="authentik", status="active").first()
return link.subject if link else None
@@ -0,0 +1,126 @@
from django.db import transaction
from django.utils import timezone
from plane.bgtasks.event_tracking_task import track_event
from plane.db.models import Profile, WorkspaceMember, WorkspaceMemberInvite
from plane.utils.analytics_events import USER_JOINED_WORKSPACE
from plane.utils.cache import invalidate_cache_directly
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
class NodeDCWorkspaceInviteJoinError(Exception):
def __init__(self, code, status_code=400):
super().__init__(code)
self.code = code
self.status_code = status_code
def accept_nodedc_workspace_invite_request_for_user(request_id, user):
if not request_id:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_request_missing", 400)
if not user or user.is_anonymous:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_user_not_authenticated", 401)
with transaction.atomic():
invitation = (
WorkspaceMemberInvite.objects.select_for_update(of=("self",))
.select_related("workspace", "created_by")
.filter(nodedc_approval_request_id=request_id, deleted_at__isnull=True)
.first()
)
if invitation is None:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_not_found", 404)
if invitation.nodedc_approval_status != WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED:
raise NodeDCWorkspaceInviteJoinError("workspace_invite_not_approved", 403)
if (invitation.email or "").strip().lower() != (user.email or "").strip().lower():
raise NodeDCWorkspaceInviteJoinError("workspace_invite_email_mismatch", 403)
workspace_member = (
WorkspaceMember.all_objects.select_for_update()
.filter(workspace=invitation.workspace, member=user)
.order_by("-created_at")
.first()
)
if is_workspace_member_currently_banned(workspace_member):
raise NodeDCWorkspaceInviteJoinError("workspace_invite_user_banned", 403)
if workspace_member is not None and workspace_member.is_banned:
release_workspace_member_ban(workspace_member)
created = workspace_member is None
if workspace_member is None:
workspace_member = WorkspaceMember.objects.create(
workspace=invitation.workspace,
member=user,
role=invitation.role,
created_by=invitation.created_by,
)
else:
workspace_member.role = invitation.role
workspace_member.is_active = True
workspace_member.is_banned = False
workspace_member.banned_at = None
workspace_member.banned_until = None
workspace_member.ban_project_member_ids = []
workspace_member.deleted_at = None
workspace_member.save(
update_fields=[
"role",
"is_active",
"is_banned",
"banned_at",
"banned_until",
"ban_project_member_ids",
"deleted_at",
"updated_at",
]
)
profile, _ = Profile.objects.get_or_create(user=user)
profile.last_workspace_id = invitation.workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"])
invitation.accepted = True
invitation.responded_at = timezone.now()
invitation.save(update_fields=["accepted", "responded_at", "updated_at"])
workspace_slug = invitation.workspace.slug
workspace_id = invitation.workspace.id
role = invitation.role
invitation.delete()
invalidate_cache_directly(
path=f"/api/workspaces/{workspace_slug}/members/",
url_params=False,
user=False,
multiple=True,
)
invalidate_cache_directly(path="/api/workspaces/", user=False, multiple=True)
invalidate_cache_directly(path="/api/users/me/workspaces/", user=False, multiple=True)
track_event.delay(
user_id=user.id,
event_name=USER_JOINED_WORKSPACE,
slug=workspace_slug,
event_properties={
"user_id": user.id,
"workspace_id": workspace_id,
"workspace_slug": workspace_slug,
"role": role,
"joined_at": str(timezone.now()),
"source": "nodedc_platform_invite",
},
)
return {
"created": created,
"workspaceSlug": workspace_slug,
"workspaceId": str(workspace_id),
"role": role,
}
@@ -23,6 +23,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"mode": "standalone",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC workspace policy is not configured.",
}
@@ -41,6 +43,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"mode": "unlinked",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.",
}
@@ -69,6 +73,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"mode": "unavailable",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "disabled",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": "NODE.DC workspace policy is unavailable.",
}
@@ -82,6 +88,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"mode": "legacy_access_check",
"managed_by": "tasker",
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"workspaces": [],
"reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.",
}
@@ -93,6 +101,13 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
workspaces=workspaces,
fallback=workspace_policy.get("managedBy") or workspace_policy.get("defaultManagedBy"),
)
invite_approval = normalize_invite_approval(workspace_policy.get("inviteApproval") or workspace_policy.get("invite_approval"))
default_invite_approval = normalize_invite_approval(
workspace_policy.get("defaultInviteApproval")
or workspace_policy.get("default_invite_approval")
or workspace_policy.get("inviteApproval")
or workspace_policy.get("invite_approval")
)
return {
"enabled": True,
@@ -100,6 +115,8 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"mode": workspace_policy.get("mode") or "unknown",
"managed_by": managed_by,
"default_managed_by": normalize_managed_by(workspace_policy.get("defaultManagedBy") or workspace_policy.get("managedBy")),
"invite_approval": invite_approval,
"default_invite_approval": default_invite_approval,
"workspaces": workspaces,
"reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.",
}
@@ -113,6 +130,10 @@ def normalize_managed_by(value):
return "launcher" if value == "launcher" else "tasker"
def normalize_invite_approval(value):
return value if value in {"tasker", "nodedc", "launcher", "disabled"} else "tasker"
def normalize_workspace_management_list(value):
if not isinstance(value, list):
return []
@@ -161,3 +182,7 @@ def nodedc_launcher_managed_workspace_response():
"error": "nodedc_launcher_managed_workspace",
"reason": "Участниками и ролями этого workspace управляет Launcher.",
}
def is_nodedc_workspace_invite_approval_required(policy):
return bool(policy.get("enabled")) and policy.get("managed_by") == "tasker" and policy.get("invite_approval") == "nodedc"
@@ -17,6 +17,10 @@ from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.nodedc_workspace_join import (
NodeDCWorkspaceInviteJoinError,
accept_nodedc_workspace_invite_request_for_user,
)
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
from plane.db.models import ExternalIdentityLink, Profile, User
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
@@ -24,6 +28,7 @@ from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX = "/auth/nodedc/workspace-invite/accept/"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
logger = logging.getLogger(__name__)
@@ -161,10 +166,45 @@ class NodeDCHandoffEndpoint(View):
user_login(request=request, user=user, is_app=True)
workspace_invite_redirect = resolve_nodedc_workspace_invite_accept_redirect(base_url, next_path, user)
if workspace_invite_redirect is not None:
return workspace_invite_redirect
path = next_path or get_redirection_path(user=user)
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
def resolve_nodedc_workspace_invite_accept_redirect(base_url, next_path, user):
if not next_path.startswith(NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX):
return None
request_id = next_path.removeprefix(NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX).strip("/")
try:
result = accept_nodedc_workspace_invite_request_for_user(request_id, user)
except NodeDCWorkspaceInviteJoinError as error:
logger.warning(
"NODEDC managed workspace invite accept failed: code=%s user_id=%s request_id=%s",
error.code,
user.id,
request_id,
)
return oidc_error_redirect(base_url, "", error.code)
except Exception:
logger.exception(
"NODEDC managed workspace invite accept crashed: user_id=%s request_id=%s",
user.id,
request_id,
)
return oidc_error_redirect(base_url, "", "workspace_invite_accept_failed")
workspace_slug = result.get("workspaceSlug")
if not workspace_slug:
return oidc_error_redirect(base_url, "", "workspace_invite_redirect_missing")
return HttpResponseRedirect(f"{base_url.rstrip('/')}/{workspace_slug}/")
def get_oidc_config():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
@@ -4,11 +4,13 @@ from urllib.parse import urlparse
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
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.utils.host import base_host
from plane.db.models import (
ExternalIdentityLink,
IssueAssignee,
@@ -18,6 +20,7 @@ from plane.db.models import (
User,
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
)
@@ -226,6 +229,44 @@ def serialize_membership(membership, created):
}
def resolve_workspace_invite(payload):
invite_id = payload.get("taskerInviteId") or payload.get("tasker_invite_id") or payload.get("inviteId")
request_id = payload.get("requestId") or payload.get("request_id")
queryset = WorkspaceMemberInvite.objects.filter(deleted_at__isnull=True).select_related("workspace")
if request_id:
queryset = queryset.filter(nodedc_approval_request_id=request_id)
if invite_id:
return queryset.filter(id=invite_id).first()
return None
def build_workspace_invite_link(request, invitation):
invite_path = f"/workspace-invitations/?invitation_id={invitation.id}&slug={invitation.workspace.slug}&token={invitation.token}"
return f"{base_host(request=request, is_app=True).rstrip('/')}{invite_path}"
def serialize_workspace_invite(request, invitation):
tasker_invite_link = build_workspace_invite_link(request, invitation)
return {
"id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"workspace": serialize_workspace(invitation.workspace),
"platformInviteLink": invitation.nodedc_platform_invite_link,
"taskerInviteLink": (
tasker_invite_link
if invitation.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
else None
),
"inviteLink": (
invitation.nodedc_platform_invite_link or tasker_invite_link
if invitation.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
else None
),
}
def serialize_project_membership(project_member, created):
return {
"created": created,
@@ -414,6 +455,68 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceInviteApproveEndpoint(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)
invitation = resolve_workspace_invite(payload)
if invitation is None:
return JsonResponse({"ok": False, "error": "workspace_invite_not_found"}, status=404)
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
invitation.nodedc_approval_request_id = payload.get("requestId") or invitation.nodedc_approval_request_id
invitation.nodedc_approval_decided_at = timezone.now()
invitation.nodedc_platform_invite_link = payload.get("platformInviteLink") or invitation.nodedc_platform_invite_link
invitation.save(
update_fields=[
"nodedc_approval_status",
"nodedc_approval_request_id",
"nodedc_approval_decided_at",
"nodedc_platform_invite_link",
"updated_at",
]
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceInviteRejectEndpoint(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)
invitation = resolve_workspace_invite(payload)
if invitation is None:
return JsonResponse({"ok": False, "error": "workspace_invite_not_found"}, status=404)
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_REJECTED
invitation.nodedc_approval_request_id = payload.get("requestId") or invitation.nodedc_approval_request_id
invitation.nodedc_approval_decided_at = timezone.now()
invitation.message = payload.get("comment") or invitation.message
invitation.save(
update_fields=[
"nodedc_approval_status",
"nodedc_approval_request_id",
"nodedc_approval_decided_at",
"message",
"updated_at",
]
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalProjectMembershipEnsureEndpoint(View):
def post(self, request):