АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: managedBy policy Launcher

This commit is contained in:
DCCONSTRUCTIONS 2026-05-09 12:49:09 +03:00
parent fd1d5baef3
commit 01e0988031
6 changed files with 140 additions and 20 deletions

View File

@ -27,6 +27,7 @@ const appRoles = new Set(["viewer", "member", "admin", "owner"]);
const grantStatuses = new Set(["active", "disabled"]);
const exceptionTypes = new Set(["deny", "allow"]);
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]);
const defaultSettings = {
brand: {
logoLinkUrl: "/",
@ -177,6 +178,7 @@ export function createControlPlaneStore({ projectRoot }) {
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
role: normalizeTaskManagerMembershipRole(payload?.role),
managedBy: payload?.managedBy,
planeUserId: membership.member?.id ?? null,
planeRole: typeof membership.role === "number" ? membership.role : null,
});
@ -234,15 +236,16 @@ export function createControlPlaneStore({ projectRoot }) {
upsertTaskManagerProjectMembership(data, {
clientId: client.id,
userId: user.id,
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
projectId: project.id ?? payload?.projectId,
projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null,
projectName: project.name ?? payload?.projectName ?? null,
role: normalizeTaskManagerMembershipRole(payload?.role),
planeUserId: membership.member?.id ?? null,
planeRole: typeof membership.role === "number" ? membership.role : null,
});
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
projectId: project.id ?? payload?.projectId,
projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null,
projectName: project.name ?? payload?.projectName ?? null,
role: normalizeTaskManagerMembershipRole(payload?.role),
managedBy: payload?.managedBy,
planeUserId: membership.member?.id ?? null,
planeRole: typeof membership.role === "number" ? membership.role : null,
});
addAuditEvent(data, actor, {
action: "Назначен Tasker project",
@ -1252,11 +1255,12 @@ function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) {
slug,
name: nullableStringWithFallback(item.name, null),
isPrimary: item.isPrimary === true,
managedBy: normalizeTaskManagerWorkspaceManagedBy(item.managedBy),
});
}
if (legacySlug && !bySlug.has(legacySlug)) {
bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true });
bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true, managedBy: "launcher" });
}
const workspaces = [...bySlug.values()];
@ -1274,10 +1278,15 @@ function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) {
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary,
managedBy: normalizeTaskManagerWorkspaceManagedBy(workspace.managedBy),
};
});
}
function normalizeTaskManagerWorkspaceManagedBy(value) {
return taskManagerWorkspaceManagedByValues.has(value) ? value : "launcher";
}
function normalizeTaskManagerMembershipRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : "member";
}
@ -1294,6 +1303,7 @@ function upsertTaskManagerMembership(data, payload) {
workspaceSlug,
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null),
role: normalizeTaskManagerMembershipRole(payload.role),
managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy),
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
updatedAt: isoNow(),
@ -1330,6 +1340,7 @@ function upsertTaskManagerProjectMembership(data, payload) {
projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null),
projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null),
role: normalizeTaskManagerMembershipRole(payload.role),
managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy),
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
updatedAt: isoNow(),

View File

