fix: lock protected launcher access roles
This commit is contained in:
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+52
-11
@@ -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 = []) {
|
||||
|
||||
Reference in New Issue
Block a user