feat: drop the 2.8 columns, moves and aliases kept for a downgrade

This commit is contained in:
LukeGus committed 2026-10-05 10:21:11 -05:00
1 parent d3d8269cb7
commit 61f46d2517
196 files changed
+29909 -10511

No files matched your search

@@ -710,9 +710,6 @@ export function registerHostBulkRoutes(
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
@@ -975,7 +972,6 @@ export function registerHostBulkRoutes(
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
@@ -12,7 +12,10 @@ import {
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
import {
folderCredentialIds,
setFolderCredentialDefault,
} from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -143,10 +146,17 @@ export function registerHostFolderRoutes(
}
try {
const folders =
await createCurrentHostFolderRepository().listFolders(userId);
const [folders, credentials] = await Promise.all([
createCurrentHostFolderRepository().listFolders(userId),
folderCredentialIds(userId),
]);
res.json(folders);
res.json(
folders.map((folder) => ({
...folder,
credentialId: credentials.get(folder.id) ?? null,
})),
);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
@@ -238,7 +248,6 @@ export function registerHostFolderRoutes(
name,
color,
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
@@ -62,6 +62,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
],
} as const;
/** Keys a 2.8 client's terminalConfig may carry that only the owner may set. */
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
"agentSocketPath",
@@ -263,18 +264,10 @@ export function stripSensitiveFields(
host: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...host };
const terminalConfigForSudo =
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
? (host.terminalConfig as Record<string, unknown>)
: undefined;
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasSudoPassword = !!host.sudoPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
@@ -283,11 +276,11 @@ export function stripSensitiveFields(
typeof result.terminalConfig === "object" &&
!Array.isArray(result.terminalConfig)
) {
const terminalConfig = {
...(result.terminalConfig as Record<string, unknown>),
};
delete terminalConfig.sudoPassword;
result.terminalConfig = terminalConfig;
const { sudoPassword: _sudo, ...rest } = result.terminalConfig as Record<
string,
unknown
>;
result.terminalConfig = rest;
}
return result;
}
@@ -397,44 +390,22 @@ export function parseSharedSource(
}
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
/**
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
* core's own keys only, the SSH options. Everything else 2.8 kept there now
* belongs to plugins' host settings, which they put back through their
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
* sudoPassword, for the sanitizers to handle.
* hostPayloadLegacy.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
terminalConfig?: Record<string, unknown>;
sudoPassword?: unknown;
} {
const raw = parseJsonObject(host.terminalConfig);
const sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : raw,
);
const sshOptions = parseSshOptions(host.sshOptions);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
terminalConfig:
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
...(legacySudo ? { sudoPassword: legacySudo } : {}),
};
}
@@ -1,14 +1,11 @@
/**
* How a host update writes terminal_config and ssh_options, kept apart from
* the route so it can be tested on its own.
* How a host update writes ssh_options, kept apart from the route so it can
* be tested on its own.
*/
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { parseSshOptions } from "../../hosts/ssh-options.js";
import {
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
} from "./host-normalizers.js";
import { OWNER_PRIVATE_SSH_OPTION_FIELDS } from "./host-normalizers.js";
export function parseTerminalConfig(
value: unknown,
@@ -27,63 +24,41 @@ export function parseTerminalConfig(
}
/**
* terminal_config is merged into what is stored rather than replaced. The
* 2.8 keys the editor no longer sends (the terminal's look, which the
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
* editors kept there) stay until 26.10.0 drops the column. A shared editor
* never changes the owner's private keys in either JSON. Returns an error
* for a terminalConfig that cannot be read.
* A 2.8 client's terminalConfig only feeds sshOptions, which the route has
* already taken from it. A shared editor never changes the owner's private
* SSH options. Returns an error for a terminalConfig that cannot be read.
*/
export async function mergeStoredTerminalFields(
export async function mergeStoredSshOptions(
sshDataObj: Record<string, unknown>,
hostData: Record<string, unknown>,
hostId: number,
ownerId: string,
isOwner: boolean,
): Promise<string | null> {
const sentTerminalConfig =
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
const incoming = sentTerminalConfig
? parseTerminalConfig(hostData.terminalConfig)
: null;
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
if (
hostData.terminalConfig !== undefined &&
hostData.terminalConfig !== null &&
!parseTerminalConfig(hostData.terminalConfig)
) {
return "Invalid terminal config";
}
delete sshDataObj.terminalConfig;
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
return null;
}
if (isOwner || sshDataObj.sshOptions === undefined) return null;
const stored = await createCurrentHostResolutionRepository().findHostById(
hostId,
ownerId,
);
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
const merged = { ...storedConfig, ...(incoming ?? {}) };
if (!isOwner) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (field in storedConfig) merged[field] = storedConfig[field];
else delete merged[field];
}
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
// A sudo password saved on its own lives in the encrypted column.
if (sentSudoPassword) delete merged.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(merged);
}
if (!isOwner && sshDataObj.sshOptions !== undefined) {
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
}
sshDataObj.sshOptions = JSON.stringify(options);
}
sshDataObj.sshOptions = JSON.stringify(options);
return null;
}
+5 -24
View File
@@ -98,7 +98,7 @@ import {
listHostProtocols,
} from "../../hosts/protocol-auth/registry.js";
import {
mergeStoredTerminalFields,
mergeStoredSshOptions,
parseTerminalConfig,
} from "./host-terminal-fields.js";
@@ -316,11 +316,6 @@ router.post(
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
@@ -889,11 +884,6 @@ router.put(
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -1081,7 +1071,7 @@ router.put(
}
}
const terminalFieldsError = await mergeStoredTerminalFields(
const terminalFieldsError = await mergeStoredSshOptions(
sshDataObj,
hostData,
Number(hostId),
@@ -1661,11 +1651,7 @@ router.get(
}
const resolved = (await resolveHostCredentials(host, userId)) || host;
let value = resolved[field];
if (!value && field === "sudoPassword") {
value = hostTerminalExport(resolved).sudoPassword || null;
}
const value = resolved[field];
if (!value) {
return res.status(404).json({ error: "No password set" });
@@ -1775,10 +1761,7 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword:
resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
sudoPassword: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
@@ -1911,9 +1894,7 @@ router.get(
!!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
@@ -123,7 +123,6 @@ export function registerUserAdminRoutes(
username: u.username,
is_admin: u.isAdmin,
is_external: isExternalAccount(u),
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
// this route to pick sharing targets.
@@ -470,15 +469,6 @@ export function registerUserAdminRoutes(
username,
passwordHash: password_hash,
isAdmin: false,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -631,7 +621,7 @@ export function registerUserAdminRoutes(
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser.isOidc && !targetUser.passwordHash) {
if (targetUser.isExternal && !targetUser.passwordHash) {
return res.status(400).json({
error: "This user authenticates through an external provider",
});
@@ -58,7 +58,7 @@ export function registerUserExternalAccountRoutes(
* /users/link-external-to-password:
* post:
* summary: Merge an external account into a password account
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0.
* tags:
* - Users
* requestBody:
@@ -86,7 +86,7 @@ export function registerUserExternalAccountRoutes(
*/
const link = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
const externalUserId = req.body?.externalUserId;
const targetUsername = req.body?.targetUsername;
if (
@@ -143,11 +143,6 @@ export function registerUserExternalAccountRoutes(
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier:
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
await authManager.revokeAllUserSessions(externalUserId);
@@ -201,7 +196,7 @@ export function registerUserExternalAccountRoutes(
* /users/unlink-external-from-password:
* post:
* summary: Remove external sign-in from a password account
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
* description: Removes every external sign-in identity from an account that also has a password (admin only).
* tags:
* - Users
* requestBody:
@@ -261,10 +256,6 @@ export function registerUserExternalAccountRoutes(
});
}
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
@@ -307,7 +298,4 @@ export function registerUserExternalAccountRoutes(
router.post("/link-external-to-password", authenticateJWT, link);
router.post("/unlink-external-from-password", authenticateJWT, unlink);
// 2.8 paths, kept until 26.10.0.
router.post("/link-oidc-to-password", authenticateJWT, link);
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
}
@@ -173,7 +173,7 @@ export function registerUserPasswordResetRoutes(
});
}
if (user.isOidc) {
if (user.isExternal) {
return res.json({
message:
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
+8 -22
View File
@@ -55,6 +55,7 @@ import {
isNativeAppRequest,
syncSharedCredentialsForUserRoles,
} from "../../auth/session-issuer.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
const authManager = AuthManager.getInstance();
@@ -184,15 +185,6 @@ router.post("/create", async (req, res) => {
id,
username,
passwordHash: password_hash,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -361,7 +353,7 @@ router.post("/proxy-login", async (req, res) => {
return res.status(403).json({ error: "Proxy user must already exist" });
}
if (
userRecord.isOidc ||
userRecord.isExternal ||
(await createCurrentUserAuthRepository().hasSecondFactor(userRecord.id))
) {
return res.status(409).json({
@@ -600,8 +592,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth =
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
const isDualAuth = hasPassword && isExternalAccount(user);
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
@@ -613,8 +604,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
username: user.username,
is_admin: !!user.isAdmin,
is_external: isExternalAccount(user),
// 2.8 name, kept until 26.10.0.
is_oidc: !!user.isOidc,
is_dual_auth: isDualAuth,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
@@ -944,7 +933,7 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
* /users/external-auto-provision:
* get:
* summary: Get the external account auto-create setting
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin).
* tags:
* - Users
* responses:
@@ -955,7 +944,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
try {
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
false,
),
});
@@ -972,7 +961,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
* /users/external-auto-provision:
* patch:
* summary: Set the external account auto-create setting
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
* description: Enables or disables creating an account on first external sign-in.
* tags:
* - Users
* requestBody:
@@ -1006,7 +995,7 @@ const setExternalAutoProvision: RequestHandler = async (req, res) => {
return res.status(400).json({ error: "Invalid value for enabled" });
}
await createCurrentSettingsRepository().set(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
enabled ? "true" : "false",
);
res.json({ enabled });
@@ -1022,9 +1011,6 @@ router.patch(
authenticateJWT,
setExternalAutoProvision,
);
// 2.8 paths, kept until 26.10.0.
router.get("/oidc-auto-provision", getExternalAutoProvision);
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
/**
* @openapi
@@ -1317,7 +1303,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isOidc) {
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",