From dae87bb9b858d2f30f2c95a114d3376677035f46 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 26 May 2026 18:31:26 +0300 Subject: [PATCH] fix: lock protected launcher access roles --- server/authentik-sync.mjs | 2 +- server/control-plane-store.mjs | 122 ++++++++++++++++++++- server/dev-server.mjs | 63 +++++++++-- src/widgets/admin-overlay/AdminOverlay.tsx | 70 ++++++++---- 4 files changed, 218 insertions(+), 39 deletions(-) diff --git a/server/authentik-sync.mjs b/server/authentik-sync.mjs index 8179133..595b254 100644 --- a/server/authentik-sync.mjs +++ b/server/authentik-sync.mjs @@ -204,7 +204,7 @@ export function resolveRequiredGroups(data, user) { groupNames.add(platformGroups.launcherAdmin); groupNames.add(platformGroups.taskManagerAdmin); groupNames.add(platformGroups.taskManagerUser); - addGroups(groupNames, resolveEngineRoleGroups("admin")); + addGroups(groupNames, resolveEngineRoleGroups("member")); return [...groupNames]; } diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 4f055e3..3301b1b 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -42,6 +42,7 @@ const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]); const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]); const engineServiceRoles = new Set(["viewer", "member"]); const publicPoolClientId = "client_public_pool"; +const protectedLauncherUserIds = new Set(["user_root"]); const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"]; const publicPoolClient = { id: publicPoolClientId, @@ -518,8 +519,20 @@ export function createControlPlaneStore({ projectRoot }) { const membership = findById(data.memberships, membershipId, "membership"); const user = findById(data.users, membership.userId, "user"); - membership.role = pickEnum(payload?.role, membershipRoles, membership.role); - membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status); + if (isProtectedLauncherUserId(user.id)) { + const nextRole = pickEnum(payload?.role, membershipRoles, "client_owner"); + const nextStatus = pickEnum(payload?.status, new Set(["active", "disabled"]), "active"); + + if (nextRole !== "client_owner" || nextStatus !== "active") { + throw new Error("Системного root-пользователя нельзя понижать или отключать"); + } + + membership.role = "client_owner"; + membership.status = "active"; + } else { + membership.role = pickEnum(payload?.role, membershipRoles, membership.role); + membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status); + } membership.updatedAt = isoNow(); addAuditEvent(data, actor, { @@ -1670,6 +1683,16 @@ export function createControlPlaneStore({ projectRoot }) { const existingGrant = data.grants.find( (grant) => grant.serviceId === serviceId && grant.targetType === targetType && grant.targetId === targetId ); + const nextAppRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member"); + + if (targetType === "user" && isProtectedLauncherUserId(targetId)) { + throw new Error("Доступ системного root-пользователя зафиксирован платформой"); + } + + if (isOperationalCoreService(service) && (nextAppRole === "admin" || nextAppRole === "owner")) { + throw new Error("Operational Core admin-роль назначается только системно"); + } + const grant = existingGrant ?? { @@ -1680,7 +1703,7 @@ export function createControlPlaneStore({ projectRoot }) { createdAt: now, }; - grant.appRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member"); + grant.appRole = nextAppRole; grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active"); grant.updatedAt = now; @@ -1711,6 +1734,10 @@ export function createControlPlaneStore({ projectRoot }) { const service = findById(data.services, serviceId, "service"); const user = findById(data.users, userId, "user"); + if (isProtectedLauncherUserId(user.id)) { + throw new Error("Доступ системного root-пользователя зафиксирован платформой"); + } + const existingException = data.exceptions.find( (exception) => exception.serviceId === serviceId && exception.userId === userId ); @@ -1756,10 +1783,18 @@ export function createControlPlaneStore({ projectRoot }) { const engineService = isEngineService(service); const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null; + if (isProtectedLauncherUserId(user.id)) { + throw new Error("Доступ системного root-пользователя зафиксирован платформой"); + } + if (engineService && (value === "admin" || value === "owner")) { throw new Error("Для Engine доступны только роли guest, member или блокировка"); } + if (isOperationalCoreService(service) && (value === "admin" || value === "owner")) { + throw new Error("Operational Core admin-роль назначается только системно"); + } + const directGrant = data.grants.find( (grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId ); @@ -2207,9 +2242,82 @@ function normalizeData(payload) { data.serviceModuleEntitlements = data.serviceModuleEntitlements.map(normalizeServiceModuleEntitlement).filter(Boolean); data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean); data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean); + applyProtectedLauncherUserInvariants(data); return data; } +function applyProtectedLauncherUserInvariants(data) { + const rootUser = data.users.find((user) => isProtectedLauncherUserId(user.id)); + if (!rootUser) return; + + const now = isoNow(); + if (rootUser.globalStatus !== "active") { + rootUser.globalStatus = "active"; + rootUser.updatedAt = now; + } + + for (const membership of data.memberships) { + if (membership.userId !== rootUser.id) continue; + if (membership.role !== "client_owner" || membership.status !== "active") { + membership.role = "client_owner"; + membership.status = "active"; + membership.updatedAt = now; + } + } + + const engineService = data.services.find(isEngineService); + if (engineService) { + upsertSystemProtectedUserGrant(data, rootUser, engineService, "member", now); + } + + const operationalCoreService = data.services.find(isOperationalCoreService); + if (operationalCoreService) { + upsertSystemProtectedUserGrant(data, rootUser, operationalCoreService, "admin", now); + } + + const protectedServiceIds = new Set([engineService?.id, operationalCoreService?.id].filter(Boolean)); + if (protectedServiceIds.size) { + data.exceptions = data.exceptions.filter( + (exception) => !(exception.userId === rootUser.id && protectedServiceIds.has(exception.serviceId)) + ); + } +} + +function upsertSystemProtectedUserGrant(data, user, service, appRole, now) { + let existingGrant = null; + data.grants = data.grants.filter((grant) => { + const matches = grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id; + if (!matches) return true; + if (!existingGrant) { + existingGrant = grant; + return true; + } + return false; + }); + + const grant = + existingGrant ?? + { + id: uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`), + serviceId: service.id, + targetType: "user", + targetId: user.id, + createdAt: now, + }; + + if (grant.appRole !== appRole || grant.status !== "active") { + grant.appRole = appRole; + grant.status = "active"; + grant.updatedAt = now; + } else { + grant.updatedAt = grant.updatedAt ?? now; + } + + if (!existingGrant) { + data.grants.push(grant); + } +} + function normalizeService(service) { if (typeof service !== "object" || service === null) return service; if (service.id !== "service_nodedc" && service.slug !== "nodedc" && service.authentikApplicationSlug !== "nodedc") return service; @@ -2901,6 +3009,10 @@ function findEngineService(data) { return data.services.find(isEngineService); } +function isProtectedLauncherUserId(userId) { + return protectedLauncherUserIds.has(userId); +} + function isEngineService(candidate) { return Boolean( candidate && @@ -2913,6 +3025,10 @@ function isEngineService(candidate) { ); } +function isOperationalCoreService(candidate) { + return Boolean(candidate && (candidate.slug === "task-manager" || candidate.authentikApplicationSlug === "task-manager")); +} + function hasTaskManagerDenyException(data, userId) { const service = data.services.find((candidate) => candidate.slug === "task-manager"); if (!service) { diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 225fdfc..b35d1d3 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -1162,7 +1162,17 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher return; } - const role = normalizeTaskManagerRole(req.body?.role) ?? resolveTaskManagerRoleForMembership(membership?.role); + const requestedRole = normalizeTaskManagerRole(req.body?.role); + if (requestedRole === "admin") { + res.status(403).json({ + ok: false, + error: "task_manager_admin_role_locked", + message: "Admin-роль Operational Core назначается только системно.", + }); + return; + } + + const role = requestedRole ?? resolveTaskManagerRoleForMembership(membership?.role); const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", { method: "POST", body: { @@ -1315,6 +1325,15 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd return; } + if (role === "admin") { + res.status(403).json({ + ok: false, + error: "task_manager_admin_role_locked", + message: "Admin-роль Operational Core назначается только системно.", + }); + return; + } + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/ensure/", { method: "POST", body: { @@ -1522,15 +1541,7 @@ app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRou return; } - if (!assertAdminCanManageMembership(req, res, membership, { allowProtectedSelf: true })) { - return; - } - - if (isProtectedSelfManageRequest(req, membership.userId) && req.body?.status && req.body.status !== "active") { - res.status(403).json({ - error: "protected_self_status_locked", - message: "Защищённый пользователь может менять себе роли, но не может отключить собственный контур.", - }); + if (!assertAdminCanManageMembership(req, res, membership)) { return; } @@ -2041,10 +2052,29 @@ app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLa app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => { const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + if (req.body?.targetType === "user" && isProtectedLauncherUser(req.body?.targetId)) { + res.status(403).json({ + ok: false, + error: "protected_user_access_locked", + message: "Доступ системного root-пользователя зафиксирован платформой.", + }); + return; + } + if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId, { allowProtectedSelf: true })) { return; } + const service = snapshot.data.services.find((candidate) => candidate.id === req.body?.serviceId); + if (isTaskManagerService(service) && (req.body?.appRole === "admin" || req.body?.appRole === "owner")) { + res.status(403).json({ + ok: false, + error: "task_manager_admin_role_locked", + message: "Admin-роль Operational Core назначается только системно.", + }); + return; + } + const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik( result.data, @@ -2072,6 +2102,17 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn } const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const service = snapshot.data.services.find((candidate) => candidate.id === req.body?.serviceId); + + if (isTaskManagerService(service) && (req.body?.value === "admin" || req.body?.value === "owner")) { + res.status(403).json({ + ok: false, + error: "task_manager_admin_role_locked", + message: "Admin-роль Operational Core назначается только системно.", + }); + return; + } + if (isPublicTaskManagerGuestServiceAssignment(snapshot.data, req.body)) { res.status(400).json({ ok: false, @@ -4128,7 +4169,7 @@ function assertAdminCanManageUser(req, res, userId, options = {}) { } function assertAdminCanManageAccessForUser(req, res, userId) { - return assertAdminCanManageUser(req, res, userId, { allowProtectedSelf: true }); + return assertAdminCanManageUser(req, res, userId); } function assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds = [], nextMemberIds = []) { diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index f5e92dd..d21a275 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -1412,6 +1412,14 @@ const accessAssignmentOptions: Array> { value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" }, ]; +const operationalCoreAccessAssignmentOptions: Array> = [ + { value: "unset", label: "—", description: "Не назначен" }, + { value: "viewer", label: "Гость", description: "Просмотр", tone: "green" }, + { value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" }, + { value: "admin", label: "Админ", description: "Системная роль", tone: "green", disabled: true, hidden: true }, + { value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" }, +]; + const engineAccessAssignmentOptions: Array> = [ { value: "unset", label: "—", description: "Не назначен" }, { value: "viewer", label: "Гость", description: "Только явно открытые workflow", tone: "green" }, @@ -1424,12 +1432,12 @@ const publicOperationalCoreAccessOptions: Array> = [ - ...accessAssignmentOptions, + ...operationalCoreAccessAssignmentOptions, { value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, ]; @@ -1437,7 +1445,7 @@ const taskManagerProjectRoleOptions: Array onUpdateMembership(membership.id, { role })} /> @@ -3129,14 +3136,16 @@ function AccessSection({ pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]} busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment} publicSelfService={usePublicTaskerAccess} - readOnly={protectedFromActor} + readOnly={protectedUser || protectedFromActor} onSelectCell={onSelectCell} onSetAccess={(value) => { + if (protectedUser || protectedFromActor) return; + const nextValue = value; onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); if (usePublicTaskerAccess) return; - if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedFromActor || (forcedTaskManagerAdmin && !protectedSelfManage)) return; + if (!isTaskManagerService || !primaryTaskManagerWorkspace || forcedTaskManagerAdmin) return; onSetTaskManagerWorkspaceMemberRole({ clientId: matrix.client.id, @@ -3239,7 +3248,6 @@ function PublicAccessUsersPanel({ if (!membership) return null; const protectedUser = isProtectedLauncherUser(user.id); - const protectedFromActor = isProtectedFromCurrentActor(me, user.id); const inviterMeta = getMembershipInviterMeta(data, membership); const operationalCoreCell = operationalCoreService ? matrix.cells.find((cell) => cell.userId === user.id && cell.serviceId === operationalCoreService.id) ?? null @@ -3274,7 +3282,7 @@ function PublicAccessUsersPanel({ )} - {protectedFromActor ? ( + {protectedUser ? ( {membershipRoleLabel(membership.role)} ) : ( - onUpdateMembership(membership.id, { status })} - /> + {protectedUser ? ( + {statusOptionLabel(membershipStatusOptions, membership.status)} + ) : ( + onUpdateMembership(membership.id, { status })} + /> + )} {protectedUser ? ( @@ -3479,12 +3491,13 @@ function OperationalCoreAccessModal({ }) { const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id); const protectedSelfManage = canSelfManageProtectedUser(me, user.id); - const protectedUser = isProtectedFromCurrentActor(me, user.id) || (membership?.role === "client_owner" && !protectedSelfManage); + const protectedUser = isProtectedLauncherUser(user.id) || isProtectedFromCurrentActor(me, user.id) || (membership?.role === "client_owner" && !protectedSelfManage); const basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)]; const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell); - const baseSelectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions; + const baseSelectOptions = publicSelfService ? publicOperationalCoreAccessOptions : operationalCoreAccessAssignmentOptions; const baseSelectValue = publicSelfService ? publicOperationalCoreSelectValue(baseAssignmentValue) : baseAssignmentValue; const basePending = basePendingValue !== undefined; + const baseAccessLocked = protectedUser || baseSelectValue === "admin"; return (
@@ -3507,8 +3520,8 @@ function OperationalCoreAccessModal({ : "Глобальная роль пользователя в Operational Core для выбранного клиента."}
- {protectedUser ? ( - {accessAssignmentLabel("admin")} + {baseAccessLocked ? ( + {operationalCoreStaticRoleLabel(baseSelectValue)} ) : ( onSetServiceModuleEntitlement({ clientId: client.id, @@ -3595,8 +3608,8 @@ function OperationalCoreAccessModal({ {workspace.isPrimary ? " · основной workspace" : ""} - {protectedUser ? ( - {accessAssignmentLabel("admin")} + {protectedUser || value === "admin" ? ( + {operationalCoreStaticRoleLabel(value)} ) : ( { if (nextValue === "pending") return; + if (nextValue === "admin") return; onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); onSetTaskManagerWorkspaceMemberRole({ clientId: client.id, @@ -3639,8 +3653,8 @@ function OperationalCoreAccessModal({ {project.memberCount ? ` · ${project.memberCount} участников` : ""} - {protectedUser ? ( - {accessAssignmentLabel("admin")} + {protectedUser || projectValue === "admin" ? ( + {operationalCoreStaticRoleLabel(projectValue)} ) : ( { if (nextValue === "pending") return; + if (nextValue === "admin") return; const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue); if (nextTaskManagerRole !== "unset") { @@ -3735,6 +3750,8 @@ function AccessCellControl({ ? publicOperationalCoreAccessOptions : isEngineServiceForAccessControl(service) ? engineAccessAssignmentOptions + : isOperationalCoreService(service) + ? operationalCoreAccessAssignmentOptions : accessAssignmentOptions; const displayTitle = isPending ? publicSelfService @@ -5139,6 +5156,11 @@ function taskManagerRoleToAccessAssignment(role: TaskManagerWorkspaceMemberRole) return "unset"; } +function operationalCoreStaticRoleLabel(value: OperationalCoreRoleSelectValue): string { + if (value === "pending") return "Сохраняем..."; + return accessAssignmentLabel(value); +} + function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string { if (!source) return "—"; const labels = {