feat(launcher): add core assistant access controls
This commit is contained in:
parent
4584c227ff
commit
eb1add4c9a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<LauncherData> | 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<ClientMembership["coreAssistantRole"]> = "member"
|
||||
): NonNullable<ClientMembership["coreAssistantRole"]> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import type {
|
|||
ClientMembership,
|
||||
ClientMembershipRole,
|
||||
ClientMembershipStatus,
|
||||
CoreAssistantAccessRole,
|
||||
LauncherUser,
|
||||
LauncherUserStatus,
|
||||
} from "../../entities/user/types";
|
||||
|
|
@ -1414,6 +1415,12 @@ const membershipRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
|
|||
{ value: "member", label: "Member", description: "Пользователь" },
|
||||
];
|
||||
|
||||
const coreAssistantAccessRoleOptions: Array<NodeDcSelectOption<CoreAssistantAccessRole>> = [
|
||||
{ value: "blocked", label: "Нет доступа", description: "Ассистент скрыт и недоступен", tone: "red" },
|
||||
{ value: "member", label: "Есть доступ", description: "Личный ассистент в разрешённых контурах", tone: "green" },
|
||||
{ value: "admin", label: "Админ", description: "Admin-команды в рамках Launcher scope", tone: "violet" },
|
||||
];
|
||||
|
||||
const inviteRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
|
||||
{ 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 (
|
||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||
|
|
@ -3169,6 +3184,9 @@ function AccessSection({
|
|||
<div className="access-grid-head" role="columnheader">
|
||||
MAIN ROLE
|
||||
</div>
|
||||
<div className="access-grid-head" role="columnheader">
|
||||
NDC Core Assistant
|
||||
</div>
|
||||
{matrix.services.map((service) => (
|
||||
<div key={service.id} className="access-grid-head" role="columnheader">
|
||||
{service.title}
|
||||
|
|
@ -3211,6 +3229,13 @@ function AccessSection({
|
|||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||
/>
|
||||
</div>
|
||||
<div className="access-grid-cell" role="cell">
|
||||
<CoreAssistantAccessControl
|
||||
value={resolveCoreAssistantRole(membership)}
|
||||
protectedUser={protectedUser || protectedFromActor}
|
||||
onChange={(coreAssistantRole) => onUpdateMembership(membership.id, { coreAssistantRole })}
|
||||
/>
|
||||
</div>
|
||||
{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 (
|
||||
<span className={cn("access-cell access-cell--main access-cell--readonly", toneClass)}>
|
||||
<strong>{label}</strong>
|
||||
<span>Assistant</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeDcSelect
|
||||
value={value}
|
||||
options={coreAssistantAccessRoleOptions}
|
||||
label="NDC Core Assistant"
|
||||
minMenuWidth={244}
|
||||
menuClassName="access-cell-menu"
|
||||
onChange={onChange}
|
||||
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||||
<button ref={setTriggerRef} className={cn("access-cell access-cell--main", toneClass)} type="button" aria-expanded={open} onClick={toggle}>
|
||||
<strong>{selectedOption.label}</strong>
|
||||
<span>Assistant</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationalCoreAccessModal({
|
||||
data,
|
||||
me,
|
||||
|
|
|
|||
Loading…
Reference in New Issue