SECURITY - LAUNCHER: harden storage and access lifecycle

This commit is contained in:
DCCONSTRUCTIONS 2026-05-12 12:50:52 +03:00
parent 2b34cf9f1b
commit b3915a851c
12 changed files with 459 additions and 4326 deletions

View File

@ -6,3 +6,4 @@ NODEDC_PLATFORM_ENV=../../NODEDC/platform/infra/.env
PORT=5173 PORT=5173
LAUNCHER_BASE_URL=http://launcher.local.nodedc LAUNCHER_BASE_URL=http://launcher.local.nodedc
TASK_BASE_URL=http://task.local.nodedc TASK_BASE_URL=http://task.local.nodedc
NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-platform-init-dev-env

5
.gitignore vendored
View File

@ -9,4 +9,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
*.tsbuildinfo *.tsbuildinfo
server/storage/*
!server/storage/.gitkeep
public/storage/launcher-data.json
dist/storage/launcher-data.json
public/storage/uploads/
public/storage/backups/ public/storage/backups/

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,15 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const publicDataPath = join(projectRoot, "public", "storage", "launcher-data.json"); const serverStorageRoot = resolveStorageRoot(projectRoot);
const distDataPath = join(projectRoot, "dist", "storage", "launcher-data.json"); const dataPath = join(serverStorageRoot, "launcher-data.json");
const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
const now = new Date().toISOString(); const now = new Date().toISOString();
const existingData = readJson(publicDataPath); const existingData = existsSync(dataPath) ? readJson(dataPath) : readJson(legacyPublicDataPath);
const services = Array.isArray(existingData.services) ? existingData.services : []; const services = Array.isArray(existingData.services) ? existingData.services : [];
const existingUsersByEmail = new Map( const existingUsersByEmail = new Map(
(Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user]) (Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user])
@ -214,11 +215,7 @@ const liveData = {
services, services,
}; };
await writeJson(publicDataPath, liveData); await writeJson(dataPath, liveData);
if (existsSync(join(projectRoot, "dist"))) {
await writeJson(distDataPath, liveData);
}
console.log(`Seeded ${liveData.users.length} users, ${liveData.clients.length} client, ${liveData.groups.length} groups.`); console.log(`Seeded ${liveData.users.length} users, ${liveData.clients.length} client, ${liveData.groups.length} groups.`);
@ -234,3 +231,13 @@ async function writeJson(path, data) {
await mkdir(dirname(path), { recursive: true }); await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8"); await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
} }
function resolveStorageRoot(projectRoot) {
const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR;
if (configuredRoot && configuredRoot.trim()) {
return isAbsolute(configuredRoot) ? configuredRoot : resolve(projectRoot, configuredRoot);
}
return join(projectRoot, "server", "storage");
}

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { mkdir, rename, writeFile } from "node:fs/promises"; import { mkdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { isAbsolute, join, resolve } from "node:path";
const collectionKeys = [ const collectionKeys = [
"clients", "clients",
@ -62,16 +62,18 @@ const defaultSettings = {
}; };
export function createControlPlaneStore({ projectRoot }) { export function createControlPlaneStore({ projectRoot }) {
const publicStorageRoot = join(projectRoot, "public", "storage"); const serverStorageRoot = resolveStorageRoot(projectRoot);
const distStorageRoot = join(projectRoot, "dist", "storage"); const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
const dataPath = join(publicStorageRoot, "launcher-data.json"); const dataPath = join(serverStorageRoot, "launcher-data.json");
function readData() { function readData() {
if (!existsSync(dataPath)) { const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath;
if (!existsSync(readablePath)) {
return normalizeData({}); return normalizeData({});
} }
return normalizeData(JSON.parse(readFileSync(dataPath, "utf8"))); return normalizeData(JSON.parse(readFileSync(readablePath, "utf8")));
} }
async function writeData(data) { async function writeData(data) {
@ -1192,6 +1194,10 @@ export function createControlPlaneStore({ projectRoot }) {
throw new Error("Этот инвайт выписан на другую почту"); throw new Error("Этот инвайт выписан на другую почту");
} }
if (invite.status === "accepted") {
throw new Error("Инвайт уже принят");
}
if (invite.status === "revoked") { if (invite.status === "revoked") {
throw new Error("Инвайт отозван"); throw new Error("Инвайт отозван");
} }
@ -1711,13 +1717,7 @@ export function createControlPlaneStore({ projectRoot }) {
} }
function getWritableStorageRoots() { function getWritableStorageRoots() {
const roots = [publicStorageRoot]; return [serverStorageRoot];
if (existsSync(join(projectRoot, "dist"))) {
roots.push(distStorageRoot);
}
return roots;
} }
function getLoginAccountStatus(email) { function getLoginAccountStatus(email) {
@ -1799,6 +1799,16 @@ export function createControlPlaneStore({ projectRoot }) {
}; };
} }
function resolveStorageRoot(projectRoot) {
const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR;
if (configuredRoot && configuredRoot.trim()) {
return isAbsolute(configuredRoot) ? configuredRoot : resolve(projectRoot, configuredRoot);
}
return join(projectRoot, "server", "storage");
}
function normalizeData(payload) { function normalizeData(payload) {
const data = typeof payload === "object" && payload !== null ? { ...payload } : {}; const data = typeof payload === "object" && payload !== null ? { ...payload } : {};

View File

@ -249,7 +249,14 @@ app.get("/api/me", (req, res) => {
return; return;
} }
const runtimeContext = getRuntimeSessionContext(session); const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
const runtimeContext = sessionAccess.runtimeContext;
res.json({ res.json({
authenticated: true, authenticated: true,
@ -268,7 +275,14 @@ app.get("/api/apps", (req, res) => {
return; return;
} }
res.json({ apps: getAppsForSession(session) }); const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
res.json({ apps: getAppsForUser(sessionAccess.runtimeContext.groups) });
}); });
app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => { app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => {
@ -456,9 +470,20 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
const groups = resolveRequiredGroups(snapshot.data, inviter); const groups = resolveRequiredGroups(snapshot.data, inviter);
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager"); const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager");
const workspacePolicy = resolveTaskManagerWorkspacePolicy(snapshot.data, groups, Boolean(app?.hasAccess), inviter); const workspaceSlug = req.body?.workspace?.slug ?? req.body?.workspaceSlug;
const workspacePolicy = resolveTaskManagerWorkspacePolicy(
snapshot.data,
groups,
Boolean(app?.hasAccess),
inviter,
workspaceSlug
);
if (!app?.hasAccess || workspacePolicy.inviteApproval !== "nodedc") { if (
!app?.hasAccess ||
workspacePolicy.managedBy !== "tasker" ||
!["nodedc", "launcher"].includes(workspacePolicy.inviteApproval)
) {
res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy }); res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy });
return; return;
} }
@ -518,9 +543,10 @@ app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
userId: actor.id, userId: actor.id,
}); });
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user); const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
const taskManagerProfile = await syncTaskManagerUserProfile(storeResult.user);
publishControlPlaneEvent("profile.updated", [actor.id]); publishControlPlaneEvent("profile.updated", [actor.id]);
res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser) }); res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser), taskManagerProfile });
})); }));
app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => { app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => {
@ -629,7 +655,15 @@ app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => {
return; return;
} }
const runtimeContext = getRuntimeSessionContext(session); const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
expireSession(res, session);
res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true }));
return;
}
const runtimeContext = sessionAccess.runtimeContext;
const request = controlPlaneStore const request = controlPlaneStore
.getSnapshot({ name: "NODE.DC tasker invite redirect" }) .getSnapshot({ name: "NODE.DC tasker invite redirect" })
.data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId); .data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId);
@ -660,6 +694,25 @@ app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(scopeAdminSnapshot(req)); res.json(scopeAdminSnapshot(req));
}); });
app.get("/api/storage/data", requireSession, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
if (snapshot.actor.source !== "launcher") {
res.status(403).json({ error: "Профиль пользователя не найден в Launcher control-plane" });
return;
}
const runtimeContext = getRuntimeSessionContext(req.nodedcSession);
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
if (adminScope.isRoot || adminScope.clientIds.size > 0) {
res.json(scopeControlPlaneData(snapshot.data, adminScope));
return;
}
res.json(scopeRuntimeControlPlaneData(snapshot.data, snapshot.actor.id));
});
app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user); const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.settings.updated"); publishControlPlaneEvent("admin.settings.updated");
@ -1019,8 +1072,10 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user); const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId);
const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser);
publishControlPlaneEvent("admin.user.updated", syncResult.userIds); publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); res.json({ ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile });
})); }));
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
@ -1038,9 +1093,10 @@ app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncher
authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId }); authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId });
} }
const taskManagerCleanup = await cleanupTaskManagerUserAccess(user);
const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user); const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user);
publishControlPlaneEvent("admin.user.deleted", [req.params.userId]); publishControlPlaneEvent("admin.user.deleted", [req.params.userId]);
res.json({ ...scopeAdminMutationResult(req, result), authentik }); res.json({ ...scopeAdminMutationResult(req, result), authentik, taskManagerCleanup });
})); }));
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => { app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
@ -1371,7 +1427,7 @@ app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLaunc
res.json(controlPlaneStore.buildAuthentikSyncPlan()); res.json(controlPlaneStore.buildAuthentikSyncPlan());
}); });
app.post("/api/storage/upload", asyncRoute(async (req, res) => { app.post("/api/storage/upload", requireSession, asyncRoute(async (req, res) => {
const result = await saveUploadedFile(req.body); const result = await saveUploadedFile(req.body);
res.json(result); res.json(result);
})); }));
@ -1379,9 +1435,14 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => {
app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
await saveLauncherData(req.body); await saveLauncherData(req.body);
publishControlPlaneEvent("storage.data.updated"); publishControlPlaneEvent("storage.data.updated");
res.json({ ok: true, url: "/storage/launcher-data.json" }); res.json({ ok: true });
})); }));
app.get("/storage/launcher-data.json", (_req, res) => {
setNoStore(res);
res.status(404).json({ error: "not_found" });
});
const vite = await createViteServer({ const vite = await createViteServer({
root: projectRoot, root: projectRoot,
appType: "spa", appType: "spa",
@ -1433,7 +1494,6 @@ function readConfig() {
internalAccessToken: internalAccessToken:
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ?? process.env.NODEDC_INTERNAL_ACCESS_TOKEN ??
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ?? process.env.NODEDC_PLATFORM_SERVICE_TOKEN ??
process.env.PLANE_OIDC_CLIENT_SECRET ??
"", "",
taskLogoutUrl: taskLogoutUrl:
process.env.TASK_LOGOUT_URL ?? process.env.TASK_LOGOUT_URL ??
@ -1771,7 +1831,15 @@ function getRuntimeSessionContext(session) {
return fallback; return fallback;
} }
const groups = resolveRequiredGroups(snapshot.data, user); return buildRuntimeSessionContext(session, snapshot.data, user);
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
return fallback;
}
}
function buildRuntimeSessionContext(session, data, user) {
const groups = resolveRequiredGroups(data, user);
return { return {
groups, groups,
@ -1783,14 +1851,58 @@ function getRuntimeSessionContext(session) {
groups, groups,
}, },
}; };
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
return fallback;
}
} }
function getAppsForSession(session) { function getSessionAccessState(session) {
return getAppsForUser(getRuntimeSessionContext(session).groups); try {
const snapshot = controlPlaneStore.getSnapshot(session.user);
const sessionEmail = typeof session.user?.email === "string" ? session.user.email.toLowerCase() : "";
if (snapshot.actor.source !== "launcher") {
const revokedAccount = snapshot.data.revokedAccounts.find((account) => account.email.toLowerCase() === sessionEmail);
return {
ok: false,
status: 401,
error: revokedAccount ? "account_revoked" : "account_not_found",
message: revokedAccount
? "Аккаунт больше не активен. Запросите доступ, если хотите подключиться снова."
: "Профиль пользователя не найден в Launcher control-plane.",
};
}
const user = snapshot.data.users.find((candidate) => candidate.id === snapshot.actor.id);
if (!user) {
return {
ok: false,
status: 401,
error: "account_not_found",
message: "Профиль пользователя не найден в Launcher control-plane.",
};
}
if (user.globalStatus === "blocked") {
return {
ok: false,
status: 401,
error: "account_blocked",
message: "Аккаунт заблокирован. Обратитесь к администратору NODE.DC.",
};
}
return {
ok: true,
runtimeContext: buildRuntimeSessionContext(session, snapshot.data, user),
};
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось проверить Launcher-сессию");
return {
ok: false,
status: 401,
error: "session_invalid",
message: "Не удалось проверить Launcher-сессию.",
};
}
} }
function getAppsForUser(userGroups) { function getAppsForUser(userGroups) {
@ -1809,7 +1921,7 @@ function getAppsForUser(userGroups) {
hasAccess, hasAccess,
accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа", accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа",
}; };
}); }).filter((app) => app.hasAccess);
} }
function getAppCatalog() { function getAppCatalog() {
@ -1904,6 +2016,54 @@ async function requestTaskManagerInternalJson(pathname, init = {}) {
return payload; return payload;
} }
async function syncTaskManagerUserProfile(user) {
if (!user?.email || !config.internalAccessToken) {
return null;
}
try {
return await requestTaskManagerInternalJson("/api/internal/nodedc/users/profile-sync/", {
method: "POST",
body: {
email: user.email,
subject: user.authentikUserId ?? undefined,
name: user.name,
avatarUrl: resolveUserAvatarPublicUrl(user),
},
});
} catch (error) {
console.warn(
error instanceof Error
? `Task Manager profile sync failed: ${error.message}`
: "Task Manager profile sync failed"
);
return null;
}
}
async function cleanupTaskManagerUserAccess(user) {
if (!user?.email || !config.internalAccessToken) {
return null;
}
try {
return await requestTaskManagerInternalJson("/api/internal/nodedc/logout/", {
method: "POST",
body: {
source: "launcher-user-hard-delete",
subject: user.authentikUserId ?? undefined,
email: user.email,
revokeIdentityLinks: true,
revokeTaskerAccess: true,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Task Manager user cleanup failed";
console.warn(`Task Manager user cleanup failed: ${message}`);
return { ok: false, error: message };
}
}
function parseJsonResponse(text, url) { function parseJsonResponse(text, url) {
try { try {
return JSON.parse(text); return JSON.parse(text);
@ -2037,7 +2197,7 @@ function pruneExpiredServiceHandoffs() {
} }
} }
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) { function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user, workspaceSlug = null) {
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user"; const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
const groupSet = new Set(groups); const groupSet = new Set(groups);
const isSuperAdmin = groupSet.has("nodedc:superadmin"); const isSuperAdmin = groupSet.has("nodedc:superadmin");
@ -2049,6 +2209,10 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
); );
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker"; const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker"; const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker";
const workspaceAssignment =
typeof workspaceSlug === "string" && workspaceSlug.trim()
? workspaces.find((workspace) => workspace.slug === workspaceSlug.trim())
: null;
if (!hasTaskManagerAccess) { if (!hasTaskManagerAccess) {
return { return {
@ -2077,6 +2241,32 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
} }
if (hasLauncherManagedWorkspace && !isSuperAdmin) { if (hasLauncherManagedWorkspace && !isSuperAdmin) {
if (workspaceAssignment?.managedBy === "launcher") {
return {
mode,
managedBy: "launcher",
defaultManagedBy: "launcher",
inviteApproval: "launcher",
defaultInviteApproval: "launcher",
workspaces,
canCreateWorkspace: false,
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
};
}
if (workspaceSlug) {
return {
mode,
managedBy: "tasker",
defaultManagedBy: "launcher",
inviteApproval: defaultInviteApproval,
defaultInviteApproval,
workspaces,
canCreateWorkspace: false,
reason: "Self-service workspace работает через Tasker, approve инвайтов выполняет Launcher.",
};
}
return { return {
mode, mode,
managedBy: "launcher", managedBy: "launcher",
@ -2306,11 +2496,8 @@ function getSessionSyncAllowedOrigins() {
} }
function readLauncherData() { function readLauncherData() {
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
try { try {
if (!existsSync(dataPath)) return null; return controlPlaneStore.readData();
return JSON.parse(readFileSync(dataPath, "utf8"));
} catch { } catch {
return null; return null;
} }
@ -2433,6 +2620,21 @@ function getCurrentSession(req) {
return session; return session;
} }
function expireSession(res, session) {
sessions.delete(session.id);
res.clearCookie(sessionCookieName, clearCookieOptions());
}
function rejectInactiveSession(res, session, sessionAccess) {
expireSession(res, session);
res.status(sessionAccess.status ?? 401).json({
authenticated: false,
loginUrl: "/auth/login",
error: sessionAccess.error,
message: sessionAccess.message,
});
}
function pruneExpiredSessions() { function pruneExpiredSessions() {
for (const [sessionId, session] of sessions) { for (const [sessionId, session] of sessions) {
if (session.expiresAt < Date.now()) { if (session.expiresAt < Date.now()) {
@ -2501,7 +2703,14 @@ function requireLauncherAdmin(req, res, next) {
return; return;
} }
const runtimeContext = getRuntimeSessionContext(session); const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
const runtimeContext = sessionAccess.runtimeContext;
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups); const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
if (!adminScope.isRoot && adminScope.clientIds.size === 0) { if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
@ -2531,7 +2740,14 @@ function requireSession(req, res, next) {
return; return;
} }
const runtimeContext = getRuntimeSessionContext(session); const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
const runtimeContext = sessionAccess.runtimeContext;
req.nodedcSession = { ...session, user: runtimeContext.user }; req.nodedcSession = { ...session, user: runtimeContext.user };
next(); next();
} }
@ -2761,6 +2977,64 @@ function scopeControlPlaneData(data, scope) {
}; };
} }
function scopeRuntimeControlPlaneData(data, userId) {
const user = data.users.find((candidate) => candidate.id === userId);
if (!user) {
return {
...data,
clients: [],
users: [],
memberships: [],
groups: [],
invites: [],
accessRequests: [],
revokedAccounts: [],
taskerInviteRequests: [],
grants: [],
exceptions: [],
syncStatuses: [],
auditEvents: [],
taskManagerMemberships: [],
taskManagerProjectMemberships: [],
};
}
const memberships = data.memberships.filter((membership) => membership.userId === user.id);
const clientIds = new Set(memberships.map((membership) => membership.clientId));
const groups = data.groups
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
const groupIds = new Set(groups.map((group) => group.id));
return {
...data,
clients: data.clients.filter((client) => clientIds.has(client.id)),
users: [user],
memberships,
groups,
invites: [],
accessRequests: [],
revokedAccounts: [],
taskerInviteRequests: [],
grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId);
if (grant.targetType === "user") return grant.targetId === user.id;
return false;
}),
exceptions: data.exceptions.filter((exception) => exception.userId === user.id),
syncStatuses: [],
auditEvents: [],
taskManagerMemberships: data.taskManagerMemberships.filter(
(membership) => membership.userId === user.id && clientIds.has(membership.clientId)
),
taskManagerProjectMemberships: data.taskManagerProjectMemberships.filter(
(membership) => membership.userId === user.id && clientIds.has(membership.clientId)
),
};
}
function cookieOptions(maxAgeMs) { function cookieOptions(maxAgeMs) {
const options = { const options = {
httpOnly: true, httpOnly: true,

0
server/storage/.gitkeep Normal file
View File

View File

@ -108,15 +108,19 @@ export function LauncherApp() {
const runtimeDataRef = useRef(data); const runtimeDataRef = useRef(data);
const runtimeProfileIdRef = useRef(activeProfileId); const runtimeProfileIdRef = useRef(activeProfileId);
const runtimeClientIdRef = useRef(activeClientId); const runtimeClientIdRef = useRef(activeClientId);
const resolvedProfileId = useMemo(
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
[activeProfileId, authSession, data]
);
useEffect(() => { useEffect(() => {
runtimeDataRef.current = data; runtimeDataRef.current = data;
runtimeProfileIdRef.current = activeProfileId; runtimeProfileIdRef.current = resolvedProfileId;
runtimeClientIdRef.current = activeClientId; runtimeClientIdRef.current = activeClientId;
}, [activeClientId, activeProfileId, data]); }, [activeClientId, data, resolvedProfileId]);
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); const me = useMemo(() => buildMe(data, resolvedProfileId, activeClientId), [data, resolvedProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0]; const activeProfileUser = data.users.find((user) => user.id === resolvedProfileId) ?? data.users[0];
const currentAccessRequest = useMemo(() => { const currentAccessRequest = useMemo(() => {
if (!authSession?.authenticated || !authSession.user.email) return null; if (!authSession?.authenticated || !authSession.user.email) return null;
@ -150,10 +154,10 @@ export function LauncherApp() {
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]); const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
const launcherServices = useMemo( const launcherServices = useMemo(
() => { () => {
const services = buildLauncherServices(data, activeProfileId, resolvedClientId); const services = buildLauncherServices(data, resolvedProfileId, resolvedClientId);
if (!authSession?.authenticated || authApps === null) { if (!authSession?.authenticated || authApps === null) {
return services; return [];
} }
return services.map((service) => { return services.map((service) => {
@ -167,32 +171,34 @@ export function LauncherApp() {
effectiveAccess: { effectiveAccess: {
...service.effectiveAccess, ...service.effectiveAccess,
allowed: false, allowed: false,
visible: true, visible: false,
openEnabled: false, openEnabled: false,
reason: "Нет доступа", reason: "Нет доступа",
}, },
}; };
} }
const openEnabled = app.hasAccess && app.status === "active"; const appVisible = app.hasAccess && app.status !== "hidden" && app.status !== "disabled";
const allowed = appVisible && service.effectiveAccess.allowed;
const openEnabled = appVisible && app.status === "active" && service.effectiveAccess.openEnabled;
return { return {
...service, ...service,
title: app.title || service.title, title: app.title || service.title,
description: app.description || service.description, description: app.description || service.description,
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null, openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
userAccess: openEnabled ? ("allowed" as const) : ("denied" as const), userAccess: allowed ? ("allowed" as const) : ("denied" as const),
effectiveAccess: { effectiveAccess: {
...service.effectiveAccess, ...service.effectiveAccess,
allowed: app.hasAccess, allowed,
visible: true, visible: appVisible && service.effectiveAccess.visible,
openEnabled, openEnabled,
reason: app.accessReason || (app.hasAccess ? "Доступ подтверждён" : "Нет доступа"), reason: !app.hasAccess ? app.accessReason || "Нет доступа" : service.effectiveAccess.reason,
}, },
}; };
}); }).filter((service) => service.effectiveAccess.visible);
}, },
[authApps, authAppsBySlug, authSession, data, activeProfileId, resolvedClientId] [authApps, authAppsBySlug, authSession, data, resolvedProfileId, resolvedClientId]
); );
useEffect(() => { useEffect(() => {
@ -846,7 +852,7 @@ export function LauncherApp() {
me={runtimeMe} me={runtimeMe}
clients={data.clients} clients={data.clients}
profileOptions={profileOptions} profileOptions={profileOptions}
activeProfileId={activeProfileId} activeProfileId={resolvedProfileId}
activeClientId={resolvedClientId} activeClientId={resolvedClientId}
adminOpen={adminOpen} adminOpen={adminOpen}
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"} adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
@ -1191,6 +1197,28 @@ function resolveAuthenticatedContext(
}; };
} }
function resolveRuntimeProfileId(data: LauncherData, session: AuthSession | null, currentProfileId: string): string {
if (data.users.some((user) => user.id === currentProfileId)) {
return currentProfileId;
}
if (session?.authenticated) {
const sessionEmail = session.user.email?.toLowerCase();
const sessionSub = session.user.sub;
const sessionUser = data.users.find(
(user) =>
(sessionSub && user.authentikUserId === sessionSub) ||
(sessionEmail && user.email.toLowerCase() === sessionEmail)
);
if (sessionUser) {
return sessionUser.id;
}
}
return data.users[0]?.id ?? currentProfileId;
}
function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string { function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string {
const user = data.users.find((item) => item.id === userId); const user = data.users.find((item) => item.id === userId);
const isRoot = user?.id === "user_root"; const isRoot = user?.id === "user_root";

View File

@ -45,7 +45,7 @@ export interface LauncherAuthApp {
export async function fetchAuthSession(): Promise<AuthSession> { export async function fetchAuthSession(): Promise<AuthSession> {
const response = await fetch("/api/me", { cache: "no-store" }); const response = await fetch("/api/me", { cache: "no-store" });
if (response.status === 401) { if (response.status === 401 || response.status === 403) {
return (await response.json()) as UnauthenticatedSession; return (await response.json()) as UnauthenticatedSession;
} }

View File

@ -261,7 +261,9 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?:
})); }));
const fallbackClientId = const fallbackClientId =
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId; profileOptions.find((option) => option.userId === user.id)?.defaultClientId ??
availableMemberships[0]?.clientId ??
PUBLIC_POOL_CLIENT.id;
const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId); const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId; const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId); const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);

View File

@ -30,11 +30,13 @@ export async function uploadStorageFile(file: File): Promise<StoredFileResponse>
export async function loadPersistedLauncherData(): Promise<LauncherData | null> { export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
try { try {
const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" }); const response = await fetch("/api/storage/data", { cache: "no-store" });
if (!response.ok) return null; if (!response.ok) return null;
return normalizeLauncherData((await response.json()) as Partial<LauncherData>); const data = normalizeLauncherData((await response.json()) as Partial<LauncherData>);
return data.users.length > 0 ? data : null;
} catch { } catch {
return null; return null;
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { KeyRound, Save, Upload, X } from "lucide-react"; import { KeyRound, Save, Upload, X } from "lucide-react";
import type { LauncherUser } from "../../entities/user/types"; import type { LauncherUser } from "../../entities/user/types";
import { uploadStorageFile } from "../../shared/api/storageApi"; import { uploadStorageFile } from "../../shared/api/storageApi";
@ -17,13 +17,42 @@ export function ProfileSettingsPanel({
onChangePassword: (newPassword: string) => Promise<void>; onChangePassword: (newPassword: string) => Promise<void>;
}) { }) {
const [draft, setDraft] = useState<LauncherUser>(user); const [draft, setDraft] = useState<LauncherUser>(user);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(user.avatarUrl ?? null);
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false); const [savingPassword, setSavingPassword] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const draftRef = useRef(draft);
const hasUnsavedProfileChangesRef = useRef(false);
const lastSyncedUserIdRef = useRef(user.id);
useEffect(() => setDraft(user), [user]); useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
const isAnotherUser = lastSyncedUserIdRef.current !== user.id;
if (!isAnotherUser && hasUnsavedProfileChangesRef.current) {
return;
}
lastSyncedUserIdRef.current = user.id;
hasUnsavedProfileChangesRef.current = false;
setDraft(user);
setAvatarPreviewUrl(user.avatarUrl ?? null);
setUploading(false);
}, [user]);
useEffect(
() => () => {
if (avatarPreviewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(avatarPreviewUrl);
}
},
[avatarPreviewUrl]
);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@ -35,12 +64,20 @@ export function ProfileSettingsPanel({
}, [onClose]); }, [onClose]);
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) { function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
setDraft((current) => ({ ...current, [key]: value })); hasUnsavedProfileChangesRef.current = true;
setDraft((current) => {
const nextDraft = { ...current, [key]: value };
draftRef.current = nextDraft;
return nextDraft;
});
} }
async function handleAvatarUpload(file: File | undefined) { async function handleAvatarUpload(file: File | undefined) {
if (!file) return; if (!file) return;
const localPreviewUrl = URL.createObjectURL(file);
hasUnsavedProfileChangesRef.current = true;
setAvatarPreviewUrl(localPreviewUrl);
setUploading(true); setUploading(true);
setMessage(null); setMessage(null);
@ -49,6 +86,7 @@ export function ProfileSettingsPanel({
update("avatarUrl", result.url); update("avatarUrl", result.url);
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар"); setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
setAvatarPreviewUrl(draftRef.current.avatarUrl ?? null);
} finally { } finally {
setUploading(false); setUploading(false);
} }
@ -59,13 +97,16 @@ export function ProfileSettingsPanel({
setMessage(null); setMessage(null);
try { try {
const profileDraft = draftRef.current;
await onSaveProfile({ await onSaveProfile({
name: draft.name, name: profileDraft.name,
email: draft.email, email: profileDraft.email,
phone: draft.phone ?? null, phone: profileDraft.phone ?? null,
position: draft.position ?? null, position: profileDraft.position ?? null,
avatarUrl: draft.avatarUrl ?? null, avatarUrl: profileDraft.avatarUrl ?? null,
}); });
hasUnsavedProfileChangesRef.current = false;
setAvatarPreviewUrl(profileDraft.avatarUrl ?? null);
setMessage("Профиль сохранён"); setMessage("Профиль сохранён");
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль"); setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
@ -109,8 +150,8 @@ export function ProfileSettingsPanel({
<div className="profile-settings-panel__body"> <div className="profile-settings-panel__body">
<div className="profile-settings-avatar-card"> <div className="profile-settings-avatar-card">
{draft.avatarUrl ? ( {avatarPreviewUrl ? (
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" /> <img className="profile-settings-avatar-card__image" src={avatarPreviewUrl} alt="" />
) : ( ) : (
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span> <span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
)} )}
@ -121,7 +162,11 @@ export function ProfileSettingsPanel({
type="file" type="file"
accept="image/png,image/jpeg,image/webp,image/gif" accept="image/png,image/jpeg,image/webp,image/gif"
disabled={uploading} disabled={uploading}
onChange={(event) => void handleAvatarUpload(event.target.files?.[0])} onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleAvatarUpload(file);
}}
/> />
</label> </label>
</div> </div>