diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index ab51420..3f66b65 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -62,6 +62,14 @@ x-app-env: &app-env PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user} PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0} PLANE_OIDC_SYNC_PROFILE: ${PLANE_OIDC_SYNC_PROFILE:-1} + PLANE_NODEDC_ACCESS_ENFORCEMENT: ${PLANE_NODEDC_ACCESS_ENFORCEMENT:-0} + PLANE_NODEDC_ACCESS_CHECK_URL: ${PLANE_NODEDC_ACCESS_CHECK_URL:-} + PLANE_NODEDC_ACCESS_TOKEN: ${PLANE_NODEDC_ACCESS_TOKEN:-} + 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_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=/} GUNICORN_WORKERS: 1 POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} POSTHOG_HOST: ${POSTHOG_HOST:-} @@ -126,6 +134,7 @@ services: command: ./bin/docker-entrypoint-api.sh extra_hosts: - "auth.local.nodedc:host-gateway" + - "launcher.local.nodedc:host-gateway" deploy: replicas: ${API_REPLICAS:-1} restart_policy: @@ -144,6 +153,7 @@ services: command: ./bin/docker-entrypoint-worker.sh extra_hosts: - "auth.local.nodedc:host-gateway" + - "launcher.local.nodedc:host-gateway" deploy: replicas: ${WORKER_REPLICAS:-1} restart_policy: @@ -163,6 +173,7 @@ services: command: ./bin/docker-entrypoint-beat.sh extra_hosts: - "auth.local.nodedc:host-gateway" + - "launcher.local.nodedc:host-gateway" deploy: replicas: ${BEAT_WORKER_REPLICAS:-1} restart_policy: @@ -182,6 +193,7 @@ services: command: ./bin/docker-entrypoint-migrator.sh extra_hosts: - "auth.local.nodedc:host-gateway" + - "launcher.local.nodedc:host-gateway" deploy: replicas: 1 restart_policy: @@ -261,6 +273,7 @@ services: volumes: - proxy_config:/config - proxy_data:/data + - ../plane-src/apps/proxy/Caddyfile.ce:/etc/caddy/Caddyfile:ro depends_on: - web - api diff --git a/plane-app/plane.env b/plane-app/plane.env index 298f0dc..f63fcdc 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -101,3 +101,11 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback 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_NODEDC_ACCESS_ENFORCEMENT=1 +PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check +PLANE_NODEDC_ACCESS_TOKEN= +PLANE_NODEDC_ACCESS_SERVICE_SLUG=task-manager +PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3 +PLANE_NODEDC_ACCESS_CACHE_SECONDS=0 +PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/ +PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/ diff --git a/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py b/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py new file mode 100644 index 0000000..7858b84 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py @@ -0,0 +1,188 @@ +import os +import time + +import requests +from django.contrib.auth import logout +from django.http import HttpResponseRedirect, JsonResponse + +from plane.db.models import ExternalIdentityLink, Session + + +OIDC_PROVIDER = "authentik" + + +class NodeDCAccessMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self._enforce(request) + + if response is not None: + return response + + return self.get_response(request) + + def _enforce(self, request): + config = get_access_config() + + if not config["enabled"] or should_skip_path(request.path_info): + return None + + user = getattr(request, "user", None) + + if not user or not user.is_authenticated: + return None + + link = ExternalIdentityLink.objects.filter( + provider=OIDC_PROVIDER, + user=user, + status=ExternalIdentityLink.Status.ACTIVE, + ).first() + + if link is None: + return deny_unlinked_user(request, config) if config["enforce_unlinked"] else None + + cached = get_cached_access_decision(request, config["cache_seconds"]) + + if cached is not None: + return None if cached else revoke_session(request, user, "nodedc_access_revoked") + + try: + decision = check_launcher_access(config, link, user) + except (ValueError, requests.RequestException): + return service_unavailable(request) + + cache_access_decision(request, decision["allowed"], config["cache_seconds"]) + + if decision["groups"] is not None and decision["groups"] != link.groups: + link.groups = decision["groups"] + link.save(update_fields=["groups", "updated_at"]) + + if not decision["allowed"]: + return revoke_session(request, user, decision["reason"]) + + return None + + +def get_access_config(): + check_url = os.environ.get("PLANE_NODEDC_ACCESS_CHECK_URL", "").strip() + 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 { + "enabled": is_truthy(os.environ.get("PLANE_NODEDC_ACCESS_ENFORCEMENT", "0")) and bool(check_url and token), + "check_url": check_url, + "token": token, + "service_slug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager").strip() or "task-manager", + "timeout": float(os.environ.get("PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS", "3") or "3"), + "cache_seconds": max(0, int(os.environ.get("PLANE_NODEDC_ACCESS_CACHE_SECONDS", "0") or "0")), + "enforce_unlinked": is_truthy(os.environ.get("PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED", "0")), + } + + +def check_launcher_access(config, link, user): + response = requests.post( + config["check_url"], + json={ + "serviceSlug": config["service_slug"], + "subject": link.subject, + "email": link.email or user.email, + "userId": None, + }, + headers={ + "Authorization": f"Bearer {config['token']}", + "Accept": "application/json", + }, + timeout=config["timeout"], + ) + response.raise_for_status() + payload = response.json() + + return { + "allowed": bool(payload.get("allowed")), + "reason": payload.get("reason") or "nodedc_access_denied", + "groups": payload.get("groups") if isinstance(payload.get("groups"), list) else None, + } + + +def get_cached_access_decision(request, cache_seconds): + if cache_seconds <= 0: + return None + + checked_at = request.session.get("nodedc_access_checked_at") + allowed = request.session.get("nodedc_access_allowed") + + if not checked_at or allowed is None: + return None + + try: + checked_at_value = float(checked_at) + except (TypeError, ValueError): + return None + + if time.time() - checked_at_value > cache_seconds: + return None + + return bool(allowed) + + +def cache_access_decision(request, allowed, cache_seconds): + if cache_seconds <= 0: + request.session.pop("nodedc_access_checked_at", None) + request.session.pop("nodedc_access_allowed", None) + return + + request.session["nodedc_access_checked_at"] = time.time() + request.session["nodedc_access_allowed"] = bool(allowed) + + +def revoke_session(request, user, reason): + Session.objects.filter(user_id=str(user.id)).delete() + logout(request) + + if is_api_request(request): + status_code = 200 if request.path_info == "/api/users/session/" else 403 + payload = {"is_authenticated": False} if status_code == 200 else {} + payload.update({"error": "nodedc_access_revoked", "reason": reason}) + return JsonResponse(payload, status=status_code) + + return HttpResponseRedirect(os.environ.get("PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL", "http://launcher.local.nodedc/")) + + +def deny_unlinked_user(request, config): + if not config["enforce_unlinked"]: + return None + + return revoke_session(request, request.user, "nodedc_identity_not_linked") + + +def service_unavailable(request): + if is_api_request(request): + return JsonResponse({"error": "nodedc_access_check_unavailable"}, status=503) + + return JsonResponse({"error": "nodedc_access_check_unavailable"}, status=503) + + +def should_skip_path(path): + return path.startswith( + ( + "/auth/", + "/api/public/", + "/api/schema/", + "/static/", + "/assets/", + "/robots.txt", + ) + ) + + +def is_api_request(request): + return request.path_info.startswith("/api/") + + +def is_truthy(value): + return str(value).strip().lower() in {"1", "true", "yes", "on"} diff --git a/plane-src/apps/api/plane/authentication/views/app/signout.py b/plane-src/apps/api/plane/authentication/views/app/signout.py index 9941da3..3b6d277 100644 --- a/plane-src/apps/api/plane/authentication/views/app/signout.py +++ b/plane-src/apps/api/plane/authentication/views/app/signout.py @@ -4,25 +4,17 @@ # Django imports from django.views import View -from django.contrib.auth import logout from django.http import HttpResponseRedirect -from django.utils import timezone # Module imports -from plane.authentication.utils.host import user_ip, base_host -from plane.db.models import User +from plane.authentication.utils.host import base_host +from plane.authentication.views.nodedc_logout import get_logout_redirect_url, logout_current_user class SignOutAuthEndpoint(View): def post(self, request): - # Get user try: - user = User.objects.get(pk=request.user.id) - user.last_logout_ip = user_ip(request=request) - user.last_logout_time = timezone.now() - user.save() - # Log the user out - logout(request) - return HttpResponseRedirect(base_host(request=request, is_app=True)) + logout_current_user(request) + return HttpResponseRedirect(get_logout_redirect_url(base_host(request=request, is_app=True))) except Exception: - return HttpResponseRedirect(base_host(request=request, is_app=True)) + return HttpResponseRedirect(get_logout_redirect_url(base_host(request=request, is_app=True))) diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py new file mode 100644 index 0000000..c58b7e2 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py @@ -0,0 +1,44 @@ +import os + +from django.contrib.auth import logout +from django.http import HttpResponse, HttpResponseRedirect +from django.utils import timezone +from django.views import View + +from plane.authentication.utils.host import user_ip +from plane.db.models import User + + +def get_nodedc_global_logout_url(): + value = os.environ.get("PLANE_NODEDC_GLOBAL_LOGOUT_URL", "").strip() + return value or None + + +def get_logout_redirect_url(default_url): + return get_nodedc_global_logout_url() or default_url + + +def logout_current_user(request): + if request.user and request.user.is_authenticated: + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + except Exception: + pass + + logout(request) + + +class NodeDCFrontChannelLogoutEndpoint(View): + def get(self, request): + logout_current_user(request) + return HttpResponse( + "NODE.DC Task session closed.", + content_type="text/html", + ) + + def post(self, request): + logout_current_user(request) + return HttpResponseRedirect(get_logout_redirect_url("/")) diff --git a/plane-src/apps/api/plane/authentication/views/space/signout.py b/plane-src/apps/api/plane/authentication/views/space/signout.py index 164c640..43467bf 100644 --- a/plane-src/apps/api/plane/authentication/views/space/signout.py +++ b/plane-src/apps/api/plane/authentication/views/space/signout.py @@ -4,13 +4,11 @@ # Django imports from django.views import View -from django.contrib.auth import logout from django.http import HttpResponseRedirect -from django.utils import timezone # Module imports -from plane.authentication.utils.host import base_host, user_ip -from plane.db.models import User +from plane.authentication.utils.host import base_host +from plane.authentication.views.nodedc_logout import get_logout_redirect_url, logout_current_user from plane.utils.path_validator import get_safe_redirect_url @@ -18,16 +16,10 @@ class SignOutAuthSpaceEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") - # Get user try: - user = User.objects.get(pk=request.user.id) - user.last_logout_ip = user_ip(request=request) - user.last_logout_time = timezone.now() - user.save() - # Log the user out - logout(request) + logout_current_user(request) url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path) - return HttpResponseRedirect(url) + return HttpResponseRedirect(get_logout_redirect_url(url)) except Exception: url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path) - return HttpResponseRedirect(url) + return HttpResponseRedirect(get_logout_redirect_url(url)) diff --git a/plane-src/apps/api/plane/settings/common.py b/plane-src/apps/api/plane/settings/common.py index 22fcc98..fd53132 100644 --- a/plane-src/apps/api/plane/settings/common.py +++ b/plane-src/apps/api/plane/settings/common.py @@ -82,6 +82,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "plane.authentication.middleware.nodedc_access.NodeDCAccessMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index f5e4340..788957e 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -11,6 +11,7 @@ from drf_spectacular.views import ( SpectacularRedocView, SpectacularSwaggerView, ) +from plane.authentication.views.nodedc_logout import NodeDCFrontChannelLogoutEndpoint handler404 = "plane.app.views.error_404.custom_404_view" @@ -20,6 +21,7 @@ urlpatterns = [ path("api/instances/", include("plane.license.urls")), path("api/v1/", include("plane.api.urls")), path("auth/", include("plane.authentication.urls")), + path("logout", NodeDCFrontChannelLogoutEndpoint.as_view(), name="nodedc-frontchannel-logout"), path("", include("plane.web.urls")), ] diff --git a/plane-src/apps/proxy/Caddyfile.ce b/plane-src/apps/proxy/Caddyfile.ce index 903e395..1426f98 100644 --- a/plane-src/apps/proxy/Caddyfile.ce +++ b/plane-src/apps/proxy/Caddyfile.ce @@ -15,6 +15,8 @@ reverse_proxy /auth/* api:8000 + reverse_proxy /logout api:8000 + reverse_proxy /static/* api:8000 reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000 diff --git a/plane-src/apps/web/app/(home)/page.tsx b/plane-src/apps/web/app/(home)/page.tsx index 4717705..3ff9b23 100644 --- a/plane-src/apps/web/app/(home)/page.tsx +++ b/plane-src/apps/web/app/(home)/page.tsx @@ -7,8 +7,10 @@ import React from "react"; // components import { AuthBase } from "@/components/auth-screens/auth-base"; +import { NodeDCAuthRedirect } from "@/components/auth-screens/nodedc-auth-redirect"; // helpers import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +import { shouldUseNodeDCOIDC } from "@/helpers/nodedc-auth"; // layouts import DefaultLayout from "@/layouts/default-layout"; // wrappers @@ -18,7 +20,7 @@ function HomePage() { return ( - + {shouldUseNodeDCOIDC() ? : } ); diff --git a/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx b/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx new file mode 100644 index 0000000..2930651 --- /dev/null +++ b/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +import { buildNodeDCOIDCLoginUrl, buildNodeDCLauncherUrl, sanitizeNextPath } from "@/helpers/nodedc-auth"; + +export function NodeDCAuthRedirect() { + useEffect(() => { + const currentUrl = new URL(window.location.href); + const oidcError = currentUrl.searchParams.get("error"); + const nextPath = sanitizeNextPath(currentUrl.searchParams.get("next_path") || window.location.pathname); + + if (oidcError === "oidc_access_denied" || oidcError === "nodedc_access_revoked") { + window.location.replace(buildNodeDCLauncherUrl()); + return; + } + + window.location.replace(buildNodeDCOIDCLoginUrl(nextPath)); + }, []); + + return ( +
+
+
Переходим в NODE.DC
+
Проверяем платформенную сессию и доступ к рабочему пространству.
+
+
+ ); +} diff --git a/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx b/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx index d2f6c51..a8ae0ed 100644 --- a/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx +++ b/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx @@ -12,6 +12,7 @@ import useSWR from "swr"; import { LogoSpinner } from "@/components/common/logo-spinner"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; +import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC } from "@/helpers/nodedc-auth"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user"; @@ -82,6 +83,15 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro return redirectionRoute; }; + const redirectToPlatformLogin = () => { + if (shouldUseNodeDCOIDC()) { + window.location.replace(buildNodeDCOIDCLoginUrl(getCurrentRelativePath())); + return; + } + + router.push(`/${pathname ? `?next_path=${pathname}` : ``}`); + }; + if ((isUserSWRLoading || isUserLoading || workspacesLoader) && !currentUser?.id) return (
@@ -107,7 +117,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro if (pageType === EPageTypes.ONBOARDING) { if (!currentUser?.id) { - router.push(`/${pathname ? `?next_path=${pathname}` : ``}`); + redirectToPlatformLogin(); return <>; } else { if (currentUser && currentUserProfile?.id && isUserOnboard) { @@ -120,7 +130,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro if (pageType === EPageTypes.SET_PASSWORD) { if (!currentUser?.id) { - router.push(`/${pathname ? `?next_path=${pathname}` : ``}`); + redirectToPlatformLogin(); return <>; } else { if (currentUser && !currentUser?.is_password_autoset && currentUserProfile?.id && isUserOnboard) { @@ -139,7 +149,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro return <>; } } else { - router.push(`/${pathname ? `?next_path=${pathname}` : ``}`); + redirectToPlatformLogin(); return <>; } } diff --git a/plane-src/apps/web/core/services/api.service.ts b/plane-src/apps/web/core/services/api.service.ts index 4ef1a2a..69ca282 100644 --- a/plane-src/apps/web/core/services/api.service.ts +++ b/plane-src/apps/web/core/services/api.service.ts @@ -8,6 +8,8 @@ import type { AxiosInstance, AxiosRequestConfig } from "axios"; import axios from "axios"; +import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC } from "@/helpers/nodedc-auth"; + export abstract class APIService { protected baseURL: string; private axiosInstance: AxiosInstance; @@ -26,9 +28,17 @@ export abstract class APIService { this.axiosInstance.interceptors.response.use( (response) => response, (error) => { - if (error.response && error.response.status === 401) { + const status = error.response?.status; + const responseError = error.response?.data?.error; + + if (status === 401 || (status === 403 && responseError === "nodedc_access_revoked")) { const currentPath = window.location.pathname; - window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`); + + if (shouldUseNodeDCOIDC()) { + window.location.replace(buildNodeDCOIDCLoginUrl(getCurrentRelativePath())); + } else { + window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`); + } } return Promise.reject(error); } diff --git a/plane-src/apps/web/helpers/nodedc-auth.ts b/plane-src/apps/web/helpers/nodedc-auth.ts new file mode 100644 index 0000000..2943b42 --- /dev/null +++ b/plane-src/apps/web/helpers/nodedc-auth.ts @@ -0,0 +1,50 @@ +export function shouldUseNodeDCOIDC(): boolean { + const flag = process.env.VITE_NODEDC_OIDC_LOGIN_ENABLED; + + if (flag === "1" || flag === "true") { + return true; + } + + if (flag === "0" || flag === "false") { + return false; + } + + if (typeof window === "undefined") { + return false; + } + + const hostname = window.location.hostname.toLowerCase(); + return hostname.endsWith(".local.nodedc") || hostname.endsWith(".notdc.ru") || hostname.endsWith(".nodedc.ru"); +} + +export function buildNodeDCOIDCLoginUrl(nextPath?: string | null): string { + const configuredUrl = process.env.VITE_NODEDC_OIDC_LOGIN_URL || "/auth/oidc/login/"; + const url = new URL(configuredUrl, window.location.origin); + const safeNextPath = sanitizeNextPath(nextPath || getCurrentRelativePath()); + + if (safeNextPath) { + url.searchParams.set("next_path", safeNextPath); + } + + return url.toString(); +} + +export function buildNodeDCLauncherUrl(): string { + return process.env.VITE_NODEDC_LAUNCHER_URL || "http://launcher.local.nodedc/"; +} + +export function getCurrentRelativePath(): string { + if (typeof window === "undefined") { + return "/"; + } + + return `${window.location.pathname}${window.location.search}`; +} + +export function sanitizeNextPath(value?: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/"; + } + + return value; +} diff --git a/scripts/bootstrap_nodedc_platform_plan.py b/scripts/bootstrap_nodedc_platform_plan.py index 848918d..b8140d7 100644 --- a/scripts/bootstrap_nodedc_platform_plan.py +++ b/scripts/bootstrap_nodedc_platform_plan.py @@ -563,9 +563,11 @@ server/control-plane-store.mjs переведен на atomic write: запис Дополнение по итогам повторного теста: найден второй источник отката access matrix. Legacy frontend autosave сохранял весь LauncherData обратно в /api/storage/data после setData и мог перезаписать свежий backend-result старым состоянием из открытой вкладки. src/app/LauncherApp.tsx больше не вызывает persistLauncherData; authenticated runtime пишет control-plane только через admin/profile API mutations. /api/storage/data дополнительно закрыт requireLauncherAdmin и оставлен только как служебный dev endpoint. -Текущий live-sync выполнен вручную после исправления: dcctouch@gmail.com имеет nodedc:launcher:user, nodedc:superadmin, nodedc:launcher:admin, nodedc:taskmanager:admin, nodedc:taskmanager:user; silver_psih@yahoo.com имеет nodedc:launcher:user и service-digital-twin, без nodedc:taskmanager:user. +Текущий live-sync выполнен вручную после исправления: dcctouch@gmail.com имеет nodedc:launcher:user, nodedc:superadmin, nodedc:launcher:admin, nodedc:taskmanager:admin, nodedc:taskmanager:user; silver_psih@yahoo.com получает nodedc:launcher:user, nodedc:taskmanager:user и service-digital-twin согласно текущей access matrix. -Ограничение безопасности остается отдельным обязательным будущим этапом: уже открытая downstream-сессия Plane может жить до logout/session expiry, даже если Launcher уже заблокировал кнопку и Authentik projection сняла группу. Для жесткого realtime revoke нужен отдельный session-revocation/app-token слой в Plane или gateway. +Дополнение 2026-05-04: Launcher BFF получил внутренний server-to-server endpoint POST /api/internal/access/check. Endpoint защищен bearer token, не доступен через пользовательскую session-модель и возвращает актуальный allow/deny по Launcher control-plane для конкретного serviceSlug, subject/email/userId. + +Ограничение по уже открытой downstream-сессии вынесено в Plane live enforcement этап. Launcher остается source-of-truth, а downstream-приложения должны либо дергать внутренний access check, либо использовать общий будущий NODE.DC auth SDK/gateway. Ограничение: email/password/profile update уже работает как dev-flow, но production UX для reset-password/invite-email и подтверждения смены email еще нужно вынести в отдельный полноценный этап. """, @@ -760,24 +762,80 @@ plane-app/plane.env настроен для local runtime: WEB_URL=http://task.l Launcher BFF нормализует picture/avatar_url/avatar в avatarUrl и отдает его через /api/me. Frontend прокидывает avatarUrl в runtime user и показывает изображение в top bar/profile menu, если claim присутствует. Plane OIDC callback получил PLANE_OIDC_SYNC_PROFILE=1 по умолчанию. При успешном OIDC login Plane обновляет display_name, first_name, last_name и avatar из claims. Если OIDC projection не отдает picture/avatar_url, существующий Plane avatar не очищается. Runtime проверен: dcctouch@gmail.com теперь display_name DC Touch, first_name DC, last_name Touch, avatar_url сохранен. Launcher готов отобразить avatarUrl, но фактический единый avatar должен появиться из будущего Launcher profile storage/avatar upload, а не из ручной настройки Authentik. +""", + ), + text_block( + "plane", + "Этап 5.5. Live access enforcement", + """ +Статус: частично реализовано. + +Plane должен не только пускать пользователя при OIDC login, но и регулярно проверять актуальный доступ в Launcher control-plane. Если админ снимает доступ к OPERATIONAL CORE, уже открытая Plane session должна быть отозвана на следующем API/request без ожидания logout/session expiry. + +Интеграция должна быть отключаемой env-флагами, чтобы Plane можно было развернуть standalone без Launcher/Auth projection. +""", + ), + checker( + "plane55", + "Чекер этапа 5.5. Live access enforcement", + [ + {"text": "Добавить внутренний Launcher access-check endpoint.", "checked": True}, + {"text": "Защитить endpoint server-side bearer token.", "checked": True}, + {"text": "Добавить Plane middleware после AuthenticationMiddleware.", "checked": True}, + {"text": "Проверять Launcher access по Authentik subject/email.", "checked": True}, + {"text": "Удалять Plane sessions при denied access.", "checked": True}, + {"text": "Сделать enforcement отключаемым env-флагом.", "checked": True}, + {"text": "Убрать legacy Plane email/password экран из NODE.DC direct flow.", "checked": True}, + {"text": "Редиректить page denied/revoked в Launcher вместо старого login UI.", "checked": True}, + {"text": "Редиректить 401/403 frontend requests в NODE.DC OIDC handoff.", "checked": True}, + {"text": "Сделать Task Manager sign-out сквозным NODE.DC logout.", "checked": True}, + {"text": "Добавить Task Manager front-channel logout endpoint /logout.", "checked": True}, + {"text": "Закрывать app sessions из Launcher global logout перед IdP logout.", "checked": True}, + {"text": "Проверить Plane API -> Launcher check из контейнера.", "checked": True}, + "Провести ручной browser acceptance: снять доступ и увидеть отзыв уже открытой Plane-сессии.", + ], + ), + text_block( + "plane", + "Реализация этапа 5.5", + """ +Launcher repo: server/dev-server.mjs получил POST /api/internal/access/check. Endpoint принимает serviceSlug, subject/email/userId, находит live Launcher user, считает groups через resolveRequiredGroups и возвращает allowed/matchedGroups/user без раскрытия frontend secrets. Token берется из server-side env: NODEDC_INTERNAL_ACCESS_TOKEN / NODEDC_PLATFORM_SERVICE_TOKEN / fallback PLANE_OIDC_CLIENT_SECRET. + +Plane repo: добавлен plane-src/apps/api/plane/authentication/middleware/nodedc_access.py и подключение в plane-src/apps/api/plane/settings/common.py сразу после django.contrib.auth.middleware.AuthenticationMiddleware. Middleware активируется только при PLANE_NODEDC_ACCESS_ENFORCEMENT=1 и наличии PLANE_NODEDC_ACCESS_CHECK_URL + token. Standalone Plane без этих env не меняет поведение. + +Если Launcher возвращает denied, middleware удаляет sessions текущего Plane user через модель Session, вызывает logout(request) и возвращает 403 JSON для API или redirect на PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL для page request. В local runtime denied page redirect ведет в Launcher, а не в legacy Plane login. При временной недоступности Launcher check middleware возвращает 503, но не удаляет session, чтобы не делать destructive logout из-за сетевого сбоя. + +plane-app/docker-compose.yaml и plane-app/plane.env получили PLANE_NODEDC_ACCESS_* env, PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL и extra_hosts для launcher.local.nodedc. В local runtime API container пересоздан с новым env; patched middleware/settings скопированы в /code/plane и API container перезапущен. + +Plane web получил NODE.DC handoff для прямых заходов на task.local.nodedc: app/(home)/page.tsx показывает NodeDCAuthRedirect вместо AuthBase на NODE.DC-доменах; AuthenticationWrapper больше не возвращает unauthenticated users на /, а отправляет в /auth/oidc/login с сохранением next_path; api.service.ts при 401 и nodedc_access_revoked также уводит в OIDC handoff. Старый email/password экран Plane сохранен как standalone/fallback для не-NODE.DC доменов, но не должен появляться в нормальном local.notdc/task.local.nodedc сценарии. + +Дополнение по logout semantics: Launcher /api/me теперь отдает global logout URL /auth/logout?global=1&returnTo=/. Launcher /auth/logout?global=1 сначала закрывает app sessions через front-channel logout URLs, затем уводит браузер в Authentik end-session. Для Task Manager добавлен GET/POST /logout, который чистит Plane session и возвращает короткий технический ответ для front-channel. Стандартный Plane POST /auth/sign-out/ теперь после локального logout редиректит в PLANE_NODEDC_GLOBAL_LOGOUT_URL, поэтому кнопка выхода внутри Task Manager больше не должна мгновенно заводить пользователя обратно через активную SSO session. + +plane-src/apps/proxy/Caddyfile.ce маршрутизирует /logout в API, а plane-app/docker-compose.yaml монтирует этот Caddyfile в local proxy runtime, чтобы route не терялся при restart контейнера. В plane-app/plane.env добавлен PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/. + +Проверки 2026-05-04: node --check server/dev-server.mjs проходит; python3 -m py_compile nodedc_access.py/settings.common проходит; docker compose --env-file plane.env config проходит; http://127.0.0.1:5173/healthz и launcher.local.nodedc/healthz возвращают internalAccessApiConfigured=true; task.local.nodedc/auth/oidc/login/ возвращает 302 на Authentik; из plane-app-api-1 POST на http://launcher.local.nodedc/api/internal/access/check для silver_psih@yahoo.com возвращает allowed=True и matchedGroups=['nodedc:taskmanager:user'], для missing-user возвращает allowed=False. Web image nodedc/plane-frontend:ru пересобран, plane-app-web-1 пересоздан, собранный home asset содержит NodeDCAuthRedirect и строку «Переходим в NODE.DC». + +Ограничение: полный browser acceptance именно на уже открытой вкладке после снятия доступа еще должен подтвердить пользователь. Для durable production deploy нужно пересобрать nodedc/plane-backend:local, потому что текущий локальный runtime применен через docker cp поверх контейнера. """, ), text_block( "plane", "Этап 6. Standalone compatibility и Plane API adapter", """ -Статус: backlog. +Статус: частично реализовано. Plane должен оставаться самостоятельным продуктом, который можно развернуть клиенту без остального NODE.DC stack. NODE.DC-интеграция должна быть включаемым слоем: OIDC provider, external identity link, app access projection и будущий adapter через Plane API/API tokens. Запрещено строить интеграцию через прямое владение Plane DB со стороны Launcher. Launcher хранит платформенную привязку client/user/access, но Plane продолжает владеть workspace/project/task/comment и собственными ролями внутри приложения. + +Частично закрыто в live enforcement: NODE.DC access middleware включается только через env PLANE_NODEDC_ACCESS_ENFORCEMENT и не активируется в standalone-профиле без Launcher access-check URL/token. """, ), checker( "plane6", "Чекер этапа 6. Standalone compatibility и Plane API adapter", [ - "Зафиксировать env-флаг включения NODE.DC SSO слоя.", + {"text": "Зафиксировать env-флаг включения NODE.DC SSO слоя.", "checked": True}, "Описать standalone-профиль Plane без Launcher/Auth projection.", "Проверить сохранение Plane API-token механизма.", "Спроектировать Launcher -> Plane API adapter.",