Выходим из NODE.DC
+Закрываем сессии подключённых приложений и платформенный вход.
+diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index ba8013f..07d5cc3 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -42,7 +42,7 @@ "avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg", "globalStatus": "active", "createdAt": "2026-05-04T00:00:00.000Z", - "updatedAt": "2026-05-04T14:15:10.555Z" + "updatedAt": "2026-05-04T15:26:08.500Z" } ], "memberships": [ @@ -275,8 +275,8 @@ "targetId": "user_silver_psih", "appRole": "member", "status": "active", - "createdAt": "2026-05-04T14:15:10.296Z", - "updatedAt": "2026-05-04T14:15:10.296Z" + "createdAt": "2026-05-04T15:26:07.830Z", + "updatedAt": "2026-05-04T15:26:07.830Z" } ], "exceptions": [], @@ -311,9 +311,9 @@ "objectType": "user", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-04T14:15:10.555Z", + "lastSyncAt": "2026-05-04T15:26:08.500Z", "error": null, - "updatedAt": "2026-05-04T14:15:10.555Z" + "updatedAt": "2026-05-04T15:26:08.500Z" }, { "id": "sync_dctouch_groups_authentik", @@ -357,7 +357,7 @@ "state": "pending", "lastSyncAt": null, "error": null, - "updatedAt": "2026-05-04T14:15:10.297Z" + "updatedAt": "2026-05-04T15:26:07.830Z" } ], "auditEvents": [ @@ -1044,6 +1044,102 @@ "clientId": null, "result": "success", "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_15", + "at": "2026-05-04T14:50:51.260Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_39", + "at": "2026-05-04T14:50:51.574Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_16", + "at": "2026-05-04T14:51:46.812Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_40", + "at": "2026-05-04T14:51:47.092Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_17", + "at": "2026-05-04T15:25:51.639Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_41", + "at": "2026-05-04T15:25:51.952Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_18", + "at": "2026-05-04T15:26:07.830Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_42", + "at": "2026-05-04T15:26:08.500Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" } ] } diff --git a/server/dev-server.mjs b/server/dev-server.mjs index c507505..119c1ec 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -1,6 +1,6 @@ import express from "express"; import { createServer as createHttpServer } from "node:http"; -import { randomBytes, randomUUID, createHash } from "node:crypto"; +import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, extname, join, resolve } from "node:path"; @@ -44,6 +44,7 @@ app.get("/healthz", (_req, res) => { service: "nodedc-launcher-bff", oidcConfigured: config.oidcConfigured, authentikApiConfigured: authentikSyncClient.isConfigured(), + internalAccessApiConfigured: Boolean(config.internalAccessToken), }); }); @@ -152,7 +153,7 @@ app.get("/auth/logout", asyncRoute(async (req, res) => { const endSessionEndpoint = discovery.end_session_endpoint; if (!endSessionEndpoint) { - res.redirect(returnTo); + res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), new URL(returnTo, config.appBaseUrl).toString())); return; } @@ -164,7 +165,7 @@ app.get("/auth/logout", asyncRoute(async (req, res) => { logoutUrl.searchParams.set("id_token_hint", session.tokenSet.idToken); } - res.redirect(logoutUrl.toString()); + res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), logoutUrl.toString())); })); app.get("/api/me", (req, res) => { @@ -182,7 +183,7 @@ app.get("/api/me", (req, res) => { user: runtimeContext.user, groups: runtimeContext.groups, isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"), - logoutUrl: "/auth/logout", + logoutUrl: "/auth/logout?global=1&returnTo=/", }); }); @@ -232,6 +233,54 @@ app.get("/api/events", requireSession, (req, res) => { }); }); +app.post("/api/internal/access/check", (req, res) => { + if (!isInternalRequestAuthorized(req)) { + res.status(config.internalAccessToken ? 401 : 503).json({ + ok: false, + error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured", + }); + return; + } + + const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC internal access check" }); + const user = findInternalAccessUser(snapshot.data, req.body); + const serviceSlug = sanitizeServiceSlug(req.body?.serviceSlug); + + if (!user) { + res.json({ + ok: true, + allowed: false, + reason: "user_not_found", + serviceSlug, + groups: [], + matchedGroups: [], + user: null, + }); + return; + } + + const groups = resolveRequiredGroups(snapshot.data, user); + const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug); + const allowed = Boolean(app?.hasAccess); + + res.json({ + ok: true, + allowed, + reason: allowed ? "access_confirmed" : "access_denied", + serviceSlug, + groups, + matchedGroups: app?.matchedGroups ?? [], + user: { + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl ?? null, + authentikUserId: user.authentikUserId ?? null, + globalStatus: user.globalStatus, + }, + }); +}); + app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => { const { actor } = getLauncherProfileContext(req.nodedcSession); const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user); @@ -506,6 +555,14 @@ function readConfig() { process.env.AUTHENTIK_SERVICE_TOKEN ?? process.env.AUTHENTIK_BOOTSTRAP_TOKEN ?? "", + internalAccessToken: + process.env.NODEDC_INTERNAL_ACCESS_TOKEN ?? + process.env.NODEDC_PLATFORM_SERVICE_TOKEN ?? + process.env.PLANE_OIDC_CLIENT_SECRET ?? + "", + taskLogoutUrl: + process.env.TASK_LOGOUT_URL ?? + `${(process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`).replace(/\/$/, "")}/logout`, }; } @@ -831,6 +888,68 @@ function getServiceUrl(service) { return service.launchUrl || service.url || "#"; } +function getFrontchannelLogoutUrls() { + const urls = [config.taskLogoutUrl]; + const launcherData = readLauncherData(); + const services = Array.isArray(launcherData?.services) ? launcherData.services : []; + + for (const service of services) { + if (typeof service.logoutUrl === "string" && service.logoutUrl.trim()) { + urls.push(service.logoutUrl.trim()); + } + } + + return [...new Set(urls.map(normalizeLogoutUrl).filter(Boolean))]; +} + +function normalizeLogoutUrl(value) { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.toString(); + } catch { + return null; + } +} + +function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) { + const logoutUrlsJson = JSON.stringify(frontchannelLogoutUrls); + const redirectUrlJson = JSON.stringify(finalRedirectUrl); + + return ` + +
+ + +Закрываем сессии подключённых приложений и платформенный вход.
+