Register Module Foundry service handoff

This commit is contained in:
DCCONSTRUCTIONS 2026-07-23 13:53:56 +03:00
parent 332e097116
commit 24d01fc813
4 changed files with 158 additions and 1 deletions

View File

@ -205,6 +205,14 @@ export function resolveRequiredGroups(data, user) {
groupNames.add(platformGroups.taskManagerAdmin); groupNames.add(platformGroups.taskManagerAdmin);
groupNames.add(platformGroups.taskManagerUser); groupNames.add(platformGroups.taskManagerUser);
addGroups(groupNames, resolveEngineRoleGroups("member")); addGroups(groupNames, resolveEngineRoleGroups("member"));
// The platform super-admin is the default owner of every internally
// registered service. This also keeps the Authentik view consistent with
// the Launcher control-plane grants (including Module Foundry).
for (const service of data.services) {
if (service.authentikGroupName) {
groupNames.add(service.authentikGroupName);
}
}
return [...groupNames]; return [...groupNames];
} }

View File

@ -107,6 +107,40 @@ const bimViewerDefaultGrants = [
updatedAt: "2026-06-18T00:00:00.000Z", updatedAt: "2026-06-18T00:00:00.000Z",
}, },
]; ];
const moduleFoundryService = {
id: "service_module_foundry",
slug: "module-foundry",
title: "MODULE FOUNDRY",
subtitle: "Конфигурация платформенных модулей",
description: "Сборка приложений NODE.DC из утверждённых страниц, визуальных профилей и онтологических сущностей.",
fullDescription:
"Module Foundry — внутренний контур для конфигурации модулей NODE.DC. В первой версии доступ открывается только через Launcher; самостоятельной регистрации, публичного входа и многопользовательского владения проектами нет.",
url: "https://foundry.nodedc.ru",
launchUrl: "https://foundry.nodedc.ru",
logoutUrl: "https://foundry.nodedc.ru/auth/logout",
accentColor: "#F12F92",
fallbackGradient: "linear-gradient(132deg, rgba(241, 47, 146, 0.8), rgba(67, 19, 58, 0.92) 48%, #080B0F 86%)",
status: "active",
order: 26,
authentikApplicationSlug: "module-foundry",
authentikGroupName: "nodedc:module-foundry:access",
authHandoffPath: "/auth/nodedc/handoff",
isAvailableForAllNewClients: false,
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
};
const moduleFoundryDefaultGrants = [
{
id: "grant_module_foundry_dctouch_admins",
serviceId: "service_module_foundry",
targetType: "group",
targetId: "group_dctouch_admins",
appRole: "member",
status: "active",
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
},
];
const defaultSettings = { const defaultSettings = {
brand: { brand: {
logoLinkUrl: "/", logoLinkUrl: "/",
@ -2333,6 +2367,7 @@ function normalizeData(payload) {
data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean); data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean);
applyProtectedLauncherUserInvariants(data); applyProtectedLauncherUserInvariants(data);
applyBimViewerServiceInvariants(data); applyBimViewerServiceInvariants(data);
applyModuleFoundryServiceInvariants(data);
return data; return data;
} }
@ -2396,6 +2431,65 @@ function applyBimViewerServiceInvariants(data) {
} }
} }
function applyModuleFoundryServiceInvariants(data) {
let service = data.services.find(
(candidate) => candidate.id === moduleFoundryService.id || candidate.slug === moduleFoundryService.slug
);
if (!service) {
service = normalizeService({ ...moduleFoundryService });
data.services.push(service);
} else {
service.id = service.id || moduleFoundryService.id;
service.slug = service.slug || moduleFoundryService.slug;
service.title = service.title || moduleFoundryService.title;
service.subtitle = service.subtitle || moduleFoundryService.subtitle;
service.description = service.description || moduleFoundryService.description;
service.fullDescription = service.fullDescription || moduleFoundryService.fullDescription;
service.url = service.url || moduleFoundryService.url;
service.launchUrl = service.launchUrl || service.url || moduleFoundryService.launchUrl;
service.logoutUrl = service.logoutUrl || moduleFoundryService.logoutUrl;
service.accentColor = service.accentColor || moduleFoundryService.accentColor;
service.fallbackGradient = service.fallbackGradient || moduleFoundryService.fallbackGradient;
service.status = service.status || moduleFoundryService.status;
service.order = service.order ?? moduleFoundryService.order;
service.authentikApplicationSlug = service.authentikApplicationSlug || moduleFoundryService.authentikApplicationSlug;
service.authentikGroupName = service.authentikGroupName || moduleFoundryService.authentikGroupName;
service.authHandoffPath = service.authHandoffPath || moduleFoundryService.authHandoffPath;
service.isAvailableForAllNewClients = service.isAvailableForAllNewClients ?? false;
service.createdAt = service.createdAt || moduleFoundryService.createdAt;
service.updatedAt = service.updatedAt || moduleFoundryService.updatedAt;
}
const serviceId = service.id || moduleFoundryService.id;
const defaultGrant = { ...moduleFoundryDefaultGrants[0], serviceId };
const groupExists = data.groups.some((group) => group.id === defaultGrant.targetId);
const grantExists = data.grants.some(
(candidate) =>
candidate.serviceId === defaultGrant.serviceId &&
candidate.targetType === defaultGrant.targetType &&
candidate.targetId === defaultGrant.targetId
);
if (groupExists && !grantExists) {
data.grants.push(defaultGrant);
}
ensureStaticSyncStatus(data, {
id: "sync_module_foundry_authentik",
objectId: serviceId,
objectName: moduleFoundryService.title,
objectType: "service",
});
if (groupExists) {
ensureStaticSyncStatus(data, {
id: `sync_${defaultGrant.id}`,
objectId: defaultGrant.id,
objectName: `${moduleFoundryService.slug}:${defaultGrant.targetType}:${defaultGrant.targetId}`,
objectType: "grant",
});
}
}
function ensureStaticSyncStatus(data, status) { function ensureStaticSyncStatus(data, status) {
const exists = data.syncStatuses.some( const exists = data.syncStatuses.some(
(candidate) => candidate.target === "authentik" && candidate.objectType === status.objectType && candidate.objectId === status.objectId (candidate) => candidate.target === "authentik" && candidate.objectType === status.objectType && candidate.objectId === status.objectId
@ -2491,6 +2585,22 @@ function upsertSystemProtectedUserGrant(data, user, service, appRole, now) {
function normalizeService(service) { function normalizeService(service) {
if (typeof service !== "object" || service === null) return service; if (typeof service !== "object" || service === null) return service;
if (
service.id === "service_module_foundry" ||
service.slug === "module-foundry" ||
service.authentikApplicationSlug === "module-foundry"
) {
return {
...service,
url: service.url || moduleFoundryService.url,
launchUrl: service.launchUrl || service.url || moduleFoundryService.launchUrl,
logoutUrl: service.logoutUrl || moduleFoundryService.logoutUrl,
authentikApplicationSlug: service.authentikApplicationSlug || moduleFoundryService.authentikApplicationSlug,
authentikGroupName: service.authentikGroupName || moduleFoundryService.authentikGroupName,
authHandoffPath: service.authHandoffPath || moduleFoundryService.authHandoffPath,
isAvailableForAllNewClients: service.isAvailableForAllNewClients ?? false,
};
}
if (service.id === "service_bim_viewer" || service.slug === "bim-viewer" || service.authentikApplicationSlug === "bim-viewer") { if (service.id === "service_bim_viewer" || service.slug === "bim-viewer" || service.authentikApplicationSlug === "bim-viewer") {
const legacyLocalUrl = (value) => value === "http://localhost:8080" || value === "http://127.0.0.1:8080"; const legacyLocalUrl = (value) => value === "http://localhost:8080" || value === "http://127.0.0.1:8080";
return { return {
@ -3680,6 +3790,7 @@ function sanitizeServicePatch(payload, service) {
"fallbackGradient", "fallbackGradient",
"authentikApplicationSlug", "authentikApplicationSlug",
"authentikGroupName", "authentikGroupName",
"authHandoffPath",
]; ];
for (const field of stringFields) { for (const field of stringFields) {

View File

@ -3347,6 +3347,7 @@ function getAppCatalog() {
url: getServiceUrl(service), url: getServiceUrl(service),
openUrl: getServiceUrl(service), openUrl: getServiceUrl(service),
launchTargetUrl: getServiceLaunchTargetUrl(service), launchTargetUrl: getServiceLaunchTargetUrl(service),
authHandoffPath: service.authHandoffPath || null,
status: service.status ?? "disabled", status: service.status ?? "disabled",
provider: "authentik", provider: "authentik",
requiredGroups, requiredGroups,
@ -3405,7 +3406,12 @@ function specialRequiredGroups(serviceOrSlug) {
} }
function getServiceUrl(service) { function getServiceUrl(service) {
if (service.slug === "task-manager" || isEngineServiceSlug(service.slug) || isBimViewerServiceSlug(service.slug)) { if (
service.slug === "task-manager" ||
isEngineServiceSlug(service.slug) ||
isBimViewerServiceSlug(service.slug) ||
service.authHandoffPath
) {
return `/api/services/${encodeURIComponent(service.slug)}/launch`; return `/api/services/${encodeURIComponent(service.slug)}/launch`;
} }
@ -3413,6 +3419,14 @@ function getServiceUrl(service) {
} }
function getServiceLaunchTargetUrl(service) { function getServiceLaunchTargetUrl(service) {
// Catalog entries replace `url` with the Launcher launch route for services
// that need a handoff. Keep their external origin separately so a relative
// handoff path is always resolved against the target application, never the
// internal `/api/services/:slug/launch` route.
if (service?.launchTargetUrl) {
return service.launchTargetUrl;
}
if (isEngineServiceSlug(service?.slug)) { if (isEngineServiceSlug(service?.slug)) {
return service.launchUrl || service.url || getEnginePublicBaseUrl(); return service.launchUrl || service.url || getEnginePublicBaseUrl();
} }
@ -3500,6 +3514,25 @@ function redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, re
return; return;
} }
// Module Foundry and future internal services use the same Launcher-owned
// session contract as BIM. The path is service metadata, so the launcher
// does not grow a separate authentication mechanism per module.
const handoffPath = String(app?.authHandoffPath || "").trim();
if (handoffPath) {
const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user, { launcherSessionId: session?.id });
const targetUrl = new URL(handoffPath, getServiceLaunchTargetUrl(app));
const nextPath = sanitizeReturnTo(returnTo);
targetUrl.searchParams.set("token", handoffToken);
if (nextPath && nextPath !== "/") {
targetUrl.searchParams.set("next_path", nextPath);
}
res.redirect(targetUrl.toString());
return;
}
if (serviceSlug !== "task-manager") { if (serviceSlug !== "task-manager") {
res.redirect(app.openUrl || app.url || "/"); res.redirect(app.openUrl || app.url || "/");
return; return;

View File

@ -31,6 +31,11 @@ export interface Service {
order: number; order: number;
authentikApplicationSlug?: string | null; authentikApplicationSlug?: string | null;
authentikGroupName?: string | null; authentikGroupName?: string | null;
/**
* Relative handoff endpoint on the target app. Services using the shared
* Launcher/Auth­entik session path never expose a standalone browser token.
*/
authHandoffPath?: string | null;
isAvailableForAllNewClients?: boolean; isAvailableForAllNewClients?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;