ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher control plane и доступы

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 17:16:47 +03:00
parent de0a0d2948
commit b221ccb83e
19 changed files with 4137 additions and 999 deletions
+305
View File
@@ -0,0 +1,305 @@
import { randomBytes } from "node:crypto";
const platformGroups = {
superadmin: "nodedc:superadmin",
launcherAdmin: "nodedc:launcher:admin",
launcherUser: "nodedc:launcher:user",
taskManagerAdmin: "nodedc:taskmanager:admin",
taskManagerUser: "nodedc:taskmanager:user",
};
export function createAuthentikSyncClient({ baseUrl, token }) {
const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, "");
function isConfigured() {
return Boolean(normalizedBaseUrl && token);
}
async function provisionUser({ data, userId, password, generatePassword = false }) {
ensureConfigured();
const user = findById(data.users, userId, "user");
const requiredGroups = resolveRequiredGroups(data, user);
const groups = await ensureGroups(requiredGroups);
const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email);
const temporaryPassword = password || (generatePassword && !existingUser ? generatePasswordValue() : null);
const payload = {
username: user.email.toLowerCase(),
email: user.email.toLowerCase(),
name: user.name,
is_active: user.globalStatus === "active",
type: "internal",
groups: groups.map((group) => group.pk),
attributes: {
nodedc_user_id: user.id,
nodedc_source: "launcher-control-plane",
picture: user.avatarUrl || undefined,
avatar_url: user.avatarUrl || undefined,
},
};
const authentikUser = existingUser
? await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, {
method: "PATCH",
body: JSON.stringify(payload),
})
: await requestJson("/api/v3/core/users/", {
method: "POST",
body: JSON.stringify(payload),
});
if (temporaryPassword) {
await setPassword(authentikUser.pk, temporaryPassword);
}
return {
authentikUserId: String(authentikUser.uuid || authentikUser.uid || authentikUser.pk),
authentikPk: authentikUser.pk,
email: authentikUser.email,
name: authentikUser.name,
groups: requiredGroups,
created: !existingUser,
temporaryPassword,
};
}
async function findUserByIdOrEmail(authentikUserId, email) {
if (authentikUserId) {
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
const users = Array.isArray(payload.results) ? payload.results : [];
const existingUser = users.find((user) => {
const identifiers = [user.uuid, user.uid, user.pk].map((value) => String(value || ""));
return identifiers.includes(String(authentikUserId));
});
if (existingUser) {
return existingUser;
}
}
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(email)}`);
const users = Array.isArray(payload.results) ? payload.results : [];
return users.find((user) => String(user.email || "").toLowerCase() === email.toLowerCase()) ?? null;
}
async function ensureGroups(groupNames) {
const groups = [];
for (const groupName of groupNames) {
groups.push(await ensureGroup(groupName));
}
return groups;
}
async function ensureGroup(groupName) {
const payload = await requestJson(`/api/v3/core/groups/?search=${encodeURIComponent(groupName)}`);
const groups = Array.isArray(payload.results) ? payload.results : [];
const existingGroup = groups.find((group) => group.name === groupName);
if (existingGroup) {
return existingGroup;
}
return requestJson("/api/v3/core/groups/", {
method: "POST",
body: JSON.stringify({
name: groupName,
is_superuser: false,
attributes: {
nodedc_source: "launcher-control-plane",
},
}),
});
}
async function setPassword(userPk, password) {
await requestJson(`/api/v3/core/users/${encodeURIComponent(userPk)}/set_password/`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
async function requestJson(path, init = {}) {
ensureConfigured();
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${token}`);
headers.set("Accept", "application/json");
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${normalizedBaseUrl}${path}`, {
...init,
headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Authentik API ${path} failed: HTTP ${response.status} ${errorText}`);
}
return response.status === 204 ? null : response.json();
}
function ensureConfigured() {
if (!isConfigured()) {
throw new Error("Authentik API is not configured. Set AUTHENTIK_BOOTSTRAP_TOKEN or NODEDC_AUTHENTIK_SERVICE_TOKEN server-side.");
}
}
return {
isConfigured,
provisionUser,
};
}
export function resolveRequiredGroups(data, user) {
const groupNames = new Set();
if (user.globalStatus !== "active") {
return [];
}
groupNames.add(platformGroups.launcherUser);
if (user.id === "user_root") {
groupNames.add(platformGroups.superadmin);
groupNames.add(platformGroups.launcherAdmin);
groupNames.add(platformGroups.taskManagerAdmin);
groupNames.add(platformGroups.taskManagerUser);
return [...groupNames];
}
for (const client of data.clients) {
const membership = getRuntimeMembership(data, user.id, client.id);
if (membership.status !== "active") {
continue;
}
const userGroups = getUserGroups(data, user.id, client.id);
for (const service of data.services) {
const access = computeEffectiveAccess(data, { client, user, membership, userGroups, service });
if (!access.allowed) {
continue;
}
if (service.slug === "task-manager") {
groupNames.add(platformGroups.taskManagerUser);
if (access.appRole === "admin" || access.appRole === "owner") {
groupNames.add(platformGroups.taskManagerAdmin);
}
} else if (service.authentikGroupName) {
groupNames.add(service.authentikGroupName);
}
}
}
return [...groupNames];
}
function generatePasswordValue() {
return `NDC-${randomBytes(15).toString("base64url")}`;
}
function computeEffectiveAccess(data, { client, user, membership, userGroups, service }) {
if (client.status === "suspended" || client.status === "expired") {
return { allowed: false };
}
if (user.globalStatus === "blocked" || membership.status === "disabled") {
return { allowed: false };
}
if (service.status === "disabled" || service.status === "hidden") {
return { allowed: false };
}
const deny = data.exceptions.find(
(exception) => exception.serviceId === service.id && exception.userId === user.id && exception.type === "deny"
);
if (deny) {
return { allowed: false };
}
const allow = data.exceptions.find(
(exception) => exception.serviceId === service.id && exception.userId === user.id && exception.type === "allow"
);
if (allow) {
return { allowed: true };
}
const userGrant = data.grants.find(
(grant) =>
grant.serviceId === service.id &&
grant.targetType === "user" &&
grant.targetId === user.id &&
grant.status === "active"
);
if (userGrant) {
return { allowed: true, appRole: userGrant.appRole };
}
const groupIds = userGroups.map((group) => group.id);
const groupGrant = data.grants.find(
(grant) =>
grant.serviceId === service.id &&
grant.targetType === "group" &&
groupIds.includes(grant.targetId) &&
grant.status === "active"
);
if (groupGrant) {
return { allowed: true, appRole: groupGrant.appRole };
}
const clientGrant = data.grants.find(
(grant) =>
grant.serviceId === service.id &&
grant.targetType === "client" &&
grant.targetId === client.id &&
grant.status === "active"
);
if (clientGrant) {
return { allowed: true, appRole: clientGrant.appRole };
}
return { allowed: false };
}
function getRuntimeMembership(data, userId, clientId) {
return (
data.memberships.find((membership) => membership.userId === userId && membership.clientId === clientId) ?? {
id: `missing_${clientId}_${userId}`,
clientId,
userId,
role: "member",
status: "disabled",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
);
}
function getUserGroups(data, userId, clientId) {
return data.groups.filter((group) => group.clientId === clientId && group.memberIds.includes(userId));
}
function findById(items, id, label) {
const item = items.find((candidate) => candidate.id === id);
if (!item) {
throw new Error(`Unknown ${label}: ${id}`);
}
return item;
}
File diff suppressed because it is too large Load Diff
+477 -13
View File
@@ -7,6 +7,8 @@ import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer as createViteServer } from "vite";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
import { createControlPlaneStore } from "./control-plane-store.mjs";
const serverRoot = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(serverRoot, "..");
@@ -25,8 +27,11 @@ loadEnvFiles([
const config = readConfig();
const app = express();
const httpServer = createHttpServer(app);
const controlPlaneStore = createControlPlaneStore({ projectRoot });
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
const pendingLogins = new Map();
const sessions = new Map();
const runtimeEventClients = new Set();
let discoveryCache = null;
let jwksCache = null;
@@ -34,7 +39,12 @@ app.disable("x-powered-by");
app.use(express.json({ limit: maxStorageJsonBodyBytes }));
app.get("/healthz", (_req, res) => {
res.json({ ok: true, service: "nodedc-launcher-bff", oidcConfigured: config.oidcConfigured });
res.json({
ok: true,
service: "nodedc-launcher-bff",
oidcConfigured: config.oidcConfigured,
authentikApiConfigured: authentikSyncClient.isConfigured(),
});
});
app.get("/auth/login", asyncRoute(async (req, res) => {
@@ -165,11 +175,13 @@ app.get("/api/me", (req, res) => {
return;
}
const runtimeContext = getRuntimeSessionContext(session);
res.json({
authenticated: true,
user: session.user,
groups: session.user.groups,
isSuperAdmin: session.user.groups.includes("nodedc:superadmin"),
user: runtimeContext.user,
groups: runtimeContext.groups,
isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"),
logoutUrl: "/auth/logout",
});
});
@@ -182,7 +194,257 @@ app.get("/api/apps", (req, res) => {
return;
}
res.json({ apps: getAppsForUser(session.user.groups) });
res.json({ apps: getAppsForSession(session) });
});
app.get("/api/profile", requireSession, (req, res) => {
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const user = findLauncherUser(data, actor.id);
res.json({
user,
memberships: data.memberships.filter((membership) => membership.userId === user.id),
});
});
app.get("/api/events", requireSession, (req, res) => {
const client = {
id: randomUUID(),
res,
};
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
res.write(`event: nodedc-ready\ndata: ${JSON.stringify({ ok: true })}\n\n`);
const keepAlive = setInterval(() => {
res.write(": keep-alive\n\n");
}, 30000);
runtimeEventClients.add(client);
req.on("close", () => {
clearInterval(keepAlive);
runtimeEventClients.delete(client);
});
});
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
const { actor } = getLauncherProfileContext(req.nodedcSession);
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
const provisionedUser = await authentikSyncClient.provisionUser({
data: result.data,
userId: actor.id,
});
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("profile.updated", [actor.id]);
res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser) });
}));
app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => {
const newPassword = sanitizeNewPassword(req.body?.newPassword);
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const provisionedUser = await authentikSyncClient.provisionUser({
data,
userId: actor.id,
password: newPassword,
});
const result = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("profile.password.updated", [actor.id]);
res.json({ data: result.data, ok: true });
}));
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
});
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
res.json({ clients: snapshot.data.clients });
});
app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
res.status(201).json(result);
}));
app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user);
res.json(result);
}));
app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user);
res.json(result);
}));
app.get("/api/admin/users", requireLauncherAdmin, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships });
});
app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user);
let provisioning = null;
if (req.body?.provisionAuth !== false) {
const provisionedUser = await authentikSyncClient.provisionUser({
data: result.data,
userId: result.user.id,
password: sanitizePassword(req.body?.password),
generatePassword: req.body?.generatePassword !== false,
});
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisionedUser, req.nodedcSession.user);
result.data = storeResult.data;
provisioning = toProvisioningResponse(provisionedUser);
}
publishControlPlaneEvent("admin.user.created", [result.user.id]);
res.status(201).json({ ...result, provisioning });
}));
app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => {
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);
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const provisionedUser = await authentikSyncClient.provisionUser({
data: snapshot.data,
userId: req.params.userId,
password: sanitizePassword(req.body?.password),
generatePassword: req.body?.generatePassword === true,
});
const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]);
res.json({ ...result, provisioning: toProvisioningResponse(provisionedUser) });
}));
app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
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({ ...result, data: syncResult.data });
}));
app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.created");
res.status(201).json(result);
}));
app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.updated");
res.json(result);
}));
app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.deleted");
res.json(result);
}));
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
publishControlPlaneEvent("admin.group.created", syncResult.userIds);
res.status(201).json({ ...result, data: syncResult.data });
}));
app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const previousMemberIds = beforeSnapshot.data.groups.find((group) => group.id === req.params.groupId)?.memberIds ?? [];
const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(
result.data,
[...previousMemberIds, ...result.group.memberIds],
req.nodedcSession.user
);
publishControlPlaneEvent("admin.group.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
publishControlPlaneEvent("admin.group.deleted", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.post("/api/admin/services", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.created");
res.status(201).json(result);
}));
app.patch("/api/admin/services/reorder", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.reordered");
res.json(result);
}));
app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.updated");
res.json(result);
}));
app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.deleted");
res.json(result);
}));
app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(
result.data,
resolveGrantTargetUserIds(result.data, result.grant.targetType, result.grant.targetId),
req.nodedcSession.user
);
publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
publishControlPlaneEvent("admin.sync.retry");
res.json(result);
}));
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, (_req, res) => {
res.json(controlPlaneStore.buildAuthentikSyncPlan());
});
app.post("/api/storage/upload", asyncRoute(async (req, res) => {
@@ -190,8 +452,9 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => {
res.json(result);
}));
app.post("/api/storage/data", asyncRoute(async (req, res) => {
app.post("/api/storage/data", requireLauncherAdmin, asyncRoute(async (req, res) => {
await saveLauncherData(req.body);
publishControlPlaneEvent("storage.data.updated");
res.json({ ok: true, url: "/storage/launcher-data.json" });
}));
@@ -234,6 +497,15 @@ function readConfig() {
cookieDomain: process.env.LAUNCHER_COOKIE_DOMAIN || undefined,
cookieSecure: process.env.COOKIE_SECURE === "true",
oidcConfigured: Boolean(issuer && clientId && clientSecret),
authentikBaseUrl:
process.env.NODEDC_AUTHENTIK_BASE_URL ??
process.env.AUTHENTIK_BASE_URL ??
(process.env.AUTH_DOMAIN ? `http://${process.env.AUTH_DOMAIN}` : ""),
authentikApiToken:
process.env.NODEDC_AUTHENTIK_SERVICE_TOKEN ??
process.env.AUTHENTIK_SERVICE_TOKEN ??
process.env.AUTHENTIK_BOOTSTRAP_TOKEN ??
"",
};
}
@@ -314,6 +586,7 @@ async function verifyIdToken(discovery, idToken, nonce) {
function normalizeUser(claims) {
const groups = normalizeGroups(claims.groups);
const email = typeof claims.email === "string" ? claims.email : "";
const avatarUrl = firstStringClaim(claims.picture, claims.avatar_url, claims.avatar);
const name =
typeof claims.name === "string" && claims.name
? claims.name
@@ -326,10 +599,106 @@ function normalizeUser(claims) {
email,
name,
preferredUsername: typeof claims.preferred_username === "string" ? claims.preferred_username : null,
avatarUrl,
groups,
};
}
function firstStringClaim(...values) {
for (const value of values) {
if (typeof value === "string" && value) return value;
}
return null;
}
function sanitizePassword(value) {
return typeof value === "string" && value.length >= 8 ? value : null;
}
function sanitizeNewPassword(value) {
if (typeof value !== "string" || value.length < 8) {
throw new Error("Новый пароль должен быть не короче 8 символов");
}
return value;
}
function sanitizeSelfProfilePatch(payload) {
return {
name: payload?.name,
email: payload?.email,
phone: payload?.phone,
position: payload?.position,
avatarUrl: payload?.avatarUrl,
};
}
function toProvisioningResponse(provisionedUser) {
return {
authentikUserId: provisionedUser.authentikUserId,
email: provisionedUser.email,
name: provisionedUser.name,
groups: provisionedUser.groups,
created: provisionedUser.created,
temporaryPassword: provisionedUser.temporaryPassword,
};
}
async function syncUsersToAuthentik(data, userIds, identity) {
let latestData = data;
const uniqueUserIds = [...new Set(userIds.filter((userId) => typeof userId === "string" && userId))];
for (const userId of uniqueUserIds) {
if (!latestData.users.some((user) => user.id === userId)) {
continue;
}
const provisionedUser = await authentikSyncClient.provisionUser({ data: latestData, userId });
const result = await controlPlaneStore.markUserAuthentikProvisioned(userId, provisionedUser, identity);
latestData = result.data;
}
return { data: latestData, userIds: uniqueUserIds };
}
function resolveGrantTargetUserIds(data, targetType, targetId) {
if (targetType === "user") {
return [targetId];
}
if (targetType === "group") {
return data.groups.find((group) => group.id === targetId)?.memberIds ?? [];
}
if (targetType === "client") {
return data.memberships.filter((membership) => membership.clientId === targetId).map((membership) => membership.userId);
}
return [];
}
function publishControlPlaneEvent(action, affectedUserIds = []) {
publishRuntimeEvent({
type: "control-plane.updated",
action,
affectedUserIds: [...new Set(affectedUserIds.filter((userId) => typeof userId === "string" && userId))],
emittedAt: new Date().toISOString(),
});
}
function publishRuntimeEvent(payload) {
const message = `event: nodedc-runtime\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of runtimeEventClients) {
try {
client.res.write(message);
} catch {
runtimeEventClients.delete(client);
}
}
}
function normalizeGroups(groupsClaim) {
if (Array.isArray(groupsClaim)) {
return [...new Set(groupsClaim.filter((group) => typeof group === "string"))];
@@ -342,6 +711,47 @@ function normalizeGroups(groupsClaim) {
return [];
}
function getRuntimeSessionContext(session) {
const fallback = {
user: session.user,
groups: session.user.groups,
};
try {
const snapshot = controlPlaneStore.getSnapshot(session.user);
if (snapshot.actor.source !== "launcher") {
return fallback;
}
const user = snapshot.data.users.find((candidate) => candidate.id === snapshot.actor.id);
if (!user) {
return fallback;
}
const groups = resolveRequiredGroups(snapshot.data, user);
return {
groups,
user: {
...session.user,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? session.user.avatarUrl,
groups,
},
};
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
return fallback;
}
}
function getAppsForSession(session) {
return getAppsForUser(getRuntimeSessionContext(session).groups);
}
function getAppsForUser(userGroups) {
const groupSet = new Set(userGroups);
const catalog = getAppCatalog();
@@ -407,7 +817,7 @@ function getAppCatalog() {
}
function specialRequiredGroups(slug) {
if (slug === "launcher" || slug === "nodedc") return ["nodedc:launcher:admin", "nodedc:launcher:user"];
if (slug === "launcher") return ["nodedc:launcher:admin", "nodedc:launcher:user"];
if (slug === "task-manager") return ["nodedc:taskmanager:admin", "nodedc:taskmanager:user"];
return [];
}
@@ -465,12 +875,7 @@ async function saveUploadedFile(payload) {
}
async function saveLauncherData(payload) {
await Promise.all(
getWritableStorageRoots().map(async (storageRoot) => {
await mkdir(storageRoot, { recursive: true });
await writeFile(join(storageRoot, "launcher-data.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
})
);
await controlPlaneStore.writeData(payload);
}
function getWritableStorageRoots() {
@@ -563,6 +968,65 @@ function parseCookies(cookieHeader) {
);
}
function requireLauncherAdmin(req, res, next) {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
const runtimeContext = getRuntimeSessionContext(session);
if (!isLauncherAdmin(runtimeContext.groups)) {
res.status(403).json({ error: "Недостаточно прав Launcher admin" });
return;
}
req.nodedcSession = { ...session, user: runtimeContext.user };
next();
}
function requireSession(req, res, next) {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
const runtimeContext = getRuntimeSessionContext(session);
req.nodedcSession = { ...session, user: runtimeContext.user };
next();
}
function getLauncherProfileContext(session) {
const snapshot = controlPlaneStore.getSnapshot(session.user);
if (snapshot.actor.source !== "launcher") {
throw new Error("Профиль пользователя не найден в Launcher control-plane");
}
return {
actor: snapshot.actor,
data: snapshot.data,
};
}
function findLauncherUser(data, userId) {
const user = data.users.find((candidate) => candidate.id === userId);
if (!user) {
throw new Error(`Unknown Launcher user: ${userId}`);
}
return user;
}
function isLauncherAdmin(groups) {
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
}
function cookieOptions(maxAgeMs) {
const options = {
httpOnly: true,