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; }