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_SERVICE_SLUG: ${PLANE_NODEDC_ACCESS_SERVICE_SLUG:-task-manager}
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS: ${PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS:-3} 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_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_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_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} 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_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=c510f7e389c95a610f34f7569c9ee7fbb744d214bc21e82734578d971e02e0aaa9812aeb83b33efdb76eb90c0a819b0a PLANE_OIDC_CLIENT_SECRET=c510f7e389c95a610f34f7569c9ee7fbb744d214bc21e82734578d971e02e0aaa9812aeb83b33efdb76eb90c0a819b0a
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback 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_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
PLANE_OIDC_AUTO_LINK_EMAIL=1 PLANE_OIDC_AUTO_LINK_EMAIL=1
PLANE_OIDC_AUTO_CREATE_USER=1 PLANE_OIDC_AUTO_CREATE_USER=1
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1 PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1
PLANE_NODEDC_ACCESS_ENFORCEMENT=1 PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check 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_SERVICE_SLUG=task-manager
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3 PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0 PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/ 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_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/
PLANE_NODEDC_LAUNCHER_PUBLIC_URL=http://launcher.local.nodedc PLANE_NODEDC_LAUNCHER_PUBLIC_URL=http://launcher.local.nodedc

View File

@ -70,7 +70,6 @@ def get_access_config():
token = ( token = (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip() os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip() or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
) )
return { return {

View File

@ -185,4 +185,8 @@ def nodedc_launcher_managed_workspace_response():
def is_nodedc_workspace_invite_approval_required(policy): 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.contrib.auth import logout
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator 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 django.views.decorators.csrf import csrf_exempt
from plane.authentication.utils.host import user_ip 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" OIDC_PROVIDER = "authentik"
@ -33,7 +34,6 @@ def get_nodedc_internal_token():
return ( return (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip() os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_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 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): class NodeDCFrontChannelLogoutEndpoint(View):
def get(self, request): def get(self, request):
logout_current_user(request) logout_current_user(request)
@ -269,12 +295,27 @@ class NodeDCInternalSessionLogoutEndpoint(View):
return JsonResponse({"ok": True, "deletedSessions": 0, "user": None}) return JsonResponse({"ok": True, "deletedSessions": 0, "user": None})
guard_keys = mark_logout_guard(user=user, payload=payload) guard_keys = mark_logout_guard(user=user, payload=payload)
with transaction.atomic():
deleted_sessions = invalidate_user_sessions(user, request) 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( return JsonResponse(
{ {
"ok": True, "ok": True,
"deletedSessions": deleted_sessions, "deletedSessions": deleted_sessions,
"deletedIdentityLinks": deleted_identity_links,
"deletedTaskerAccess": deleted_tasker_access,
"guardKeys": len(guard_keys), "guardKeys": len(guard_keys),
"user": { "user": {
"id": str(user.id), "id": str(user.id),

View File

@ -175,12 +175,28 @@ def resolve_nodedc_launcher_origin():
return "" 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")) 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: if avatar_url and user.avatar != avatar_url:
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): 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") @method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View): class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
def post(self, request): 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 ( from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalProjectMembershipEnsureEndpoint, NodeDCInternalProjectMembershipEnsureEndpoint,
NodeDCInternalProjectMembershipRemoveEndpoint, NodeDCInternalProjectMembershipRemoveEndpoint,
NodeDCInternalUserProfileSyncEndpoint,
NodeDCInternalWorkspaceInviteApproveEndpoint, NodeDCInternalWorkspaceInviteApproveEndpoint,
NodeDCInternalWorkspaceInviteRejectEndpoint, NodeDCInternalWorkspaceInviteRejectEndpoint,
NodeDCInternalWorkspaceListEndpoint, NodeDCInternalWorkspaceListEndpoint,
@ -38,6 +39,11 @@ urlpatterns = [
NodeDCInternalWorkspaceListEndpoint.as_view(), NodeDCInternalWorkspaceListEndpoint.as_view(),
name="nodedc-internal-workspaces", name="nodedc-internal-workspaces",
), ),
path(
"api/internal/nodedc/users/profile-sync/",
NodeDCInternalUserProfileSyncEndpoint.as_view(),
name="nodedc-internal-user-profile-sync",
),
path( path(
"api/internal/nodedc/workspace-memberships/ensure/", "api/internal/nodedc/workspace-memberships/ensure/",
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(), NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),