324 lines
9.2 KiB
JavaScript
324 lines
9.2 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 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 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;
|
|
}
|