NODEDC_LAUNCHER/server/authentik-sync.mjs

350 lines
9.9 KiB
JavaScript

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",
};
const publicPoolClientId = "client_public_pool";
const publicPoolClient = {
id: publicPoolClientId,
status: "active",
};
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 deleteUser({ data, userId }) {
ensureConfigured();
const user = findById(data.users, userId, "user");
const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email);
if (!existingUser) {
return {
deleted: false,
email: user.email,
authentikUserId: user.authentikUserId ?? null,
authentikPk: null,
};
}
await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, { method: "DELETE" });
return {
deleted: true,
email: user.email,
authentikUserId: user.authentikUserId ?? null,
authentikPk: existingUser.pk,
};
}
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 {
deleteUser,
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 getUserRuntimeClients(data, user.id)) {
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 getUserRuntimeClients(data, userId) {
const clients = [...data.clients];
const hasPublicPoolMembership = data.memberships.some(
(membership) => membership.userId === userId && membership.clientId === publicPoolClientId
);
if (hasPublicPoolMembership) {
clients.push(publicPoolClient);
}
return clients;
}
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;
}