From eb1add4c9a9bf8a3ef95847dd49d377753938e3e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 20 Jun 2026 12:54:48 +0300 Subject: [PATCH] feat(launcher): add core assistant access controls --- server/control-plane-store.mjs | 39 +- server/dev-server.mjs | 538 +++++++++++++++++- .../assistantAdminRoutes.contract.test.ts | 88 +++ src/entities/user/types.ts | 2 + src/shared/api/mockApi.ts | 20 +- src/shared/api/mockData.ts | 3 + src/styles/globals.css | 5 + src/widgets/admin-overlay/AdminOverlay.tsx | 71 ++- 8 files changed, 742 insertions(+), 24 deletions(-) create mode 100644 src/entities/user/assistantAdminRoutes.contract.test.ts diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 092ff84..8e243c1 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -27,6 +27,7 @@ const clientTypes = new Set(["company", "person"]); const clientStatuses = new Set(["active", "suspended", "demo", "expired"]); const userStatuses = new Set(["invited", "active", "blocked"]); const membershipRoles = new Set(["client_owner", "client_admin", "member"]); +const coreAssistantRoles = new Set(["blocked", "member", "admin"]); const grantTargetTypes = new Set(["client", "group", "user"]); const appRoles = new Set(["viewer", "member", "admin", "owner"]); const grantStatuses = new Set(["active", "disabled"]); @@ -520,6 +521,7 @@ export function createControlPlaneStore({ projectRoot }) { userId: user.id, role: pickEnum(payload?.role, membershipRoles, "member"), status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"), + coreAssistantRole: pickEnum(payload?.coreAssistantRole, coreAssistantRoles, "member"), invitedByUserId: actor.id, inviteId: null, source: "launcher", @@ -572,9 +574,11 @@ export function createControlPlaneStore({ projectRoot }) { membership.role = "client_owner"; membership.status = "active"; + membership.coreAssistantRole = "admin"; } else { membership.role = pickEnum(payload?.role, membershipRoles, membership.role); membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status); + membership.coreAssistantRole = pickEnum(payload?.coreAssistantRole, coreAssistantRoles, membership.coreAssistantRole ?? "member"); } membership.updatedAt = isoNow(); @@ -584,7 +588,7 @@ export function createControlPlaneStore({ projectRoot }) { objectName: user.email, clientId: membership.clientId, result: "success", - details: `Role: ${membership.role}; status: ${membership.status}`, + details: `Role: ${membership.role}; status: ${membership.status}; coreAssistantRole: ${membership.coreAssistantRole}`, }); markPendingSync(data, user, "user"); @@ -1595,6 +1599,7 @@ export function createControlPlaneStore({ projectRoot }) { if (membership) { membership.role = invite.role; membership.status = "active"; + membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole); membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null; membership.inviteId = invite.id; membership.source = invite.source ?? membership.source ?? "launcher"; @@ -1607,6 +1612,7 @@ export function createControlPlaneStore({ projectRoot }) { userId: user.id, role: invite.role, status: "active", + coreAssistantRole: "member", invitedByUserId: invite.invitedByUserId ?? null, inviteId: invite.id, source: invite.source ?? "launcher", @@ -2316,6 +2322,7 @@ function normalizeData(payload) { ...client, integrations: normalizeClientIntegrations(client.integrations), })); + data.memberships = data.memberships.map(normalizeMembership).filter(Boolean); data.services = data.services.map(normalizeService); data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean); data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean); @@ -2418,9 +2425,10 @@ function applyProtectedLauncherUserInvariants(data) { for (const membership of data.memberships) { if (membership.userId !== rootUser.id) continue; - if (membership.role !== "client_owner" || membership.status !== "active") { + if (membership.role !== "client_owner" || membership.status !== "active" || membership.coreAssistantRole !== "admin") { membership.role = "client_owner"; membership.status = "active"; + membership.coreAssistantRole = "admin"; membership.updatedAt = now; } } @@ -2502,6 +2510,26 @@ function normalizeService(service) { }; } +function normalizeMembership(payload) { + if (typeof payload !== "object" || payload === null) return null; + const now = isoNow(); + const userId = optionalString(payload.userId, ""); + const fallbackAssistantRole = isProtectedLauncherUserId(userId) ? "admin" : "member"; + + return { + ...payload, + role: pickEnum(payload.role, membershipRoles, "member"), + status: pickEnum(payload.status, new Set(["active", "disabled"]), "active"), + coreAssistantRole: normalizeCoreAssistantRole(payload.coreAssistantRole, fallbackAssistantRole), + createdAt: optionalString(payload.createdAt, now), + updatedAt: optionalString(payload.updatedAt, now), + }; +} + +function normalizeCoreAssistantRole(value, fallback = "member") { + return pickEnum(value, coreAssistantRoles, fallback); +} + function normalizeServiceModuleEntitlement(payload) { if (typeof payload !== "object" || payload === null) return null; const clientId = nullableString(payload.clientId); @@ -3117,6 +3145,7 @@ function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, no membership.status = "active"; if (!wasActive) { membership.role = "member"; + membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole); membership.source = "engine_workflow_access_request"; membership.sourceEngineWorkflowAccessRequestId = request.id; } else { @@ -3135,6 +3164,7 @@ function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, no userId: user.id, role: "member", status: "active", + coreAssistantRole: "member", invitedByUserId, inviteId: null, source: "engine_workflow_access_request", @@ -3158,6 +3188,7 @@ function ensurePublicPoolMembershipActiveForServiceAccess(data, user, actor, now membership.role = "member"; membership.status = "active"; + membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole); membership.invitedByUserId = membership.invitedByUserId ?? actor?.id ?? null; membership.source = membership.source ?? "launcher"; membership.updatedAt = now; @@ -3328,6 +3359,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning = if (membership) { membership.role = invite.role; membership.status = "active"; + membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole); membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null; membership.inviteId = invite.id; membership.source = invite.source ?? membership.source ?? "launcher"; @@ -3340,6 +3372,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning = userId: user.id, role: invite.role, status: "active", + coreAssistantRole: "member", invitedByUserId: invite.invitedByUserId ?? null, inviteId: invite.id, source: invite.source ?? "launcher", @@ -3498,6 +3531,7 @@ function upsertAccessRequestMembership(data, user, requestPayload, options = {}) if (existingMembership) { existingMembership.role = role; existingMembership.status = options.status ?? existingMembership.status; + existingMembership.coreAssistantRole = normalizeCoreAssistantRole(existingMembership.coreAssistantRole); existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null; existingMembership.source = existingMembership.source ?? "access_request"; existingMembership.updatedAt = now; @@ -3510,6 +3544,7 @@ function upsertAccessRequestMembership(data, user, requestPayload, options = {}) userId: user.id, role, status: options.status ?? "disabled", + coreAssistantRole: "member", invitedByUserId: options.invitedByUserId ?? null, inviteId: null, source: "access_request", diff --git a/server/dev-server.mjs b/server/dev-server.mjs index e6ec41d..b61b134 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -39,9 +39,17 @@ const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.no const pendingLogins = new Map(); const serviceHandoffs = new Map(); const sessions = new Map(); +const adminMutationIdempotencyRecords = new Map(); const runtimeEventClients = new Set(); let discoveryCache = null; let jwksCache = null; +const adminMutationIdempotencyTtlMs = 10 * 60 * 1000; +const assistantGatewayName = "ontology-core"; +const assistantGatewayAdminRouteAllowlist = [ + { method: "GET", pattern: /^\/api\/admin\/control-plane$/ }, + { method: "PATCH", pattern: /^\/api\/admin\/users\/[^/?#]+\/profile$/ }, + { method: "PATCH", pattern: /^\/api\/admin\/memberships\/[^/?#]+$/ }, +]; app.disable("x-powered-by"); app.use((req, res, next) => { @@ -512,6 +520,23 @@ app.get("/api/profile", requireSession, (req, res) => { }); }); +app.post("/api/assistant/actions", requireSession, asyncRoute(async (req, res) => { + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const runtimeContext = getRuntimeSessionContext(req.nodedcSession); + const command = buildAssistantActionProxyCommand(req.body, { + actor, + data, + groups: runtimeContext.groups, + }); + const payload = await requestAiWorkspaceAssistantJson("/api/ai-workspace/assistant/v1/actions", { + body: command, + actor, + groups: runtimeContext.groups, + role: command.input.launcherGlobalRole, + }); + res.json(payload); +})); + app.get("/api/events", requireSession, (req, res) => { const client = { id: randomUUID(), @@ -1501,22 +1526,24 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a return; } - const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); - const beforeUser = beforeSnapshot.data.users.find((candidate) => candidate.id === req.params.userId) ?? null; - 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); - const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId); - const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser); - const taskManagerCleanup = - beforeUser?.globalStatus === "active" && updatedUser?.globalStatus === "blocked" - ? await cleanupTaskManagerUserAccess(updatedUser, { - source: "launcher-user-blocked", - revokeIdentityLinks: false, - revokeTaskerAccess: true, - }) - : null; - publishControlPlaneEvent("admin.user.updated", syncResult.userIds); - res.json({ ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile, taskManagerCleanup }); + await sendAdminIdempotentJson(req, res, async () => { + const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const beforeUser = beforeSnapshot.data.users.find((candidate) => candidate.id === req.params.userId) ?? null; + 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); + const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId); + const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser); + const taskManagerCleanup = + beforeUser?.globalStatus === "active" && updatedUser?.globalStatus === "blocked" + ? await cleanupTaskManagerUserAccess(updatedUser, { + source: "launcher-user-blocked", + revokeIdentityLinks: false, + revokeTaskerAccess: true, + }) + : null; + publishControlPlaneEvent("admin.user.updated", syncResult.userIds); + return { ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile, taskManagerCleanup }; + }); })); app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { @@ -1575,10 +1602,12 @@ app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRou 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(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); + await sendAdminIdempotentJson(req, res, async () => { + 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); + return scopeAdminMutationResult(req, { ...result, data: syncResult.data }); + }); })); app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => { @@ -3114,6 +3143,199 @@ function getEngineLaunchTargetUrl(service) { return String(service?.launchTargetUrl || getEnginePublicBaseUrl()).replace(/\/$/, ""); } +function buildAssistantActionProxyCommand(body, context) { + const source = isPlainRecord(body) ? body : {}; + const sourceInput = isPlainRecord(source.input) ? source.input : source; + const input = stripClientAssistantContext(sourceInput); + const ownerContext = resolveAssistantActionOwnerContext(context, input); + const phase = normalizeAssistantActionPhase(source.phase || source.mode); + const confirmationToken = normalizeOptionalText(source.confirmationToken || sourceInput.confirmationToken || source.confirmation?.token); + + return { + phase, + input: { + ...input, + ...ownerContext, + }, + ...(confirmationToken ? { confirmationToken } : {}), + }; +} + +function stripClientAssistantContext(input) { + const result = {}; + const blockedKeys = new Set([ + "actorUserId", + "actorEmail", + "actorSubject", + "groups", + "launcherGlobalRole", + "launcherUserStatus", + "globalStatus", + "membershipRole", + "membershipStatus", + "assistantRole", + "coreAssistantRole", + "isRoot", + "isSuperAdmin", + "removesOwnAccess", + "hasAlternativeAdminPath", + ]); + + for (const [key, value] of Object.entries(isPlainRecord(input) ? input : {})) { + if (blockedKeys.has(key)) continue; + result[key] = value; + } + + return result; +} + +function resolveAssistantActionOwnerContext({ actor, data, groups }, input) { + const groupSet = new Set(groups || []); + const isRoot = groupSet.has("nodedc:superadmin") || groupSet.has("nodedc:launcher:admin"); + const actorMembership = isRoot ? null : resolveAssistantActionActorMembership(data, actor, input); + const assistantRole = isRoot ? "admin" : actorMembership?.coreAssistantRole || "member"; + const membershipRole = isRoot ? "client_owner" : actorMembership?.role || "member"; + const membershipStatus = isRoot ? "active" : actorMembership?.status || "disabled"; + const selfTarget = isAssistantActionSelfTarget(data, actor, input); + const removesOwnAccess = selfTarget && assistantActionRemovesOwnAccess(input); + + return { + assistantRole, + launcherGlobalRole: isRoot ? "root_admin" : membershipRole, + launcherUserStatus: actor.globalStatus || "active", + membershipRole, + membershipStatus, + removesOwnAccess, + hasAlternativeAdminPath: removesOwnAccess ? hasAlternativeAssistantAdminPath(data, actor, groups, input) : false, + }; +} + +function resolveAssistantActionActorMembership(data, actor, input) { + const activeMemberships = data.memberships.filter((membership) => membership.userId === actor.id && membership.status === "active"); + const targetClientId = resolveAssistantActionTargetClientId(data, input); + + if (targetClientId) { + const exact = activeMemberships.find((membership) => membership.clientId === targetClientId); + if (exact) return exact; + } + + return activeMemberships + .slice() + .sort((left, right) => assistantMembershipWeight(right) - assistantMembershipWeight(left))[0] || null; +} + +function resolveAssistantActionTargetClientId(data, input) { + const explicitClientId = normalizeOptionalText(input.clientId); + if (explicitClientId) return explicitClientId; + + const targetMembershipId = normalizeOptionalText(input.membershipId); + if (targetMembershipId) { + return data.memberships.find((membership) => membership.id === targetMembershipId)?.clientId || null; + } + + const targetUserId = normalizeOptionalText(input.targetUserId); + if (targetUserId) { + return data.memberships.find((membership) => membership.userId === targetUserId && membership.status === "active")?.clientId || null; + } + + return null; +} + +function isAssistantActionSelfTarget(data, actor, input) { + const targetUserId = normalizeOptionalText(input.targetUserId); + if (targetUserId && targetUserId === actor.id) return true; + + const targetMembershipId = normalizeOptionalText(input.membershipId); + if (!targetMembershipId) return false; + + return data.memberships.some((membership) => membership.id === targetMembershipId && membership.userId === actor.id); +} + +function assistantActionRemovesOwnAccess(input) { + const actionId = normalizeOptionalText(input.actionId); + if (actionId === "hub.user.block" || actionId === "hub.membership.disable") return true; + + if (actionId === "hub.assistant_access.change_role") { + const targetAssistantRole = normalizeOptionalText(input.targetAssistantRole); + return targetAssistantRole === "blocked" || targetAssistantRole === "member"; + } + + if (actionId === "hub.membership.change_role") { + return normalizeOptionalText(input.targetRole) === "member"; + } + + return false; +} + +function hasAlternativeAssistantAdminPath(data, actor, groups, input) { + const groupSet = new Set(groups || []); + if (groupSet.has("nodedc:superadmin") || groupSet.has("nodedc:launcher:admin")) { + return normalizeOptionalText(input.actionId) !== "hub.user.block"; + } + + const targetMembershipId = normalizeOptionalText(input.membershipId); + return data.memberships.some( + (membership) => + membership.userId === actor.id && + membership.id !== targetMembershipId && + membership.status === "active" && + membership.coreAssistantRole === "admin" && + clientAdminMembershipRoles.has(membership.role) + ); +} + +function assistantMembershipWeight(membership) { + const assistantWeight = membership.coreAssistantRole === "admin" ? 100 : membership.coreAssistantRole === "blocked" ? -100 : 0; + const roleWeight = membership.role === "client_owner" ? 30 : membership.role === "client_admin" ? 20 : 10; + return assistantWeight + roleWeight; +} + +function normalizeAssistantActionPhase(value) { + const phase = String(value || "preview").trim().toLowerCase().replace(/_/g, "-"); + return ["preview", "dry-run", "execute"].includes(phase) ? phase : "preview"; +} + +function getAiWorkspaceAssistantBaseUrl() { + return String( + process.env.NDC_AI_WORKSPACE_ASSISTANT_INTERNAL_URL || + process.env.AI_WORKSPACE_ASSISTANT_INTERNAL_URL || + process.env.NDC_AI_WORKSPACE_ASSISTANT_URL || + process.env.AI_WORKSPACE_ASSISTANT_URL || + "http://localhost:18082" + ).replace(/\/$/, ""); +} + +async function requestAiWorkspaceAssistantJson(pathname, init = {}) { + if (!config.internalAccessToken) { + throw new Error("NODE.DC internal access token is not configured"); + } + + const targetUrl = new URL(pathname, `${getAiWorkspaceAssistantBaseUrl()}/`); + const response = await fetch(targetUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${config.internalAccessToken}`, + "X-NODEDC-User-Id": init.actor?.id || "", + "X-NODEDC-User-Email": init.actor?.email || "", + "X-NODEDC-User-Role": init.role || "", + "X-NODEDC-User-Groups": Array.isArray(init.groups) ? init.groups.join(",") : "", + ...(init.headers ?? {}), + }, + body: JSON.stringify(init.body ?? {}), + }); + const text = await response.text(); + const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {}; + + if (!response.ok) { + const error = typeof payload?.error === "string" ? payload.error : `AI Workspace Assistant API failed: ${response.status}`; + throw new Error(error); + } + + return payload; +} + async function requestTaskManagerInternalJson(pathname, init = {}) { if (!config.internalAccessToken) { throw new Error("NODE.DC internal access token is not configured"); @@ -3238,6 +3460,10 @@ function normalizeOptionalText(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } +function isPlainRecord(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function normalizeTaskManagerRole(value) { return value === "guest" || value === "admin" || value === "member" ? value : null; } @@ -3969,6 +4195,25 @@ function requireLauncherAdmin(req, res, next) { const session = getCurrentSession(req); if (!session) { + const assistantGatewayContext = resolveAssistantGatewayAdminContext(req); + + if (assistantGatewayContext?.ok) { + req.nodedcSession = assistantGatewayContext.session; + req.nodedcAdminScope = assistantGatewayContext.adminScope; + req.nodedcAssistantGateway = assistantGatewayContext.gateway; + next(); + return; + } + + if (assistantGatewayContext) { + res.status(assistantGatewayContext.status).json({ + ok: false, + error: assistantGatewayContext.error, + message: assistantGatewayContext.message, + }); + return; + } + res.status(401).json({ authenticated: false, loginUrl: "/auth/login" }); return; } @@ -4022,6 +4267,120 @@ function requireSession(req, res, next) { next(); } +function resolveAssistantGatewayAdminContext(req) { + const gateway = readRequestHeader(req, "x-nodedc-assistant-gateway"); + const actorUserId = readRequestHeader(req, "x-nodedc-assistant-actor-user-id"); + const actorEmail = readRequestHeader(req, "x-nodedc-assistant-actor-email"); + const actorSubject = readRequestHeader(req, "x-nodedc-assistant-actor-subject"); + const hasGatewaySignal = Boolean(gateway || actorUserId || actorEmail || actorSubject); + + if (!hasGatewaySignal) { + return null; + } + + if (gateway !== assistantGatewayName) { + return { + ok: false, + status: 401, + error: "assistant_gateway_invalid", + message: "Unknown assistant gateway.", + }; + } + + if (!isInternalRequestAuthorized(req)) { + return { + ok: false, + status: config.internalAccessToken ? 401 : 503, + error: config.internalAccessToken ? "assistant_gateway_unauthorized" : "assistant_gateway_not_configured", + message: "Assistant gateway internal token is missing or invalid.", + }; + } + + if (!isAssistantGatewayAdminRoute(req)) { + return { + ok: false, + status: 403, + error: "assistant_gateway_route_not_allowlisted", + message: "Assistant gateway can only call explicitly allowlisted admin routes.", + }; + } + + const snapshot = controlPlaneStore.getSnapshot({ name: "NDC Core Assistant Gateway", source: "assistant-gateway" }); + const actor = findInternalAccessUser(snapshot.data, { + userId: actorUserId, + email: actorEmail, + subject: actorSubject, + }); + + if (!actor) { + return { + ok: false, + status: 404, + error: "assistant_gateway_actor_not_found", + message: "Assistant gateway actor was not found in Launcher control-plane.", + }; + } + + if (actor.globalStatus === "blocked") { + return { + ok: false, + status: 403, + error: "assistant_gateway_actor_blocked", + message: "Assistant gateway actor is blocked.", + }; + } + + const groups = resolveRequiredGroups(snapshot.data, actor); + const user = buildAssistantGatewaySessionUser(actor, groups); + const adminScope = resolveAdminScope(user, groups); + + if (!adminScope.isRoot && adminScope.clientIds.size === 0) { + return { + ok: false, + status: 403, + error: "assistant_gateway_actor_not_admin", + message: "Assistant gateway actor has no Launcher admin scope.", + }; + } + + return { + ok: true, + session: { + id: `assistant-gateway:${actor.id}`, + expiresAt: Date.now() + 60_000, + user, + }, + adminScope, + gateway: { + name: gateway, + actorUserId: actor.id, + }, + }; +} + +function isAssistantGatewayAdminRoute(req) { + const method = String(req.method || "").toUpperCase(); + const path = req.path || String(req.url || "").split("?")[0] || ""; + + return assistantGatewayAdminRouteAllowlist.some((route) => route.method === method && route.pattern.test(path)); +} + +function buildAssistantGatewaySessionUser(actor, groups) { + return { + sub: actor.authentikUserId || `launcher:${actor.id}`, + email: actor.email, + name: actor.name, + avatarUrl: actor.avatarUrl ?? null, + groups, + }; +} + +function readRequestHeader(req, name) { + const raw = req.headers[String(name).toLowerCase()]; + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === "string" ? value.trim() : ""; +} + function isInternalRequestAuthorized(req) { if (!config.internalAccessToken) { return false; @@ -4049,16 +4408,28 @@ function safeTokenEquals(actual, expected) { function findInternalAccessUser(data, payload) { const subject = typeof payload?.subject === "string" ? payload.subject : ""; const email = typeof payload?.email === "string" ? payload.email.toLowerCase() : ""; + const canonicalEmail = canonicalInternalAccessEmail(email); const userId = typeof payload?.userId === "string" ? payload.userId : ""; return ( data.users.find((user) => userId && user.id === userId) ?? data.users.find((user) => subject && user.authentikUserId === subject) ?? data.users.find((user) => email && user.email.toLowerCase() === email) ?? + data.users.find((user) => canonicalEmail && canonicalInternalAccessEmail(user.email) === canonicalEmail) ?? null ); } +function canonicalInternalAccessEmail(value) { + const email = String(value || "").trim().toLowerCase(); + const [local, domain] = email.split("@"); + if (!local || !domain) return email; + if (domain === "gmail.com" || domain === "googlemail.com") { + return `${local.replace(/\./g, "")}@gmail.com`; + } + return email; +} + function sanitizeServiceSlug(value) { return typeof value === "string" && value ? value : "task-manager"; } @@ -4772,6 +5143,133 @@ function ensureTrailingSlash(value) { return value.endsWith("/") ? value : `${value}/`; } +async function sendAdminIdempotentJson(req, res, operation) { + const idempotencyKey = normalizeAdminMutationIdempotencyKey(req); + + if (idempotencyKey === false) { + res.status(400).json({ + ok: false, + error: "idempotency_key_invalid", + message: "Idempotency-Key must be 8-256 visible characters.", + }); + return; + } + + if (!idempotencyKey) { + res.json(await operation()); + return; + } + + pruneAdminMutationIdempotencyRecords(); + + const recordKey = buildAdminMutationIdempotencyRecordKey(req, idempotencyKey); + const fingerprint = buildAdminMutationFingerprint(req); + const existingRecord = adminMutationIdempotencyRecords.get(recordKey); + + if (existingRecord && existingRecord.fingerprint !== fingerprint) { + res.status(409).json({ + ok: false, + error: "idempotency_key_conflict", + message: "Idempotency-Key was already used for a different admin mutation.", + }); + return; + } + + if (existingRecord?.status === "completed") { + res.setHeader("Idempotency-Key", idempotencyKey); + res.setHeader("Idempotency-Replayed", "true"); + res.status(existingRecord.statusCode).json(existingRecord.body); + return; + } + + if (existingRecord?.status === "pending") { + const response = await existingRecord.promise; + res.setHeader("Idempotency-Key", idempotencyKey); + res.setHeader("Idempotency-Replayed", "true"); + res.status(response.statusCode).json(response.body); + return; + } + + const promise = Promise.resolve(operation()).then((body) => ({ statusCode: 200, body })); + adminMutationIdempotencyRecords.set(recordKey, { + status: "pending", + createdAt: Date.now(), + fingerprint, + promise, + }); + + try { + const response = await promise; + adminMutationIdempotencyRecords.set(recordKey, { + status: "completed", + createdAt: Date.now(), + fingerprint, + statusCode: response.statusCode, + body: response.body, + }); + res.setHeader("Idempotency-Key", idempotencyKey); + res.status(response.statusCode).json(response.body); + } catch (error) { + adminMutationIdempotencyRecords.delete(recordKey); + throw error; + } +} + +function normalizeAdminMutationIdempotencyKey(req) { + const rawHeader = req.headers["idempotency-key"]; + const rawValue = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + + if (rawValue === undefined || rawValue === null || rawValue === "") { + return null; + } + + const value = String(rawValue).trim(); + if (!/^[\x21-\x7E]{8,256}$/.test(value)) { + return false; + } + + return value; +} + +function buildAdminMutationIdempotencyRecordKey(req, idempotencyKey) { + const actorId = + req.nodedcAdminScope?.actorId || + req.nodedcSession?.user?.sub || + req.nodedcSession?.user?.email || + "unknown"; + return `${actorId}:${idempotencyKey}`; +} + +function buildAdminMutationFingerprint(req) { + const routePath = req.route?.path || req.path; + const bodyHash = createHash("sha256").update(stableJson(req.body ?? null)).digest("hex"); + return `${req.method}:${routePath}:${req.path}:${bodyHash}`; +} + +function pruneAdminMutationIdempotencyRecords() { + const expiresBefore = Date.now() - adminMutationIdempotencyTtlMs; + for (const [key, record] of adminMutationIdempotencyRecords.entries()) { + if ((record.createdAt ?? 0) < expiresBefore) { + adminMutationIdempotencyRecords.delete(key); + } + } +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + function asyncRoute(handler) { return (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); diff --git a/src/entities/user/assistantAdminRoutes.contract.test.ts b/src/entities/user/assistantAdminRoutes.contract.test.ts new file mode 100644 index 0000000..913fd53 --- /dev/null +++ b/src/entities/user/assistantAdminRoutes.contract.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(resolve(process.cwd(), "server", "dev-server.mjs"), "utf8"); + +function sourceBlock(startMarker: string, endMarker: string) { + const start = source.indexOf(startMarker); + expect(start, `Missing source marker: ${startMarker}`).toBeGreaterThanOrEqual(0); + const end = source.indexOf(endMarker, start + startMarker.length); + expect(end, `Missing end marker after: ${startMarker}`).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("assistant-ready Launcher admin routes", () => { + it("keeps control-plane read behind Launcher admin guard", () => { + expect(source).toContain('app.get("/api/admin/control-plane", requireLauncherAdmin'); + expect(source).toContain("res.json(scopeAdminSnapshot(req));"); + }); + + it("keeps assistant gateway auth narrow and route-allowlisted", () => { + expect(source).toContain('const assistantGatewayName = "ontology-core"'); + expect(source).toContain("const assistantGatewayAdminRouteAllowlist = ["); + expect(source).toContain('{ method: "GET", pattern: /^\\/api\\/admin\\/control-plane$/ }'); + expect(source).toContain('{ method: "PATCH", pattern: /^\\/api\\/admin\\/users\\/[^/?#]+\\/profile$/ }'); + expect(source).toContain('{ method: "PATCH", pattern: /^\\/api\\/admin\\/memberships\\/[^/?#]+$/ }'); + expect(source).toContain("resolveAssistantGatewayAdminContext(req)"); + expect(source).toContain("assistant_gateway_route_not_allowlisted"); + expect(source).toContain('readRequestHeader(req, "x-nodedc-assistant-actor-user-id")'); + }); + + it("routes browser assistant actions through session-owned Launcher proxy context", () => { + const block = sourceBlock('app.post("/api/assistant/actions"', 'app.get("/api/events"'); + const builderBlock = sourceBlock("function buildAssistantActionProxyCommand", "function getAiWorkspaceAssistantBaseUrl"); + + expect(block).toContain("requireSession"); + expect(block).toContain("getLauncherProfileContext(req.nodedcSession)"); + expect(block).toContain("getRuntimeSessionContext(req.nodedcSession)"); + expect(block).toContain("buildAssistantActionProxyCommand"); + expect(block).toContain("requestAiWorkspaceAssistantJson"); + expect(builderBlock).toContain("stripClientAssistantContext"); + expect(builderBlock).toContain('"actorUserId"'); + expect(builderBlock).toContain('"assistantRole"'); + expect(builderBlock).toContain('"groups"'); + expect(builderBlock).toContain('"removesOwnAccess"'); + expect(builderBlock).toContain('"hasAlternativeAdminPath"'); + expect(builderBlock).toContain("resolveAssistantActionOwnerContext"); + expect(builderBlock).toContain("assistantActionRemovesOwnAccess"); + expect(builderBlock).toContain("hasAlternativeAssistantAdminPath"); + }); + + it("keeps user profile mutation guarded, idempotent, audited, and scoped", () => { + const block = sourceBlock('app.patch("/api/admin/users/:userId/profile"', 'app.delete("/api/admin/users/:userId"'); + + expect(block).toContain("requireLauncherAdmin"); + expect(block).toContain("assertAdminCanManageUser(req, res, req.params.userId)"); + expect(block).toContain("sendAdminIdempotentJson(req, res"); + expect(block).toContain("controlPlaneStore.updateUserProfile"); + expect(block).toContain('publishControlPlaneEvent("admin.user.updated"'); + expect(block).toContain("scopeAdminMutationResult"); + }); + + it("keeps membership mutation guarded, idempotent, audited, and scoped", () => { + const block = sourceBlock('app.patch("/api/admin/memberships/:membershipId"', 'app.delete("/api/admin/memberships/:membershipId"'); + + expect(block).toContain("requireLauncherAdmin"); + expect(block).toContain("assertAdminCanManageMembership(req, res, membership)"); + expect(block).toContain("sendAdminIdempotentJson(req, res"); + expect(block).toContain("controlPlaneStore.updateMembership"); + expect(block).toContain('publishControlPlaneEvent("admin.membership.updated"'); + expect(block).toContain("scopeAdminMutationResult"); + }); + + it("keeps hard delete routes outside assistant-ready routes and root-guarded where available", () => { + const userDeleteBlock = sourceBlock('app.delete("/api/admin/users/:userId"', 'app.post("/api/admin/users/:userId/provision-authentik"'); + + expect(userDeleteBlock).toContain("requireRootLauncherAdmin"); + expect(userDeleteBlock).toContain("controlPlaneStore.deleteUser"); + }); + + it("keeps admin mutation idempotency conflict and replay behavior", () => { + expect(source).toContain("sendAdminIdempotentJson"); + expect(source).toContain("idempotency_key_conflict"); + expect(source).toContain('res.setHeader("Idempotency-Replayed", "true")'); + expect(source).toContain("buildAdminMutationFingerprint"); + expect(source).toContain("stableJson(req.body ?? null)"); + }); +}); diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts index 3773043..8088d9e 100644 --- a/src/entities/user/types.ts +++ b/src/entities/user/types.ts @@ -23,6 +23,7 @@ export interface LauncherUser { export type ClientMembershipRole = "client_owner" | "client_admin" | "member"; export type ClientMembershipStatus = "active" | "disabled"; +export type CoreAssistantAccessRole = "blocked" | "member" | "admin"; export interface ClientMembership { id: string; @@ -30,6 +31,7 @@ export interface ClientMembership { userId: string; role: ClientMembershipRole; status: ClientMembershipStatus; + coreAssistantRole?: CoreAssistantAccessRole; invitedByUserId?: string | null; inviteId?: string | null; source?: "launcher" | "access_request" | "tasker_workspace_invite" | "engine_workflow_access_request" | null; diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index dfdde54..3f84dc5 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -50,6 +50,7 @@ export interface MeResponse { clientName: string; role: ClientMembershipRole; status: ClientMembership["status"]; + coreAssistantRole: ClientMembership["coreAssistantRole"]; }>; activeClientId: string; permissions: LauncherPermissions; @@ -214,7 +215,7 @@ export function normalizeLauncherData(data: Partial | null | undef return { clients: Array.isArray(payload.clients) ? payload.clients : mockClients, users: Array.isArray(payload.users) ? payload.users : mockUsers, - memberships: Array.isArray(payload.memberships) ? payload.memberships : mockMemberships, + memberships: Array.isArray(payload.memberships) ? payload.memberships.map(normalizeMembership) : mockMemberships.map(normalizeMembership), groups: Array.isArray(payload.groups) ? payload.groups : mockGroups, services: Array.isArray(payload.services) ? payload.services : mockServices, grants: Array.isArray(payload.grants) ? payload.grants : mockGrants, @@ -259,6 +260,7 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?: clientName: client.name, role: "client_owner" as const, status: "active" as const, + coreAssistantRole: "admin" as const, })) : data.memberships .filter((membership) => membership.userId === user.id) @@ -267,6 +269,7 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?: clientName: getClient(data, membership.clientId).name, role: membership.role, status: membership.status, + coreAssistantRole: normalizeCoreAssistantRole(membership.coreAssistantRole), })); const fallbackClientId = @@ -304,6 +307,21 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?: }; } +function normalizeMembership(membership: ClientMembership): ClientMembership { + return { + ...membership, + coreAssistantRole: normalizeCoreAssistantRole(membership.coreAssistantRole, membership.userId === "user_root" ? "admin" : "member"), + }; +} + +function normalizeCoreAssistantRole( + value: ClientMembership["coreAssistantRole"] | null | undefined, + fallback: NonNullable = "member" +): NonNullable { + if (value === "blocked" || value === "member" || value === "admin") return value; + return fallback; +} + export function buildLauncherServices(data: LauncherData, userId: string, activeClientId: string): LauncherServiceView[] { const me = buildMe(data, userId, activeClientId); const user = getUser(data, userId); diff --git a/src/shared/api/mockData.ts b/src/shared/api/mockData.ts index 701e3a8..8449fe4 100644 --- a/src/shared/api/mockData.ts +++ b/src/shared/api/mockData.ts @@ -297,12 +297,15 @@ function membership( role: ClientMembership["role"], status: ClientMembership["status"] = "active" ): ClientMembership { + const coreAssistantRole: ClientMembership["coreAssistantRole"] = userId === "user_root" ? "admin" : "member"; + return { id, clientId, userId, role, status, + coreAssistantRole, createdAt: "2026-04-01T10:00:00Z", updatedAt: now, }; diff --git a/src/styles/globals.css b/src/styles/globals.css index 906858d..1084e50 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -3761,6 +3761,11 @@ code { color: #ffd0d0; } +.access-cell--assistant-admin { + background: rgba(199, 166, 255, 0.16); + color: #e6d8ff; +} + .access-cell--active { background: rgba(255, 255, 255, 0.12); box-shadow: none; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index deb913b..4f0d185 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -55,6 +55,7 @@ import type { ClientMembership, ClientMembershipRole, ClientMembershipStatus, + CoreAssistantAccessRole, LauncherUser, LauncherUserStatus, } from "../../entities/user/types"; @@ -1414,6 +1415,12 @@ const membershipRoleOptions: Array> = [ { value: "member", label: "Member", description: "Пользователь" }, ]; +const coreAssistantAccessRoleOptions: Array> = [ + { value: "blocked", label: "Нет доступа", description: "Ассистент скрыт и недоступен", tone: "red" }, + { value: "member", label: "Есть доступ", description: "Личный ассистент в разрешённых контурах", tone: "green" }, + { value: "admin", label: "Админ", description: "Admin-команды в рамках Launcher scope", tone: "violet" }, +]; + const inviteRoleOptions: Array> = [ { value: "member", label: "Member" }, { value: "client_admin", label: "Client Admin" }, @@ -1559,6 +1566,14 @@ function membershipRoleLabel(role: ClientMembershipRole): string { return membershipRoleOptions.find((option) => option.value === role)?.label ?? role; } +function resolveCoreAssistantRole(membership: ClientMembership): CoreAssistantAccessRole { + return membership.coreAssistantRole ?? "member"; +} + +function coreAssistantAccessRoleLabel(role: CoreAssistantAccessRole): string { + return coreAssistantAccessRoleOptions.find((option) => option.value === role)?.label ?? role; +} + function inviteSourceLabel(invite: Invite): string { if (invite.source === "access_request") return "Заявка доступа"; if (invite.source === "tasker_workspace_invite") return "Workspace-инвайт"; @@ -3149,7 +3164,7 @@ function AccessSection({ ); } - const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`; + const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) 12.6rem repeat(${matrix.services.length}, 11.25rem)`; return (
@@ -3169,6 +3184,9 @@ function AccessSection({
MAIN ROLE
+
+ NDC Core Assistant +
{matrix.services.map((service) => (
{service.title} @@ -3211,6 +3229,13 @@ function AccessSection({ onChange={(role) => onUpdateMembership(membership.id, { role })} />
+
+ onUpdateMembership(membership.id, { coreAssistantRole })} + /> +
{matrix.services.map((service) => { const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!; const active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id; @@ -3540,6 +3565,50 @@ function MainRoleControl({ ); } +function CoreAssistantAccessControl({ + value, + protectedUser, + onChange, +}: { + value: CoreAssistantAccessRole; + protectedUser: boolean; + onChange: (value: CoreAssistantAccessRole) => void; +}) { + const label = coreAssistantAccessRoleLabel(value); + const toneClass = + value === "blocked" + ? "access-cell--exception" + : value === "admin" + ? "access-cell--assistant-admin" + : "access-cell--allowed"; + + if (protectedUser) { + return ( + + {label} + Assistant + + ); + } + + return ( + ( + + )} + /> + ); +} + function OperationalCoreAccessModal({ data, me,