SECURITY - TASKER: cleanup access and invite approval

This commit is contained in:
DCCONSTRUCTIONS
2026-05-12 12:50:52 +03:00
parent 2224faa8f7
commit 4ffcd64ddc
7 changed files with 108 additions and 9 deletions
@@ -70,7 +70,6 @@ def get_access_config():
token = (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
)
return {
@@ -185,4 +185,8 @@ def nodedc_launcher_managed_workspace_response():
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"
return (
bool(policy.get("enabled"))
and policy.get("managed_by") == "tasker"
and policy.get("invite_approval") in {"nodedc", "launcher"}
)
@@ -6,6 +6,7 @@ from secrets import compare_digest
from django.contrib.auth import logout
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -13,7 +14,7 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.authentication.utils.host import user_ip
from plane.db.models import ExternalIdentityLink, Session, User
from plane.db.models import ExternalIdentityLink, IssueAssignee, ProjectMember, Session, User, WorkspaceMember
OIDC_PROVIDER = "authentik"
@@ -33,7 +34,6 @@ def get_nodedc_internal_token():
return (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
)
@@ -225,6 +225,32 @@ def invalidate_user_sessions(user, request):
return deleted_count
def revoke_user_external_identity_links(user):
deleted_count, _ = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
user=user,
status=ExternalIdentityLink.Status.ACTIVE,
).delete()
return deleted_count
def delete_queryset(queryset):
result = queryset.delete()
return result[0] if isinstance(result, tuple) else result
def revoke_user_tasker_access(user):
deleted_issue_assignees = delete_queryset(IssueAssignee.objects.filter(assignee=user))
deleted_project_memberships = delete_queryset(ProjectMember.objects.filter(member=user))
deleted_workspace_memberships = delete_queryset(WorkspaceMember.objects.filter(member=user))
return {
"workspaceMemberships": deleted_workspace_memberships,
"projectMemberships": deleted_project_memberships,
"issueAssignees": deleted_issue_assignees,
}
class NodeDCFrontChannelLogoutEndpoint(View):
def get(self, request):
logout_current_user(request)
@@ -269,12 +295,27 @@ class NodeDCInternalSessionLogoutEndpoint(View):
return JsonResponse({"ok": True, "deletedSessions": 0, "user": None})
guard_keys = mark_logout_guard(user=user, payload=payload)
deleted_sessions = invalidate_user_sessions(user, request)
with transaction.atomic():
deleted_sessions = invalidate_user_sessions(user, request)
deleted_identity_links = 0
deleted_tasker_access = {
"workspaceMemberships": 0,
"projectMemberships": 0,
"issueAssignees": 0,
}
if payload.get("revokeIdentityLinks") is True or payload.get("revokeIdentityLink") is True:
deleted_identity_links = revoke_user_external_identity_links(user)
if payload.get("revokeTaskerAccess") is True or payload.get("revokeMemberships") is True:
deleted_tasker_access = revoke_user_tasker_access(user)
return JsonResponse(
{
"ok": True,
"deletedSessions": deleted_sessions,
"deletedIdentityLinks": deleted_identity_links,
"deletedTaskerAccess": deleted_tasker_access,
"guardKeys": len(guard_keys),
"user": {
"id": str(user.id),
@@ -175,12 +175,28 @@ def resolve_nodedc_launcher_origin():
return ""
def sync_user_avatar_from_payload(user, payload):
def sync_user_profile_from_payload(user, payload):
updated_fields = []
display_name = first_payload_string(payload, "displayName", "display_name", "name")
avatar_url = normalize_nodedc_avatar_url(first_payload_string(payload, "avatarUrl", "avatar_url", "avatar"))
if display_name and user.display_name != display_name:
user.display_name = display_name
updated_fields.append("display_name")
if avatar_url and user.avatar != avatar_url:
user.avatar = avatar_url
user.save(update_fields=["avatar", "updated_at"])
updated_fields.append("avatar")
if updated_fields:
updated_fields.append("updated_at")
user.save(update_fields=updated_fields)
return updated_fields
def sync_user_avatar_from_payload(user, payload):
sync_user_profile_from_payload(user, payload)
def serialize_project(project):
@@ -328,6 +344,37 @@ class NodeDCInternalWorkspaceListEndpoint(View):
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalUserProfileSyncEndpoint(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)
user = resolve_user(payload)
if user is None:
return JsonResponse({"ok": False, "error": "user_not_found"}, status=404)
with transaction.atomic():
updated_fields = sync_user_profile_from_payload(user, payload)
return JsonResponse(
{
"ok": True,
"updatedFields": updated_fields,
"user": {
"id": str(user.id),
"email": user.email,
"displayName": user.display_name,
"avatar": user.avatar or None,
},
}
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
def post(self, request):