АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: 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 grantStatuses = new Set(["active", "disabled"]);
const exceptionTypes = new Set(["deny", "allow"]); const exceptionTypes = new Set(["deny", "allow"]);
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]);
const defaultSettings = { const defaultSettings = {
brand: { brand: {
logoLinkUrl: "/", logoLinkUrl: "/",
@ -177,6 +178,7 @@ export function createControlPlaneStore({ projectRoot }) {
workspaceSlug: workspace.slug ?? payload?.workspaceSlug, workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
workspaceName: workspace.name ?? payload?.workspaceName ?? null, workspaceName: workspace.name ?? payload?.workspaceName ?? null,
role: normalizeTaskManagerMembershipRole(payload?.role), role: normalizeTaskManagerMembershipRole(payload?.role),
managedBy: payload?.managedBy,
planeUserId: membership.member?.id ?? null, planeUserId: membership.member?.id ?? null,
planeRole: typeof membership.role === "number" ? membership.role : null, planeRole: typeof membership.role === "number" ? membership.role : null,
}); });
@ -234,15 +236,16 @@ export function createControlPlaneStore({ projectRoot }) {
upsertTaskManagerProjectMembership(data, { upsertTaskManagerProjectMembership(data, {
clientId: client.id, clientId: client.id,
userId: user.id, userId: user.id,
workspaceSlug: workspace.slug ?? payload?.workspaceSlug, workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
workspaceName: workspace.name ?? payload?.workspaceName ?? null, workspaceName: workspace.name ?? payload?.workspaceName ?? null,
projectId: project.id ?? payload?.projectId, projectId: project.id ?? payload?.projectId,
projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null, projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null,
projectName: project.name ?? payload?.projectName ?? null, projectName: project.name ?? payload?.projectName ?? null,
role: normalizeTaskManagerMembershipRole(payload?.role), role: normalizeTaskManagerMembershipRole(payload?.role),
planeUserId: membership.member?.id ?? null, managedBy: payload?.managedBy,
planeRole: typeof membership.role === "number" ? membership.role : null, planeUserId: membership.member?.id ?? null,
}); planeRole: typeof membership.role === "number" ? membership.role : null,
});
addAuditEvent(data, actor, { addAuditEvent(data, actor, {
action: "Назначен Tasker project", action: "Назначен Tasker project",
@ -1252,11 +1255,12 @@ function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) {
slug, slug,
name: nullableStringWithFallback(item.name, null), name: nullableStringWithFallback(item.name, null),
isPrimary: item.isPrimary === true, isPrimary: item.isPrimary === true,
managedBy: normalizeTaskManagerWorkspaceManagedBy(item.managedBy),
}); });
} }
if (legacySlug && !bySlug.has(legacySlug)) { 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()]; const workspaces = [...bySlug.values()];
@ -1274,10 +1278,15 @@ function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) {
slug: workspace.slug, slug: workspace.slug,
name: workspace.name ?? null, name: workspace.name ?? null,
isPrimary, isPrimary,
managedBy: normalizeTaskManagerWorkspaceManagedBy(workspace.managedBy),
}; };
}); });
} }
function normalizeTaskManagerWorkspaceManagedBy(value) {
return taskManagerWorkspaceManagedByValues.has(value) ? value : "launcher";
}
function normalizeTaskManagerMembershipRole(value) { function normalizeTaskManagerMembershipRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : "member"; return value === "guest" || value === "admin" || value === "member" ? value : "member";
} }
@ -1294,6 +1303,7 @@ function upsertTaskManagerMembership(data, payload) {
workspaceSlug, workspaceSlug,
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null), workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null),
role: normalizeTaskManagerMembershipRole(payload.role), role: normalizeTaskManagerMembershipRole(payload.role),
managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy),
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null), planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null), planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
updatedAt: isoNow(), updatedAt: isoNow(),
@ -1330,6 +1340,7 @@ function upsertTaskManagerProjectMembership(data, payload) {
projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null), projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null),
projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null), projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null),
role: normalizeTaskManagerMembershipRole(payload.role), role: normalizeTaskManagerMembershipRole(payload.role),
managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy),
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null), planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null), planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
updatedAt: isoNow(), 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 app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
const allowed = Boolean(app?.hasAccess); const allowed = Boolean(app?.hasAccess);
const workspacePolicy = const workspacePolicy =
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed) : null; serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user) : null;
res.json({ res.json({
ok: true, 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 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 workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
if (!workspaceSlug) { if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); 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), avatarUrl: resolveUserAvatarPublicUrl(user),
role, role,
companyRole: membership?.role ?? null, companyRole: membership?.role ?? null,
managedBy: workspaceManagedBy,
setLastWorkspace: req.body?.setLastWorkspace !== false, setLastWorkspace: req.body?.setLastWorkspace !== false,
}, },
}); });
@ -595,6 +597,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
userId: user.id, userId: user.id,
workspaceSlug, workspaceSlug,
role, role,
managedBy: workspaceManagedBy,
taskManager, taskManager,
}, },
req.nodedcSession.user req.nodedcSession.user
@ -641,17 +644,19 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
email: user.email, email: user.email,
subject: user.authentikUserId ?? undefined, subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user), avatarUrl: resolveUserAvatarPublicUrl(user),
role: "admin", role: "admin",
companyRole: membership.role, companyRole: membership.role,
setLastWorkspace: false, managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
}, setLastWorkspace: false,
}); },
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership( const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{ {
clientId: client.id, clientId: client.id,
userId: user.id, userId: user.id,
workspaceSlug, workspaceSlug,
role: "admin", role: "admin",
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
taskManager, taskManager,
}, },
req.nodedcSession.user req.nodedcSession.user
@ -707,6 +712,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug); const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
const projectId = normalizeOptionalText(req.body?.projectId); const projectId = normalizeOptionalText(req.body?.projectId);
const role = normalizeTaskManagerRole(req.body?.role); const role = normalizeTaskManagerRole(req.body?.role);
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
if (!workspaceSlug) { if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); 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, subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user), avatarUrl: resolveUserAvatarPublicUrl(user),
role, role,
managedBy: workspaceManagedBy,
setLastWorkspace: false, setLastWorkspace: false,
}, },
}); });
@ -743,6 +750,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd
workspaceSlug, workspaceSlug,
projectId, projectId,
role, role,
managedBy: workspaceManagedBy,
taskManager, taskManager,
}, },
req.nodedcSession.user req.nodedcSession.user
@ -1632,6 +1640,72 @@ function resolveTaskManagerRoleForMembership(role) {
return role === "client_owner" || role === "client_admin" ? "admin" : "member"; 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) { function createServiceHandoff(serviceSlug, user) {
pruneExpiredServiceHandoffs(); 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 mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
const groupSet = new Set(groups); const groupSet = new Set(groups);
const isSuperAdmin = groupSet.has("nodedc:superadmin"); const isSuperAdmin = groupSet.has("nodedc:superadmin");
const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin"); 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) { if (!hasTaskManagerAccess) {
return { return {
mode, mode,
managedBy: defaultManagedBy,
defaultManagedBy,
workspaces,
canCreateWorkspace: false, canCreateWorkspace: false,
reason: "Нет доступа к Operational Core.", reason: "Нет доступа к Operational Core.",
}; };
@ -1696,14 +1776,31 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
if (mode === "disabled") { if (mode === "disabled") {
return { return {
mode, mode,
managedBy: defaultManagedBy,
defaultManagedBy,
workspaces,
canCreateWorkspace: false, canCreateWorkspace: false,
reason: "Создание рабочих пространств отключено на уровне платформы.", reason: "Создание рабочих пространств отключено на уровне платформы.",
}; };
} }
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
return {
mode,
managedBy: "launcher",
defaultManagedBy: "launcher",
workspaces,
canCreateWorkspace: false,
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
};
}
if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) { if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) {
return { return {
mode, mode,
managedBy: defaultManagedBy,
defaultManagedBy,
workspaces,
canCreateWorkspace: false, canCreateWorkspace: false,
reason: "Создание рабочих пространств доступно только администраторам Operational Core.", reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
}; };
@ -1711,6 +1808,9 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
return { return {
mode, mode,
managedBy: "tasker",
defaultManagedBy: "tasker",
workspaces,
canCreateWorkspace: true, canCreateWorkspace: true,
reason: "Создание рабочих пространств разрешено платформенной policy.", reason: "Создание рабочих пространств разрешено платформенной policy.",
}; };

View File

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

View File

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

View File

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

View File

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