diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 2f8fc3b..78e9459 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -817,7 +817,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher return; } - if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageAccessForUser(req, res, user.id)) { return; } @@ -878,7 +878,7 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher return; } - if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageAccessForUser(req, res, user.id)) { return; } @@ -959,7 +959,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd return; } - if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageAccessForUser(req, res, user.id)) { return; } @@ -1031,7 +1031,7 @@ app.post("/api/admin/task-manager/project-memberships/remove", requireLauncherAd return; } - if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageAccessForUser(req, res, user.id)) { return; } @@ -1190,7 +1190,15 @@ app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRou return; } - if (!assertAdminCanManageMembership(req, res, membership)) { + 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: "Защищённый пользователь может менять себе роли, но не может отключить собственный контур.", + }); return; } @@ -1369,6 +1377,10 @@ app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) return; } + if (!assertAdminCanManageProtectedGroupMembers(req, res, [], req.body?.memberIds)) { + return; + } + const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user); publishControlPlaneEvent("admin.group.created", syncResult.userIds); @@ -1389,6 +1401,11 @@ app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async ( } const previousMemberIds = group.memberIds; + + if (!assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds, req.body?.memberIds)) { + return; + } + const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik( result.data, @@ -1412,6 +1429,10 @@ app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async return; } + if (!assertAdminCanManageProtectedGroupMembers(req, res, group.memberIds, [])) { + return; + } + const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user); publishControlPlaneEvent("admin.group.deleted", syncResult.userIds); @@ -1445,7 +1466,7 @@ 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 (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId)) { + if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId, { allowProtectedSelf: true })) { return; } @@ -1460,7 +1481,7 @@ app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req })); app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => { - if (!assertAdminCanManageUser(req, res, req.body?.userId)) { + if (!assertAdminCanManageAccessForUser(req, res, req.body?.userId)) { return; } @@ -1471,7 +1492,7 @@ app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async })); app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => { - if (!assertAdminCanManageUser(req, res, req.body?.userId)) { + if (!assertAdminCanManageAccessForUser(req, res, req.body?.userId)) { return; } @@ -1492,7 +1513,7 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn })); app.post("/api/admin/access/service-modules", requireLauncherAdmin, asyncRoute(async (req, res) => { - if (!assertAdminCanManageClient(req, res, req.body?.clientId) || !assertAdminCanManageUser(req, res, req.body?.userId)) { + if (!assertAdminCanManageClient(req, res, req.body?.clientId) || !assertAdminCanManageAccessForUser(req, res, req.body?.userId)) { return; } @@ -3183,8 +3204,16 @@ function canAdminManageClient(req, clientId) { return Boolean(req.nodedcAdminScope?.isRoot || req.nodedcAdminScope?.clientIds.has(clientId)); } -function canAdminManageUser(req, userId) { - if (protectedLauncherUserIds.has(userId)) { +function isProtectedLauncherUser(userId) { + return protectedLauncherUserIds.has(userId); +} + +function isProtectedSelfManageRequest(req, userId) { + return isProtectedLauncherUser(userId) && req.nodedcAdminScope?.actorId === userId; +} + +function canAdminManageUser(req, userId, options = {}) { + if (isProtectedLauncherUser(userId) && !(options.allowProtectedSelf && isProtectedSelfManageRequest(req, userId))) { return false; } @@ -3206,8 +3235,8 @@ function assertAdminCanManageClient(req, res, clientId) { return false; } -function assertAdminCanManageUser(req, res, userId) { - if (canAdminManageUser(req, userId)) { +function assertAdminCanManageUser(req, res, userId, options = {}) { + if (canAdminManageUser(req, userId, options)) { return true; } @@ -3215,17 +3244,48 @@ function assertAdminCanManageUser(req, res, userId) { return false; } -function assertAdminCanManageMembership(req, res, membership) { +function assertAdminCanManageAccessForUser(req, res, userId) { + return assertAdminCanManageUser(req, res, userId, { allowProtectedSelf: true }); +} + +function assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds = [], nextMemberIds = []) { + if (!Array.isArray(nextMemberIds)) { + return true; + } + + const previous = new Set(previousMemberIds); + const next = new Set(nextMemberIds); + const changedProtectedUserIds = [...protectedLauncherUserIds].filter((userId) => previous.has(userId) !== next.has(userId)); + const blockedUserId = changedProtectedUserIds.find((userId) => !isProtectedSelfManageRequest(req, userId)); + + if (!blockedUserId) { + return true; + } + + res.status(403).json({ + error: "protected_user_group_membership_locked", + message: "Защищённого пользователя может добавлять в группы или удалять из групп только он сам.", + }); + return false; +} + +function assertAdminCanManageMembership(req, res, membership, options = {}) { if (!assertAdminCanManageClient(req, res, membership.clientId)) { return false; } - return assertAdminCanManageUser(req, res, membership.userId); + return assertAdminCanManageUser(req, res, membership.userId, options); } -function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId) { +function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId, options = {}) { if (req.nodedcAdminScope?.isRoot) { - return true; + if (targetType === "client") { + return true; + } + + if (targetType === "user" && (!isProtectedLauncherUser(targetId) || isProtectedSelfManageRequest(req, targetId) || !options.allowProtectedSelf)) { + return true; + } } if (targetType === "client") { @@ -3240,11 +3300,21 @@ function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId) { return false; } + const blockedProtectedUserId = group.memberIds.find((userId) => isProtectedLauncherUser(userId) && !isProtectedSelfManageRequest(req, userId)); + + if (blockedProtectedUserId) { + res.status(403).json({ + error: "protected_group_access_locked", + message: "Гранты группы с защищённым пользователем может менять только сам защищённый пользователь.", + }); + return false; + } + return assertAdminCanManageClient(req, res, group.clientId); } if (targetType === "user") { - return assertAdminCanManageUser(req, res, targetId); + return assertAdminCanManageUser(req, res, targetId, options); } res.status(403).json({ error: "Недостаточно прав для управления этим доступом" }); diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 60b741a..9df36fa 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -163,6 +163,20 @@ const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.R { id: "misc", label: "Разное", icon: }, ]; +const protectedLauncherUserIds = new Set(["user_root"]); + +function isProtectedLauncherUser(userId: string): boolean { + return protectedLauncherUserIds.has(userId); +} + +function canSelfManageProtectedUser(me: MeResponse, userId: string): boolean { + return isProtectedLauncherUser(userId) && me.user.id === userId; +} + +function isProtectedFromCurrentActor(me: MeResponse, userId: string): boolean { + return isProtectedLauncherUser(userId) && !canSelfManageProtectedUser(me, userId); +} + export function AdminOverlay({ data, me, @@ -499,6 +513,7 @@ export function AdminOverlay({ setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })} @@ -1174,12 +1190,14 @@ function PlatformUsersSection({ function GroupsSection({ data, clientId, + me, onCreateGroup, onUpdateGroup, onDeleteGroup, }: { data: LauncherData; clientId: string; + me: MeResponse; onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; onDeleteGroup: (groupId: string) => void; @@ -1247,6 +1265,7 @@ function GroupsSection({ {editingGroup ? ( membership.clientId === clientId) .map((membership) => getUser(data, membership.userId))} @@ -2674,12 +2693,14 @@ function UserEditorModal({ function GroupEditorModal({ group, + me, users, onClose, onSave, onDelete, }: { group: ClientGroup; + me: MeResponse; users: LauncherUser[]; onClose: () => void; onSave: (patch: Partial) => void; @@ -2718,17 +2739,23 @@ function GroupEditorModal({
Участники
- {users.map((user) => ( - - ))} + {users.map((user) => { + const lockedProtectedUser = isProtectedFromCurrentActor(me, user.id); + + return ( + + ); + })}
@@ -2926,6 +2953,7 @@ function mediaKindFromUrl(value: string): MediaKind | null { function AccessSection({ data, + me, matrix, selectedCell, onSelectCell, @@ -2942,6 +2970,7 @@ function AccessSection({ onSetServiceModuleEntitlement, }: { data: LauncherData; + me: MeResponse; matrix: ReturnType; selectedCell: AccessMatrixCell | null; onSelectCell: (cell: AccessMatrixCell) => void; @@ -3011,7 +3040,9 @@ function AccessSection({ const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id); if (!membership) return null; - const protectedUser = user.id === "user_root"; + const protectedUser = isProtectedLauncherUser(user.id); + const protectedFromActor = isProtectedFromCurrentActor(me, user.id); + const protectedSelfManage = canSelfManageProtectedUser(me, user.id); const inviterMeta = getMembershipInviterMeta(data, membership); const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`; const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]); @@ -3038,7 +3069,7 @@ function AccessSection({
onUpdateMembership(membership.id, { role })} />
@@ -3056,13 +3087,14 @@ function AccessSection({ pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]} busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment} publicSelfService={usePublicTaskerAccess} + readOnly={protectedFromActor} onSelectCell={onSelectCell} onSetAccess={(value) => { const nextValue = value; onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); if (usePublicTaskerAccess) return; - if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return; + if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedFromActor || (forcedTaskManagerAdmin && !protectedSelfManage)) return; onSetTaskManagerWorkspaceMemberRole({ clientId: matrix.client.id, @@ -3086,6 +3118,7 @@ function AccessSection({ {detailsCell && detailsService ? ( ; selectedCell: AccessMatrixCell | null; onSelectCell: (cell: AccessMatrixCell) => void; @@ -3161,7 +3196,8 @@ function PublicAccessUsersPanel({ const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id); if (!membership) return null; - const protectedUser = user.id === "user_root"; + 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 @@ -3196,7 +3232,7 @@ function PublicAccessUsersPanel({ )} - {protectedUser ? ( + {protectedFromActor ? ( {membershipRoleLabel(membership.role)} ) : ( void; }) { const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id); - const protectedUser = user.id === "user_root" || membership?.role === "client_owner"; + const protectedSelfManage = canSelfManageProtectedUser(me, user.id); + const protectedUser = 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; @@ -3628,6 +3668,7 @@ function AccessCellControl({ pendingValue, busy = false, publicSelfService = false, + readOnly = false, onSelectCell, onSetAccess, onOpenDetails, @@ -3637,6 +3678,7 @@ function AccessCellControl({ pendingValue?: AccessAssignmentValue; busy?: boolean; publicSelfService?: boolean; + readOnly?: boolean; onSelectCell: (cell: AccessMatrixCell) => void; onSetAccess: (value: AccessAssignmentValue) => void; onOpenDetails?: () => void; @@ -3663,6 +3705,7 @@ function AccessCellControl({ cell.effectiveAccess.allowed && "access-cell--allowed", !cell.effectiveAccess.allowed && "access-cell--denied", cell.effectiveAccess.source === "exception" && !publicSelfService && "access-cell--exception", + readOnly && "access-cell--readonly", isPending && "access-cell--pending", active && "access-cell--active" ); @@ -3685,6 +3728,15 @@ function AccessCellControl({ ); } + if (readOnly) { + return ( + + {displayTitle} + {displaySource} + + ); + } + return (