ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane live access и logout handoff
This commit is contained in:
parent
55318f14e5
commit
3b13e5be52
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=/
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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("/"))
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 <></>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue