Compare commits

...

2 Commits

16 changed files with 464 additions and 43 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ plane-src/apps/web/.react-router/
plane-src/apps/admin/.react-router/
plane-src/apps/space/.react-router/
plane-app/archive/
plane-app/backup/

View File

@ -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

View File

@ -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=/

View File

@ -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"}

View File

@ -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)))

View File

@ -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(
"<!doctype html><html><head><meta charset='utf-8'></head><body>NODE.DC Task session closed.</body></html>",
content_type="text/html",
)
def post(self, request):
logout_current_user(request)
return HttpResponseRedirect(get_logout_redirect_url("/"))

View File

@ -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))

View File

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

View File

@ -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")),
]

View File

@ -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

View File

@ -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 (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<AuthBase authType={EAuthModes.SIGN_IN} />
{shouldUseNodeDCOIDC() ? <NodeDCAuthRedirect /> : <AuthBase authType={EAuthModes.SIGN_IN} />}
</AuthenticationWrapper>
</DefaultLayout>
);

View File

@ -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 (
<div className="relative z-10 flex h-screen w-screen flex-col items-center justify-center overflow-hidden bg-canvas px-8 py-12">
<div className="nodedc-auth-shell flex w-full max-w-[28rem] flex-col gap-4 text-center">
<div className="text-2xl font-semibold text-custom-text-100">Переходим в NODE.DC</div>
<div className="text-sm text-custom-text-300">Проверяем платформенную сессию и доступ к рабочему пространству.</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="relative flex h-screen w-full items-center justify-center">
@ -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 <></>;
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -219,6 +219,8 @@ Runtime launch закрыт через локальный Caddy-based image node
{"text": "Настроить app access policies по группам.", "checked": True},
{"text": "Описать bootstrap/export или blueprint strategy.", "checked": True},
{"text": "Проверить login/logout в Authentik за proxy.", "checked": True},
{"text": "Добавить безопасный NODE.DC branded login через Authentik Brand/CSS.", "checked": True},
{"text": "Зафиксировать запрет HTML-proxy/password facade в security RFC.", "checked": True},
],
),
text_block(
@ -264,6 +266,18 @@ Plane live WebSocket upgrade через proxy проверен: запрос с
Application access задан через group bindings: Launcher доступен nodedc:superadmin, nodedc:launcher:admin, nodedc:launcher:user; Task Manager доступен nodedc:superadmin, nodedc:taskmanager:admin, nodedc:taskmanager:user. OIDC tokens получают стандартные openid/email/profile/offline_access scopes и custom groups scope.
2026-05-04 добавлен безопасный branded login prototype без proxy над Authentik. В platform/docs/AUTH_BRANDED_LOGIN_RFC.md зафиксирована threat model: запрещены HTML-rewrite proxy, password form в Launcher/BFF, ROPC/password grant и обход MFA/recovery/audit. Разрешенный путь Authentik-native Brand/CSS/Flow customization.
platform/infra/authentik/bootstrap-dev.py теперь idempotent настраивает Brand для auth.local.nodedc: title NODE.DC, default authentication flow default-authentication-flow, flow title "Работайте во всех измерениях.", layout stacked и branding_custom_css из /templates/branding/nodedc-login.css. CSS лежит в platform/infra/authentik/custom-templates/branding/nodedc-login.css и монтируется в Authentik существующим volume /templates.
Runtime bootstrap выполнен: curl страницы auth.local.nodedc подтверждает title NODE.DC, matched_domain auth.local.nodedc и наличие brand-css со строкой "Работайте во всех измерениях". Authentik shell подтверждает default Brand auth.local.nodedc, branding_title NODE.DC и flow title "Работайте во всех измерениях.". Password/MFA/recovery mechanics не тронуты.
Дополнение 2026-05-04: экран Authentik "My applications" признан не login flow, а пользовательским dashboard самого identity provider. Для обычного платформенного UX он исключен на proxy-уровне: auth.local.nodedc/ и auth.local.nodedc/if/user* возвращают 302 на launcher.local.nodedc. OIDC discovery/authorize/callback и /if/admin/ не редиректятся, чтобы не ломать SSO и служебную админку Authentik.
Дополнение 2026-05-04: экран "You've logged out of NODE.DC Launcher" признан Authentik provider invalidation dashboard. OAuth2 providers переключены на штатный default-invalidation-flow с UserLogoutStage, чтобы global logout закрывал Authentik session и возвращал пользователя в NODE.DC route без показа Authentik application logout UI.
Дополнение 2026-05-04: authentication flow приближен к старому Plane login в рамках Authentik-native customization. Brand attributes выставлены в settings.locale=ru и settings.theme.base=dark. Flow title изменен на "Работайте во всех измерениях.". IdentificationStage теперь использует только email как идентификатор и привязан к штатному PasswordStage, поэтому email/password находятся в одном Authentik challenge без передачи пароля в Launcher. Отдельный PasswordStage binding удален из flow, MFA и UserLoginStage остаются штатными.
Изменения platform repo закоммичены и отправлены в origin/main: 4a10726, "АРХ - NODEDC PLATFORM: bootstrap Authentik applications". Remote main обновлен с afa53d5 до 4a10726.
""",
),
@ -371,22 +385,29 @@ Production flow: пользователь открывает nodedc.ru, види
"launcher",
"Этап 3.5. NODE.DC login facade",
"""
Статус: backlog.
Статус: частично реализовано.
Ручная проверка 2026-05-04 подтвердила: при входе пользователь пока видит стандартное окно Authentik. Это допустимо только для текущего dev/OIDC bootstrap, но не соответствует целевой UX-модели NODE.DC.
Ручная проверка 2026-05-04 подтвердила: при входе пользователь видел стандартное окно Authentik. Это допустимо только для dev/OIDC bootstrap, но не соответствует целевой UX-модели NODE.DC.
Нужно сделать production login без публичного Authentik UI: либо полностью кастомизированный Authentik flow, если он реально выдержит дизайн-канон NODE.DC, либо Launcher/BFF login facade, который server-side работает с внутренним Authentik flow/API и не раскрывает IdP пользователю.
Выбран безопасный первый путь: полностью кастомизированный Authentik-native Brand/CSS/Flow, а не proxy и не password facade в Launcher. Это сохраняет Authentik как password/session/MFA/recovery/audit authority.
Безопасностная граница: не делать frontend-only password form, не хранить IdP/service secrets в browser bundle, не ломать MFA/recovery/rate-limit/audit и не обходить Authentik как password/session authority.
Безопасностная граница: не делать frontend-only password form, не хранить IdP/service secrets в browser bundle, не ломать MFA/recovery/rate-limit/audit и не обходить Authentik как password/session authority. Если Brand/CSS не даст pixel-level дизайн Plane login, следующий допустимый уровень template override внутри Authentik deployment с отдельным security review, но не HTML-rewrite proxy.
""",
),
checker(
"launcher35",
"Чекер этапа 3.5. NODE.DC login facade",
[
"Выбрать безопасную стратегию: branded Authentik flow или Launcher/BFF facade.",
"Сверстать NODE.DC login по канону Task Manager/Launcher.",
"Скрыть Authentik brand во всех штатных login/logout/error текстах.",
{"text": "Выбрать безопасную стратегию: branded Authentik flow или Launcher/BFF facade.", "checked": True},
{"text": "Запретить reverse proxy HTML rewrite и password form в Launcher/BFF.", "checked": True},
{"text": "Добавить AUTH_BRANDED_LOGIN_RFC.md с threat model.", "checked": True},
{"text": "Настроить Authentik Brand/CSS для auth.local.nodedc.", "checked": True},
{"text": "Сверстать NODE.DC login по канону Task Manager/Launcher в пределах Brand/CSS.", "checked": True},
{"text": "Объединить email/password в одном Authentik challenge через IdentificationStage.password_stage.", "checked": True},
{"text": "Включить ru locale и dark theme на уровне Authentik Brand settings.", "checked": True},
{"text": "Скрыть Authentik brand в базовом authentication page title/brand.", "checked": True},
{"text": "Исключить Authentik My applications dashboard из пользовательского маршрута.", "checked": True},
{"text": "Исключить Authentik application logout dashboard из пользовательского маршрута.", "checked": True},
"Поддержать returnTo для прямых ссылок на приложения.",
"Поддержать forced login для диагностики.",
"Спроектировать recovery/enrollment/MFA без раскрытия Authentik UI.",
@ -563,9 +584,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 +783,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.",