diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index a84126e..4309eca 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -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} diff --git a/plane-app/plane.env b/plane-app/plane.env index 82b36d6..01119ee 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -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 diff --git a/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py b/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py index 7858b84..2aea722 100644 --- a/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py +++ b/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py @@ -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 { diff --git a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py index 5938d77..697f99a 100644 --- a/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py +++ b/plane-src/apps/api/plane/authentication/nodedc_workspace_policy.py @@ -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"} + ) diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py index ba5445e..1117168 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py @@ -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), diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py index e9ffe4a..d7e91a7 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py @@ -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): diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index a409107..659ad8a 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -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(),