fix: lock protected launcher access roles

This commit is contained in:
DCCONSTRUCTIONS 2026-05-26 18:31:26 +03:00
parent 8f56fd2ab6
commit dae87bb9b8
4 changed files with 218 additions and 39 deletions

View File

@ -204,7 +204,7 @@ export function resolveRequiredGroups(data, user) {
groupNames.add(platformGroups.launcherAdmin); groupNames.add(platformGroups.launcherAdmin);
groupNames.add(platformGroups.taskManagerAdmin); groupNames.add(platformGroups.taskManagerAdmin);
groupNames.add(platformGroups.taskManagerUser); groupNames.add(platformGroups.taskManagerUser);
addGroups(groupNames, resolveEngineRoleGroups("admin")); addGroups(groupNames, resolveEngineRoleGroups("member"));
return [...groupNames]; return [...groupNames];
} }

View File

@ -42,6 +42,7 @@ const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]); const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
const engineServiceRoles = new Set(["viewer", "member"]); const engineServiceRoles = new Set(["viewer", "member"]);
const publicPoolClientId = "client_public_pool"; const publicPoolClientId = "client_public_pool";
const protectedLauncherUserIds = new Set(["user_root"]);
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"]; const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
const publicPoolClient = { const publicPoolClient = {
id: publicPoolClientId, id: publicPoolClientId,
@ -518,8 +519,20 @@ export function createControlPlaneStore({ projectRoot }) {
const membership = findById(data.memberships, membershipId, "membership"); const membership = findById(data.memberships, membershipId, "membership");
const user = findById(data.users, membership.userId, "user"); const user = findById(data.users, membership.userId, "user");
membership.role = pickEnum(payload?.role, membershipRoles, membership.role); if (isProtectedLauncherUserId(user.id)) {
membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status); 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(); membership.updatedAt = isoNow();
addAuditEvent(data, actor, { addAuditEvent(data, actor, {
@ -1670,6 +1683,16 @@ export function createControlPlaneStore({ projectRoot }) {
const existingGrant = data.grants.find( const existingGrant = data.grants.find(
(grant) => grant.serviceId === serviceId && grant.targetType === targetType && grant.targetId === targetId (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 = const grant =
existingGrant ?? existingGrant ??
{ {
@ -1680,7 +1703,7 @@ export function createControlPlaneStore({ projectRoot }) {
createdAt: now, createdAt: now,
}; };
grant.appRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member"); grant.appRole = nextAppRole;
grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active"); grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active");
grant.updatedAt = now; grant.updatedAt = now;
@ -1711,6 +1734,10 @@ export function createControlPlaneStore({ projectRoot }) {
const service = findById(data.services, serviceId, "service"); const service = findById(data.services, serviceId, "service");
const user = findById(data.users, userId, "user"); const user = findById(data.users, userId, "user");
if (isProtectedLauncherUserId(user.id)) {
throw new Error("Доступ системного root-пользователя зафиксирован платформой");
}
const existingException = data.exceptions.find( const existingException = data.exceptions.find(
(exception) => exception.serviceId === serviceId && exception.userId === userId (exception) => exception.serviceId === serviceId && exception.userId === userId
); );
@ -1756,10 +1783,18 @@ export function createControlPlaneStore({ projectRoot }) {
const engineService = isEngineService(service); const engineService = isEngineService(service);
const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null; const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null;
if (isProtectedLauncherUserId(user.id)) {
throw new Error("Доступ системного root-пользователя зафиксирован платформой");
}
if (engineService && (value === "admin" || value === "owner")) { if (engineService && (value === "admin" || value === "owner")) {
throw new Error("Для Engine доступны только роли guest, member или блокировка"); throw new Error("Для Engine доступны только роли guest, member или блокировка");
} }
if (isOperationalCoreService(service) && (value === "admin" || value === "owner")) {
throw new Error("Operational Core admin-роль назначается только системно");
}
const directGrant = data.grants.find( const directGrant = data.grants.find(
(grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId (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.serviceModuleEntitlements = data.serviceModuleEntitlements.map(normalizeServiceModuleEntitlement).filter(Boolean);
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean); data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean); data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean);
applyProtectedLauncherUserInvariants(data);
return 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) { function normalizeService(service) {
if (typeof service !== "object" || service === null) return service; if (typeof service !== "object" || service === null) return service;
if (service.id !== "service_nodedc" && service.slug !== "nodedc" && service.authentikApplicationSlug !== "nodedc") 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); return data.services.find(isEngineService);
} }
function isProtectedLauncherUserId(userId) {
return protectedLauncherUserIds.has(userId);
}
function isEngineService(candidate) { function isEngineService(candidate) {
return Boolean( return Boolean(
candidate && 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) { function hasTaskManagerDenyException(data, userId) {
const service = data.services.find((candidate) => candidate.slug === "task-manager"); const service = data.services.find((candidate) => candidate.slug === "task-manager");
if (!service) { if (!service) {

View File

@ -1162,7 +1162,17 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
return; 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/", { const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
method: "POST", method: "POST",
body: { body: {
@ -1315,6 +1325,15 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
return; 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/", { const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/ensure/", {
method: "POST", method: "POST",
body: { body: {
@ -1522,15 +1541,7 @@ app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRou
return; return;
} }
if (!assertAdminCanManageMembership(req, res, membership, { allowProtectedSelf: true })) { if (!assertAdminCanManageMembership(req, res, membership)) {
return;
}
if (isProtectedSelfManageRequest(req, membership.userId) && req.body?.status && req.body.status !== "active") {
res.status(403).json({
error: "protected_self_status_locked",
message: "Защищённый пользователь может менять себе роли, но не может отключить собственный контур.",
});
return; return;
} }
@ -2041,10 +2052,29 @@ app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLa
app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => { app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); 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 })) { if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId, { allowProtectedSelf: true })) {
return; 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 result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik( const syncResult = await syncUsersToAuthentik(
result.data, result.data,
@ -2072,6 +2102,17 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn
} }
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); 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)) { if (isPublicTaskManagerGuestServiceAssignment(snapshot.data, req.body)) {
res.status(400).json({ res.status(400).json({
ok: false, ok: false,
@ -4128,7 +4169,7 @@ function assertAdminCanManageUser(req, res, userId, options = {}) {
} }
function assertAdminCanManageAccessForUser(req, res, userId) { function assertAdminCanManageAccessForUser(req, res, userId) {
return assertAdminCanManageUser(req, res, userId, { allowProtectedSelf: true }); return assertAdminCanManageUser(req, res, userId);
} }
function assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds = [], nextMemberIds = []) { function assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds = [], nextMemberIds = []) {

View File

@ -1412,6 +1412,14 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" }, { value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
]; ];
const operationalCoreAccessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ 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<NodeDcSelectOption<AccessAssignmentValue>> = [ const engineAccessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен" }, { value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "Гость", description: "Только явно открытые workflow", tone: "green" }, { value: "viewer", label: "Гость", description: "Только явно открытые workflow", tone: "green" },
@ -1424,12 +1432,12 @@ const publicOperationalCoreAccessOptions: Array<NodeDcSelectOption<AccessAssignm
{ value: "unset", label: "—", description: "Не назначен", hidden: true }, { value: "unset", label: "—", description: "Не назначен", hidden: true },
{ value: "viewer", label: "Workspace Guest", description: "Выдаётся только через Tasker", tone: "green", hidden: true }, { value: "viewer", label: "Workspace Guest", description: "Выдаётся только через Tasker", tone: "green", hidden: true },
{ value: "member", label: "Workspace Member", description: "Доступ к приглашённому workspace", tone: "green" }, { value: "member", label: "Workspace Member", description: "Доступ к приглашённому workspace", tone: "green" },
{ value: "admin", label: "Service Admin", description: "Self-service", tone: "green" }, { value: "admin", label: "Service Admin", description: "Системная роль", tone: "green", disabled: true, hidden: true },
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" }, { value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
]; ];
const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [ const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
...accessAssignmentOptions, ...operationalCoreAccessAssignmentOptions,
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, { value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
]; ];
@ -1437,7 +1445,7 @@ const taskManagerProjectRoleOptions: Array<NodeDcSelectOption<OperationalCoreRol
{ value: "unset", label: "—", description: "Не назначен" }, { value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" }, { value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" }, { value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" }, { value: "admin", label: "Админ", description: "Системная роль", tone: "green", disabled: true, hidden: true },
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, { value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
]; ];
@ -3083,7 +3091,6 @@ function AccessSection({
const protectedUser = isProtectedLauncherUser(user.id); const protectedUser = isProtectedLauncherUser(user.id);
const protectedFromActor = isProtectedFromCurrentActor(me, user.id); const protectedFromActor = isProtectedFromCurrentActor(me, user.id);
const protectedSelfManage = canSelfManageProtectedUser(me, user.id);
const inviterMeta = getMembershipInviterMeta(data, membership); const inviterMeta = getMembershipInviterMeta(data, membership);
const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`; const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`;
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]); const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
@ -3110,7 +3117,7 @@ function AccessSection({
<div className="access-grid-cell" role="cell"> <div className="access-grid-cell" role="cell">
<MainRoleControl <MainRoleControl
value={membership.role} value={membership.role}
protectedUser={protectedFromActor} protectedUser={protectedUser || protectedFromActor}
onChange={(role) => onUpdateMembership(membership.id, { role })} onChange={(role) => onUpdateMembership(membership.id, { role })}
/> />
</div> </div>
@ -3129,14 +3136,16 @@ function AccessSection({
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]} pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment} busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment}
publicSelfService={usePublicTaskerAccess} publicSelfService={usePublicTaskerAccess}
readOnly={protectedFromActor} readOnly={protectedUser || protectedFromActor}
onSelectCell={onSelectCell} onSelectCell={onSelectCell}
onSetAccess={(value) => { onSetAccess={(value) => {
if (protectedUser || protectedFromActor) return;
const nextValue = value; const nextValue = value;
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
if (usePublicTaskerAccess) return; if (usePublicTaskerAccess) return;
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedFromActor || (forcedTaskManagerAdmin && !protectedSelfManage)) return; if (!isTaskManagerService || !primaryTaskManagerWorkspace || forcedTaskManagerAdmin) return;
onSetTaskManagerWorkspaceMemberRole({ onSetTaskManagerWorkspaceMemberRole({
clientId: matrix.client.id, clientId: matrix.client.id,
@ -3239,7 +3248,6 @@ function PublicAccessUsersPanel({
if (!membership) return null; if (!membership) return null;
const protectedUser = isProtectedLauncherUser(user.id); const protectedUser = isProtectedLauncherUser(user.id);
const protectedFromActor = isProtectedFromCurrentActor(me, user.id);
const inviterMeta = getMembershipInviterMeta(data, membership); const inviterMeta = getMembershipInviterMeta(data, membership);
const operationalCoreCell = operationalCoreService const operationalCoreCell = operationalCoreService
? matrix.cells.find((cell) => cell.userId === user.id && cell.serviceId === operationalCoreService.id) ?? null ? matrix.cells.find((cell) => cell.userId === user.id && cell.serviceId === operationalCoreService.id) ?? null
@ -3274,7 +3282,7 @@ function PublicAccessUsersPanel({
)} )}
</td> </td>
<td> <td>
{protectedFromActor ? ( {protectedUser ? (
<AdminStaticPill>{membershipRoleLabel(membership.role)}</AdminStaticPill> <AdminStaticPill>{membershipRoleLabel(membership.role)}</AdminStaticPill>
) : ( ) : (
<NodeDcSelect <NodeDcSelect
@ -3289,12 +3297,16 @@ function PublicAccessUsersPanel({
)} )}
</td> </td>
<td> <td>
<AdminStatusDropdown {protectedUser ? (
value={membership.status} <AdminStaticPill>{statusOptionLabel(membershipStatusOptions, membership.status)}</AdminStaticPill>
options={membershipStatusOptions} ) : (
label={`Статус в открытом контуре ${user.name}`} <AdminStatusDropdown
onChange={(status) => onUpdateMembership(membership.id, { status })} value={membership.status}
/> options={membershipStatusOptions}
label={`Статус в открытом контуре ${user.name}`}
onChange={(status) => onUpdateMembership(membership.id, { status })}
/>
)}
</td> </td>
<td> <td>
{protectedUser ? ( {protectedUser ? (
@ -3479,12 +3491,13 @@ function OperationalCoreAccessModal({
}) { }) {
const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id); const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id);
const protectedSelfManage = canSelfManageProtectedUser(me, 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 basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)];
const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell); const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell);
const baseSelectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions; const baseSelectOptions = publicSelfService ? publicOperationalCoreAccessOptions : operationalCoreAccessAssignmentOptions;
const baseSelectValue = publicSelfService ? publicOperationalCoreSelectValue(baseAssignmentValue) : baseAssignmentValue; const baseSelectValue = publicSelfService ? publicOperationalCoreSelectValue(baseAssignmentValue) : baseAssignmentValue;
const basePending = basePendingValue !== undefined; const basePending = basePendingValue !== undefined;
const baseAccessLocked = protectedUser || baseSelectValue === "admin";
return ( return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}> <div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}>
@ -3507,8 +3520,8 @@ function OperationalCoreAccessModal({
: "Глобальная роль пользователя в Operational Core для выбранного клиента."} : "Глобальная роль пользователя в Operational Core для выбранного клиента."}
</small> </small>
</div> </div>
{protectedUser ? ( {baseAccessLocked ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill> <AdminStaticPill>{operationalCoreStaticRoleLabel(baseSelectValue)}</AdminStaticPill>
) : ( ) : (
<NodeDcSelect <NodeDcSelect
className="admin-table-select-wrap" className="admin-table-select-wrap"
@ -3546,7 +3559,7 @@ function OperationalCoreAccessModal({
className={cn("task-module-access-row", enabled && "task-module-access-row--enabled", pending && "task-module-access-row--pending")} className={cn("task-module-access-row", enabled && "task-module-access-row--enabled", pending && "task-module-access-row--pending")}
type="button" type="button"
aria-pressed={enabled} aria-pressed={enabled}
disabled={pending || systemEnabled} disabled={pending || systemEnabled || protectedUser}
onClick={() => onClick={() =>
onSetServiceModuleEntitlement({ onSetServiceModuleEntitlement({
clientId: client.id, clientId: client.id,
@ -3595,8 +3608,8 @@ function OperationalCoreAccessModal({
{workspace.isPrimary ? " · основной workspace" : ""} {workspace.isPrimary ? " · основной workspace" : ""}
</small> </small>
</div> </div>
{protectedUser ? ( {protectedUser || value === "admin" ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill> <AdminStaticPill>{operationalCoreStaticRoleLabel(value)}</AdminStaticPill>
) : ( ) : (
<NodeDcSelect <NodeDcSelect
className="admin-table-select-wrap" className="admin-table-select-wrap"
@ -3608,6 +3621,7 @@ function OperationalCoreAccessModal({
disabled={pending} disabled={pending}
onChange={(nextValue) => { onChange={(nextValue) => {
if (nextValue === "pending") return; if (nextValue === "pending") return;
if (nextValue === "admin") return;
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
onSetTaskManagerWorkspaceMemberRole({ onSetTaskManagerWorkspaceMemberRole({
clientId: client.id, clientId: client.id,
@ -3639,8 +3653,8 @@ function OperationalCoreAccessModal({
{project.memberCount ? ` · ${project.memberCount} участников` : ""} {project.memberCount ? ` · ${project.memberCount} участников` : ""}
</small> </small>
</div> </div>
{protectedUser ? ( {protectedUser || projectValue === "admin" ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill> <AdminStaticPill>{operationalCoreStaticRoleLabel(projectValue)}</AdminStaticPill>
) : ( ) : (
<NodeDcSelect <NodeDcSelect
className="admin-table-select-wrap" className="admin-table-select-wrap"
@ -3652,6 +3666,7 @@ function OperationalCoreAccessModal({
disabled={projectPending} disabled={projectPending}
onChange={(nextValue) => { onChange={(nextValue) => {
if (nextValue === "pending") return; if (nextValue === "pending") return;
if (nextValue === "admin") return;
const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue); const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue);
if (nextTaskManagerRole !== "unset") { if (nextTaskManagerRole !== "unset") {
@ -3735,6 +3750,8 @@ function AccessCellControl({
? publicOperationalCoreAccessOptions ? publicOperationalCoreAccessOptions
: isEngineServiceForAccessControl(service) : isEngineServiceForAccessControl(service)
? engineAccessAssignmentOptions ? engineAccessAssignmentOptions
: isOperationalCoreService(service)
? operationalCoreAccessAssignmentOptions
: accessAssignmentOptions; : accessAssignmentOptions;
const displayTitle = isPending const displayTitle = isPending
? publicSelfService ? publicSelfService
@ -5139,6 +5156,11 @@ function taskManagerRoleToAccessAssignment(role: TaskManagerWorkspaceMemberRole)
return "unset"; return "unset";
} }
function operationalCoreStaticRoleLabel(value: OperationalCoreRoleSelectValue): string {
if (value === "pending") return "Сохраняем...";
return accessAssignmentLabel(value);
}
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string { function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
if (!source) return "—"; if (!source) return "—";
const labels = { const labels = {