SECURITY - TASKER: cleanup access and invite approval
This commit is contained in:
parent
2224faa8f7
commit
4ffcd64ddc
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue