FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: АДМИНКА ДОСТУПОВ И УЧАСТНИКОВ

This commit is contained in:
DCCONSTRUCTIONS
2026-05-07 22:28:31 +03:00
parent 5f461d57ea
commit 784b3ca5c3
5 changed files with 1042 additions and 241 deletions
+315 -34
View File
@@ -506,21 +506,26 @@ app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, re
}));
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
res.json(scopeAdminSnapshot(req));
});
app.patch("/api/admin/settings", requireLauncherAdmin, 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);
publishControlPlaneEvent("admin.settings.updated");
res.json(result);
}));
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const snapshot = scopeAdminSnapshot(req);
res.json({ clients: snapshot.data.clients });
});
app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (_req, res) => {
app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!req.nodedcAdminScope?.isRoot) {
res.json({ ok: true, workspaces: [] });
return;
}
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
res.json(taskManager);
}));
@@ -542,6 +547,10 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
return;
}
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
return;
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
@@ -576,7 +585,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...result, taskManager });
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
}));
app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncherAdmin, asyncRoute(async (req, res) => {
@@ -596,6 +605,10 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
return;
}
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
return;
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = client.integrations?.taskManager?.workspaceSlug ?? null;
@@ -629,7 +642,7 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...result, taskManager, protected: true });
res.json({ ...scopeAdminMutationResult(req, result), taskManager, protected: true });
return;
}
@@ -651,30 +664,34 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...result, taskManager });
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
}));
app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.post("/api/admin/clients", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
res.status(201).json(result);
}));
app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user);
res.json(result);
}));
app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user);
res.json(result);
}));
app.get("/api/admin/users", requireLauncherAdmin, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const snapshot = scopeAdminSnapshot(req);
res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships });
});
app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
return;
}
const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user);
let provisioning = null;
@@ -691,17 +708,25 @@ app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) =
}
publishControlPlaneEvent("admin.user.created", [result.user.id]);
res.status(201).json({ ...result, provisioning });
res.status(201).json({ ...scopeAdminMutationResult(req, result), provisioning });
}));
app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
return;
}
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);
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
return;
}
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const provisionedUser = await authentikSyncClient.provisionUser({
data: snapshot.data,
@@ -712,51 +737,118 @@ app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, a
const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]);
res.json({ ...result, provisioning: toProvisioningResponse(provisionedUser) });
res.json({ ...scopeAdminMutationResult(req, result), provisioning: toProvisioningResponse(provisionedUser) });
}));
app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId);
if (!membership) {
res.status(404).json({ error: "membership_not_found" });
return;
}
if (!assertAdminCanManageMembership(req, res, membership)) {
return;
}
const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId);
if (!membership) {
res.status(404).json({ error: "membership_not_found" });
return;
}
if (!assertAdminCanManageMembership(req, res, membership)) {
return;
}
const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
return;
}
const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.created");
res.status(201).json(result);
res.status(201).json(scopeAdminMutationResult(req, result));
}));
app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId);
if (!invite) {
res.status(404).json({ error: "invite_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, invite.clientId)) {
return;
}
const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.updated");
res.json(result);
res.json(scopeAdminMutationResult(req, result));
}));
app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId);
if (!invite) {
res.status(404).json({ error: "invite_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, invite.clientId)) {
return;
}
const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.deleted");
res.json(result);
res.json(scopeAdminMutationResult(req, result));
}));
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
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);
res.status(201).json({ ...result, data: syncResult.data });
res.status(201).json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const previousMemberIds = beforeSnapshot.data.groups.find((group) => group.id === req.params.groupId)?.memberIds ?? [];
const group = beforeSnapshot.data.groups.find((candidate) => candidate.id === req.params.groupId);
if (!group) {
res.status(404).json({ error: "group_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, group.clientId)) {
return;
}
const previousMemberIds = group.memberIds;
const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(
result.data,
@@ -764,41 +856,59 @@ app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (
req.nodedcSession.user
);
publishControlPlaneEvent("admin.group.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const group = snapshot.data.groups.find((candidate) => candidate.id === req.params.groupId);
if (!group) {
res.status(404).json({ error: "group_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, group.clientId)) {
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);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/services", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.post("/api/admin/services", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.created");
res.status(201).json(result);
}));
app.patch("/api/admin/services/reorder", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.patch("/api/admin/services/reorder", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.reordered");
res.json(result);
}));
app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.updated");
res.json(result);
}));
app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.deleted");
res.json(result);
}));
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)) {
return;
}
const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(
result.data,
@@ -806,30 +916,38 @@ app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req
req.nodedcSession.user
);
publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.body?.userId)) {
return;
}
const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.body?.userId)) {
return;
}
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
publishControlPlaneEvent("admin.sync.retry");
res.json(result);
}));
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, (_req, res) => {
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => {
res.json(controlPlaneStore.buildAuthentikSyncPlan());
});
@@ -838,7 +956,7 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => {
res.json(result);
}));
app.post("/api/storage/data", requireLauncherAdmin, asyncRoute(async (req, res) => {
app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
await saveLauncherData(req.body);
publishControlPlaneEvent("storage.data.updated");
res.json({ ok: true, url: "/storage/launcher-data.json" });
@@ -1844,13 +1962,24 @@ function requireLauncherAdmin(req, res, next) {
}
const runtimeContext = getRuntimeSessionContext(session);
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
if (!isLauncherAdmin(runtimeContext.groups)) {
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
res.status(403).json({ error: "Недостаточно прав Launcher admin" });
return;
}
req.nodedcSession = { ...session, user: runtimeContext.user };
req.nodedcAdminScope = adminScope;
next();
}
function requireRootLauncherAdmin(req, res, next) {
if (!req.nodedcAdminScope?.isRoot) {
res.status(403).json({ error: "Действие доступно только суперпользователю NODE.DC" });
return;
}
next();
}
@@ -1935,6 +2064,158 @@ function isLauncherAdmin(groups) {
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
}
const clientAdminMembershipRoles = new Set(["client_owner", "client_admin"]);
const protectedLauncherUserIds = new Set(["user_root"]);
function resolveAdminScope(identity, groups) {
const snapshot = controlPlaneStore.getSnapshot(identity);
const actorId = snapshot.actor.source === "launcher" ? snapshot.actor.id : null;
const isRoot = isLauncherAdmin(groups);
const clientIds = new Set(
isRoot
? snapshot.data.clients.map((client) => client.id)
: snapshot.data.memberships
.filter((membership) => membership.userId === actorId && membership.status === "active" && clientAdminMembershipRoles.has(membership.role))
.map((membership) => membership.clientId)
);
return {
actorId,
clientIds,
isRoot,
snapshot,
};
}
function canAdminManageClient(req, clientId) {
return Boolean(req.nodedcAdminScope?.isRoot || req.nodedcAdminScope?.clientIds.has(clientId));
}
function canAdminManageUser(req, userId) {
if (protectedLauncherUserIds.has(userId)) {
return false;
}
if (req.nodedcAdminScope?.isRoot) {
return true;
}
return req.nodedcAdminScope?.snapshot.data.memberships.some(
(membership) => membership.userId === userId && req.nodedcAdminScope.clientIds.has(membership.clientId)
);
}
function assertAdminCanManageClient(req, res, clientId) {
if (canAdminManageClient(req, clientId)) {
return true;
}
res.status(403).json({ error: "Недостаточно прав для управления этим клиентом" });
return false;
}
function assertAdminCanManageUser(req, res, userId) {
if (canAdminManageUser(req, userId)) {
return true;
}
res.status(403).json({ error: "Недостаточно прав для управления этим пользователем" });
return false;
}
function assertAdminCanManageMembership(req, res, membership) {
if (!assertAdminCanManageClient(req, res, membership.clientId)) {
return false;
}
return assertAdminCanManageUser(req, res, membership.userId);
}
function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId) {
if (req.nodedcAdminScope?.isRoot) {
return true;
}
if (targetType === "client") {
return assertAdminCanManageClient(req, res, targetId);
}
if (targetType === "group") {
const group = data.groups.find((candidate) => candidate.id === targetId);
if (!group) {
res.status(404).json({ error: "group_not_found" });
return false;
}
return assertAdminCanManageClient(req, res, group.clientId);
}
if (targetType === "user") {
return assertAdminCanManageUser(req, res, targetId);
}
res.status(403).json({ error: "Недостаточно прав для управления этим доступом" });
return false;
}
function scopeAdminSnapshot(req, snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user)) {
return {
...snapshot,
data: scopeControlPlaneData(snapshot.data, req.nodedcAdminScope),
};
}
function scopeAdminMutationResult(req, result) {
if (!result?.data) {
return result;
}
return {
...result,
data: scopeControlPlaneData(result.data, req.nodedcAdminScope),
};
}
function scopeControlPlaneData(data, scope) {
if (!scope || scope.isRoot) {
return data;
}
const clientIds = scope.clientIds;
const memberships = data.memberships.filter((membership) => clientIds.has(membership.clientId));
const userIds = new Set(memberships.map((membership) => membership.userId));
if (scope.actorId) {
userIds.add(scope.actorId);
}
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
return {
...data,
clients: data.clients.filter((client) => clientIds.has(client.id)),
users: data.users.filter((user) => userIds.has(user.id)),
memberships,
groups: data.groups.filter((group) => clientIds.has(group.clientId)),
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
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 userIds.has(grant.targetId);
return false;
}),
exceptions: data.exceptions.filter((exception) => userIds.has(exception.userId)),
taskManagerMemberships: data.taskManagerMemberships.filter(
(membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId)
),
syncStatuses: data.syncStatuses.filter(
(syncStatus) => clientIds.has(syncStatus.objectId) || userIds.has(syncStatus.objectId) || groupIds.has(syncStatus.objectId)
),
auditEvents: data.auditEvents.filter((event) => !event.clientId || clientIds.has(event.clientId)),
};
}
function cookieOptions(maxAgeMs) {
const options = {
httpOnly: true,