ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher control plane и доступы
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user