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.",