@ -374,7 +374,7 @@ app.post("/api/internal/access/check", (req, res) => {
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
const allowed = Boolean(app?.hasAccess);
const workspacePolicy =
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed) : null;
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user) : null;
res.json({
ok: true,
@ -569,6 +569,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
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;
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
@ -585,6 +586,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
avatarUrl: resolveUserAvatarPublicUrl(user),
role,
companyRole: membership?.role ?? null,
managedBy: workspaceManagedBy,
setLastWorkspace: req.body?.setLastWorkspace !== false,
},
});
@ -595,6 +597,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
userId: user.id,
workspaceSlug,
role,
managedBy: workspaceManagedBy,
taskManager,
},
req.nodedcSession.user
@ -641,17 +644,19 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
email: user.email,
subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user),
role: "admin",
companyRole: membership.role,
setLastWorkspace: false,
},
});
role: "admin",
companyRole: membership.role,
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
setLastWorkspace: false,
},
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
role: "admin",
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
taskManager,
},
req.nodedcSession.user
@ -707,6 +712,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
const projectId = normalizeOptionalText(req.body?.projectId);
const role = normalizeTaskManagerRole(req.body?.role);
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
@ -732,6 +738,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user),
role,
managedBy: workspaceManagedBy,
setLastWorkspace: false,
},
});
@ -743,6 +750,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
workspaceSlug,
projectId,
role,
managedBy: workspaceManagedBy,
taskManager,
},
req.nodedcSession.user
@ -1632,6 +1640,72 @@ function resolveTaskManagerRoleForMembership(role) {
return role === "client_owner" || role === "client_admin" ? "admin" : "member";
}
function normalizeTaskManagerWorkspaceManagedBy(value) {
return value === "tasker" ? "tasker" : "launcher";
}
function getClientTaskManagerWorkspaces(client) {
const taskManager = client?.integrations?.taskManager;
const workspaces = Array.isArray(taskManager?.workspaces) ? taskManager.workspaces : [];
const legacySlug = normalizeOptionalText(taskManager?.workspaceSlug);
if (!legacySlug || workspaces.some((workspace) => normalizeOptionalText(workspace?.slug) === legacySlug)) {
return workspaces;
}
return [
...workspaces,
{
slug: legacySlug,
name: normalizeOptionalText(taskManager?.workspaceName),
isPrimary: true,
managedBy: "launcher",
},
];
}
function resolveTaskManagerWorkspaceBinding(client, workspaceSlug) {
const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug);
if (!normalizedWorkspaceSlug) return null;
return (
getClientTaskManagerWorkspaces(client).find((workspace) => normalizeOptionalText(workspace?.slug) === normalizedWorkspaceSlug) ?? null
);
}
function resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug) {
return normalizeTaskManagerWorkspaceManagedBy(resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy);
}
function resolveTaskManagerWorkspaceAssignments(data, user) {
if (!user?.id) return [];
const bySlug = new Map();
for (const membership of data.taskManagerMemberships ?? []) {
if (membership.userId !== user.id) continue;
const workspaceSlug = normalizeOptionalText(membership.workspaceSlug);
if (!workspaceSlug) continue;
const client = data.clients.find((candidate) => candidate.id === membership.clientId);
const managedBy = normalizeTaskManagerWorkspaceManagedBy(
membership.managedBy ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy
);
const current = bySlug.get(workspaceSlug);
if (current && current.managedBy === "launcher") continue;
bySlug.set(workspaceSlug, {
slug: workspaceSlug,
name: normalizeOptionalText(membership.workspaceName ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.name),
managedBy,
clientId: client?.id ?? membership.clientId ?? null,
clientName: client?.name ?? null,
role: normalizeTaskManagerRole(membership.role) ?? "member",
});
}
return [...bySlug.values()];
}
function createServiceHandoff(serviceSlug, user) {
pruneExpiredServiceHandoffs();
@ -1679,15 +1753,21 @@ function pruneExpiredServiceHandoffs() {
}
}
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) {
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
const groupSet = new Set(groups);
const isSuperAdmin = groupSet.has("nodedc:superadmin");
const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin");
const workspaces = resolveTaskManagerWorkspaceAssignments(data, user);
const hasLauncherManagedWorkspace = workspaces.some((workspace) => workspace.managedBy === "launcher");
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
if (!hasTaskManagerAccess) {
return {
mode,
managedBy: defaultManagedBy,
defaultManagedBy,
workspaces,
canCreateWorkspace: false,
reason: "Нет доступа к Operational Core.",
};
@ -1696,14 +1776,31 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
if (mode === "disabled") {
return {
mode,
managedBy: defaultManagedBy,
defaultManagedBy,
workspaces,
canCreateWorkspace: false,
reason: "Создание рабочих пространств отключено на уровне платформы.",
};
}
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
return {
mode,
managedBy: "launcher",
defaultManagedBy: "launcher",
workspaces,
canCreateWorkspace: false,
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
};
}
if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) {
return {
mode,
managedBy: defaultManagedBy,
defaultManagedBy,
workspaces,
canCreateWorkspace: false,
reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
};
@ -1711,6 +1808,9 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
return {
mode,
managedBy: "tasker",
defaultManagedBy: "tasker",
workspaces,
canCreateWorkspace: true,
reason: "Создание рабочих пространств разрешено платформенной policy.",
};

View File

@ -1,10 +1,12 @@
export type ClientType = "company" | "person";
export type ClientStatus = "active" | "suspended" | "demo" | "expired";
export type TaskManagerWorkspaceManagedBy = "launcher" | "tasker";
export interface ClientTaskManagerWorkspaceBinding {
slug: string;
name?: string | null;
isPrimary?: boolean;
managedBy?: TaskManagerWorkspaceManagedBy;
}
export interface Client {

View File

@ -1,5 +1,5 @@
import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types";
import type { Client } from "../../entities/client/types";
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types";
import type { Service } from "../../entities/service/types";
import type { SyncStatus } from "../../entities/sync/types";
@ -35,6 +35,7 @@ export interface TaskManagerWorkspaceSummary {
id: string;
slug: string;
name: string;
managedBy?: TaskManagerWorkspaceManagedBy;
ownerEmail: string | null;
memberCount: number;
projects?: TaskManagerProjectSummary[];

View File

@ -1,6 +1,6 @@
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
import type { Client } from "../../entities/client/types";
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types";
import { getServiceLaunchLink } from "../../entities/service/links";
import type { LauncherServiceView, Service } from "../../entities/service/types";
@ -72,6 +72,7 @@ export interface TaskManagerMembershipAssignment {
workspaceSlug: string;
workspaceName?: string | null;
role: "guest" | "member" | "admin";
managedBy?: TaskManagerWorkspaceManagedBy;
planeUserId?: string | null;
planeRole?: number | null;
updatedAt: string;
@ -87,6 +88,7 @@ export interface TaskManagerProjectMembershipAssignment {
projectIdentifier?: string | null;
projectName?: string | null;
role: "guest" | "member" | "admin";
managedBy?: TaskManagerWorkspaceManagedBy;
planeUserId?: string | null;
planeRole?: number | null;
updatedAt: string;

View File

@ -989,6 +989,7 @@ function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorksp
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary: workspace.isPrimary === true,
managedBy: workspace.managedBy ?? "launcher",
});
}
@ -997,6 +998,7 @@ function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorksp
slug: taskManager.workspaceSlug,
name: taskManager.workspaceName ?? null,
isPrimary: true,
managedBy: "launcher",
});
}
@ -1055,6 +1057,7 @@ function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerW
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary: workspace.isPrimary === true,
managedBy: workspace.managedBy ?? "launcher",
});
}
@ -1781,6 +1784,7 @@ function ClientEditorModal({
slug: workspace.slug,
name: workspace.name,
isPrimary: currentWorkspaces.length === 0,
managedBy: "launcher",
},
];
@ -1919,7 +1923,7 @@ function ClientEditorModal({
<span>
<strong>{workspace.name}</strong>
<small>
{workspace.slug} · {workspace.memberCount} участников
{workspace.slug} · {workspace.memberCount} участников · Launcher-managed
</small>
</span>
{selected ? (