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

View File

@ -70,6 +70,7 @@ x-app-env: &app-env
PLANE_NODEDC_ACCESS_SERVICE_SLUG: ${PLANE_NODEDC_ACCESS_SERVICE_SLUG:-task-manager}
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS: ${PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS:-3}
PLANE_NODEDC_ACCESS_CACHE_SECONDS: ${PLANE_NODEDC_ACCESS_CACHE_SECONDS:-0}
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED: ${PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED:-0}
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL: ${PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL:-http://launcher.local.nodedc/}
PLANE_NODEDC_GLOBAL_LOGOUT_URL: ${PLANE_NODEDC_GLOBAL_LOGOUT_URL:-http://launcher.local.nodedc/auth/logout?global=1&returnTo=/}
PLANE_NODEDC_LAUNCHER_PUBLIC_URL: ${PLANE_NODEDC_LAUNCHER_PUBLIC_URL:-http://launcher.local.nodedc}

View File

@ -98,17 +98,18 @@ PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=c510f7e389c95a610f34f7569c9ee7fbb744d214bc21e82734578d971e02e0aaa9812aeb83b33efdb76eb90c0a819b0a
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
PLANE_OIDC_SCOPE=openid email profile groups
PLANE_OIDC_SCOPE="openid email profile groups"
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
PLANE_OIDC_AUTO_LINK_EMAIL=1
PLANE_OIDC_AUTO_CREATE_USER=1
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check
PLANE_NODEDC_ACCESS_TOKEN=
PLANE_NODEDC_ACCESS_TOKEN=local-dev-nodedc-internal-token-change-me
PLANE_NODEDC_ACCESS_SERVICE_SLUG=task-manager
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1
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_LAUNCHER_PUBLIC_URL=http://launcher.local.nodedc

View File

@ -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 {

View File

@ -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"}
)

View File

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

View File

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

View File

@ -18,6 +18,7 @@ from plane.authentication.views.nodedc_logout import (
from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalProjectMembershipEnsureEndpoint,
NodeDCInternalProjectMembershipRemoveEndpoint,
NodeDCInternalUserProfileSyncEndpoint,
NodeDCInternalWorkspaceInviteApproveEndpoint,
NodeDCInternalWorkspaceInviteRejectEndpoint,
NodeDCInternalWorkspaceListEndpoint,
@ -38,6 +39,11 @@ urlpatterns = [
NodeDCInternalWorkspaceListEndpoint.as_view(),
name="nodedc-internal-workspaces",
),
path(
"api/internal/nodedc/users/profile-sync/",
NodeDCInternalUserProfileSyncEndpoint.as_view(),
name="nodedc-internal-user-profile-sync",
),
path(
"api/internal/nodedc/workspace-memberships/ensure/",
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),