chore: merge dev-2.9.1 into dev-26.10.0

This commit is contained in:
LukeGus committed 2026-10-03 16:01:42 -05:00
commit 77bcbdb3d6
1310 files changed
+102834 -51010

No files matched your search

+19
View File
@@ -198,6 +198,25 @@ async function mayClaimLegacyAccount(
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
legacy.id,
);
// An unscoped legacy subject may already have been migrated under a
// different provider. Creating another account loses access to the old
// data; claiming it automatically could cross an identity-provider boundary.
if (
legacy.ssoProviderId == null &&
identity.ssoProviderId != null &&
identity.legacyIdentifier === identity.subject &&
links.some(
(link) =>
link.subject === identity.subject &&
link.providerId !== identity.provider,
)
) {
throw new LoginMethodError(
"A legacy SSO account has an unresolved provider mapping. Ask an administrator to verify and repair its identity mapping before signing in.",
409,
"legacy_identity_conflict",
);
}
return links.length === 0;
}
+17 -5
View File
@@ -1,7 +1,7 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../hosts/defaults/index.js";
import { recompute } from "../hosts/defaults/recompute.js";
@@ -687,6 +687,11 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
});
const exportDb = new Database(tempPath);
// A scratch file: without these every row is its own synced commit, and
// plugin rows may point at hosts the export leaves out.
exportDb.pragma("journal_mode = OFF");
exportDb.pragma("synchronous = OFF");
exportDb.pragma("foreign_keys = OFF");
try {
exportDb.exec(`
@@ -990,9 +995,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
userId,
filename,
});
});
// "close" also fires when the client aborts mid download, which "end"
// does not, so the decrypted export never stays behind on disk.
fileStream.on("close", () => {
fs.unlink(tempPath, (err) => {
if (err) {
if (err && err.code !== "ENOENT") {
apiLogger.warn("Failed to clean up export file", {
operation: "export_cleanup_failed",
path: tempPath,
@@ -1001,6 +1010,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
}
});
});
res.on("close", () => fileStream.destroy());
fileStream.pipe(res);
} catch (error) {
@@ -1048,7 +1058,7 @@ app.post(
"/database/import",
authenticateJWT,
upload.single("file"),
async (req, res) => {
DatabaseSaveTrigger.batched(async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
@@ -1150,6 +1160,7 @@ app.post(
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
const importedHostIds: number[] = [];
for (const host of importedHosts) {
try {
const hostRepository = createCurrentHostRepository();
@@ -1224,7 +1235,7 @@ app.post(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
await applyDefaultsAfterHostWrite(Number(created.id));
importedHostIds.push(Number(created.id));
await importHostProtocolLogins(
importDb,
host,
@@ -1238,6 +1249,7 @@ app.post(
);
}
}
await applyDefaultsAfterHostWrites(importedHostIds);
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
@@ -1402,7 +1414,7 @@ app.post(
details: getErrorMessage(error),
});
}
},
}),
);
/**
+2
View File
@@ -1,3 +1,4 @@
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
@@ -782,6 +783,7 @@ const relaxPluginGrantGrantedBy = () => {
};
const migrateSchema = () => {
repairSnippetsNoteColumn(sqlite);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -278,6 +278,10 @@ export function createCurrentSessionRepository(): SessionRepository {
return new SessionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("session_repository_write"),
// Activity timestamps are informational; the periodic flush persists them.
needsExplicitPersist(resolveDatabaseDialect())
? () => DatabaseSaveTrigger.markDirty()
: undefined,
);
}
@@ -13,6 +13,8 @@ export class SessionRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
private readonly onActivity:
(() => void | Promise<void>) | undefined = onWrite,
) {}
async create(session: NewSessionRecord): Promise<SessionRecord> {
@@ -63,7 +65,7 @@ export class SessionRepository {
.where(and(eq(sessions.id, id), lte(sessions.lastActiveAt, cutoff)));
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
await this.onActivity?.();
return true;
}
@@ -9,6 +9,9 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { isSecretReference } from "../../hosts/external-secrets.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
function describeDeployError(err: unknown): string {
const message = getErrorMessage(err, "Connection failed");
@@ -363,7 +366,20 @@ export function registerCredentialDeployRoutes(
});
}
const publicKey = credData.publicKey;
let publicKey = credData.publicKey;
const storedKey = credData.privateKey || credData.key;
if (!publicKey && isSecretReference(storedKey)) {
// A key kept in a secret source has no stored public key; derive
// it from the resolved key.
const resolved = await resolveKeyReferences(
userId,
storedKey,
(credData as { keyPassword?: string | null }).keyPassword,
);
publicKey =
parseSSHKey(resolved.key, resolved.passphrase).publicKey ||
undefined;
}
if (!publicKey) {
return res.status(400).json({
success: false,
@@ -8,6 +8,8 @@ import {
parseSSHKey,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
const { utils: ssh2Utils } = ssh2Pkg;
@@ -99,13 +101,17 @@ export function registerCredentialKeyRoutes(
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
const keyInfo = parseSSHKey(privateKey, keyPassword);
const response = {
@@ -352,13 +358,17 @@ export function registerCredentialKeyRoutes(
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
let privateKeyObj;
const parseAttempts = [];
+25 -9
View File
@@ -7,7 +7,7 @@ import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
@@ -165,7 +165,7 @@ router.post(
let keyInfo = null;
if (authType === "key" && plainKey) {
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
keyInfo = parseKeyForStorage(plainKey, plainKeyPassword);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed", {
operation: "credential_create",
@@ -192,7 +192,11 @@ router.post(
password: plainPassword,
key: plainKey,
privateKey: keyInfo?.privateKey || plainKey,
publicKey: keyInfo?.publicKey || null,
publicKey:
keyInfo?.publicKey ||
(keyInfo?.reference && typeof req.body?.publicKey === "string"
? req.body.publicKey.trim() || null
: null),
keyPassword: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
@@ -502,7 +506,7 @@ router.post(
let keyInfo = null;
if (authType === "key" && plainKey) {
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
keyInfo = parseKeyForStorage(plainKey, plainKeyPassword);
if (!keyInfo.success) {
return res.status(400).json({
error: keyInfo.error
@@ -524,7 +528,11 @@ router.post(
password: authType === "password" ? plainPassword : null,
key: authType === "key" ? plainKey : null,
privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null,
publicKey: authType === "key" ? keyInfo?.publicKey || null : null,
publicKey:
authType === "key"
? keyInfo?.publicKey ||
(keyInfo?.reference ? source.publicKey || null : null)
: null,
keyPassword: authType === "key" ? plainKeyPassword : null,
keyType: source.keyType,
detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null,
@@ -688,8 +696,14 @@ router.put(
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
if (updateData.key && existingCredential.authType === "key") {
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
if (
updateData.key &&
(updateData.authType ?? existingCredential.authType) === "key"
) {
const keyInfo = parseKeyForStorage(
updateData.key,
updateData.keyPassword,
);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed during update", {
operation: "credential_update",
@@ -704,8 +718,10 @@ router.put(
});
}
updateFields.privateKey = keyInfo.privateKey;
updateFields.publicKey = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
updateFields.publicKey = keyInfo.reference
? updateData.publicKey?.trim() || null
: keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType || null;
}
}
if (updateData.keyPassword !== undefined) {
+17 -12
View File
@@ -4,7 +4,7 @@ import {
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
@@ -22,6 +22,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -183,8 +184,6 @@ export function registerHostBulkRoutes(
* patch:
* summary: Bulk update partial fields on multiple SSH hosts
* tags: [SSH]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
@@ -239,7 +238,7 @@ export function registerHostBulkRoutes(
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostIds, updates } = req.body;
@@ -368,7 +367,7 @@ export function registerHostBulkRoutes(
sshLogger.error("Failed to bulk update hosts:", error);
return res.status(500).json({ error: "Failed to bulk update hosts" });
}
},
}),
);
/**
@@ -457,7 +456,7 @@ export function registerHostBulkRoutes(
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
hosts: hostsToImport,
@@ -577,6 +576,7 @@ export function registerHostBulkRoutes(
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -812,13 +812,15 @@ export function registerHostBulkRoutes(
savedHostId,
hostData as Record<string, unknown>,
);
await applyDefaultsAfterHostWrite(savedHostId);
writtenHostIds.push(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
@@ -827,7 +829,7 @@ export function registerHostBulkRoutes(
failed: results.failed,
errors: results.errors,
});
},
}),
);
/**
@@ -864,7 +866,7 @@ export function registerHostBulkRoutes(
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
@@ -936,6 +938,7 @@ export function registerHostBulkRoutes(
}
}
const writtenHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
@@ -1003,7 +1006,7 @@ export function registerHostBulkRoutes(
existing.id,
sshDataObj,
);
await applyDefaultsAfterHostWrite(existing.id);
writtenHostIds.push(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
@@ -1011,7 +1014,7 @@ export function registerHostBulkRoutes(
userId,
sshDataObj,
);
await applyDefaultsAfterHostWrite(saved.id);
writtenHostIds.push(saved.id);
results.success++;
}
} catch (error) {
@@ -1022,6 +1025,8 @@ export function registerHostBulkRoutes(
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
@@ -1030,7 +1035,7 @@ export function registerHostBulkRoutes(
failed: results.failed,
errors: results.errors,
});
},
}),
);
}
@@ -0,0 +1,122 @@
import type { Request, RequestHandler, Response, Router } from "express";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import { databaseLogger } from "../../utils/logger.js";
const CATALOG_KEY = "host_tag_catalog";
function parseCatalog(value: string | null | undefined): string[] {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((tag): tag is string => typeof tag === "string")
: [];
} catch {
return [];
}
}
export function registerHostTagRoutes(
router: Router,
authenticate: RequestHandler,
requireAdmin: RequestHandler,
): void {
/**
* @openapi
* /host/tags:
* get:
* summary: Get the predefined host tags
* description: The instance-wide tag list host editors suggest. Any signed-in user can read it.
* tags:
* - SSH
* responses:
* 200:
* description: The predefined tags.
* content:
* application/json:
* schema:
* type: object
* properties:
* tags:
* type: array
* items:
* type: string
* 500:
* description: Failed to read the tags.
*/
router.get("/tags", authenticate, async (_req: Request, res: Response) => {
try {
const value = await createCurrentSettingsRepository().get(CATALOG_KEY);
res.json({ tags: parseCatalog(value) });
} catch (error) {
databaseLogger.error("Failed to read host tags", error);
res.status(500).json({ error: "Failed to read host tags" });
}
});
/**
* @openapi
* /host/tags:
* put:
* summary: Replace the predefined host tags
* description: Sets the instance-wide tag list. Tags are trimmed and duplicates dropped. Tags already on hosts are not changed. Requires admin.settings.manage.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tags:
* type: array
* maxItems: 500
* items:
* type: string
* maxLength: 100
* responses:
* 200:
* description: The saved tags.
* 400:
* description: Not an array of up to 500 non-empty tags of at most 100 characters.
* 403:
* description: Missing admin.settings.manage.
* 500:
* description: Failed to save the tags.
*/
router.put(
"/tags",
authenticate,
requireAdmin,
async (req: Request, res: Response) => {
const tags: unknown = req.body?.tags;
if (
!Array.isArray(tags) ||
tags.length > 500 ||
tags.some(
(tag) =>
typeof tag !== "string" || !tag.trim() || tag.trim().length > 100,
)
) {
res.status(400).json({
error: "Expected up to 500 non-empty tags of at most 100 characters",
});
return;
}
const normalized = [
...new Set((tags as string[]).map((tag) => tag.trim())),
];
try {
await createCurrentSettingsRepository().set(
CATALOG_KEY,
JSON.stringify(normalized),
);
res.json({ tags: normalized });
} catch (error) {
databaseLogger.error("Failed to save host tags", error);
res.status(500).json({ error: "Failed to save host tags" });
}
},
);
}
+10 -5
View File
@@ -11,7 +11,7 @@ import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import {
pickResolvedPassword,
pickResolvedUsername,
@@ -53,6 +53,7 @@ import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
import { registerHostTagRoutes } from "./host-tag-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
import {
applyHostEnrollmentDefaults,
@@ -136,6 +137,12 @@ const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
registerHostTagRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("admin.settings.manage"),
);
registerHostStatusRoutes(router, {
authenticateJWT,
requireAdmin: authManager.createAdminMiddleware(),
@@ -351,7 +358,7 @@ router.post(
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
const keyValidation = parseSSHKey(
const keyValidation = parseKeyForStorage(
key,
typeof keyPassword === "string" ? keyPassword : undefined,
);
@@ -493,8 +500,6 @@ router.post(
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
* tags:
* - Host Enrollment
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
@@ -935,7 +940,7 @@ router.put(
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
const keyValidation = parseSSHKey(
const keyValidation = parseKeyForStorage(
key,
typeof keyPassword === "string" ? keyPassword : undefined,
);
-4
View File
@@ -1990,8 +1990,6 @@ router.delete(
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
* tags: [RBAC]
* security:
* - bearerAuth: []
*/
router.put(
"/host-access/:hostId/auth/:protocol",
@@ -2047,8 +2045,6 @@ router.put(
* get:
* summary: Get the current recipient's shared-host protocol authentication override
* tags: [RBAC]
* security:
* - bearerAuth: []
*/
router.get(
"/host-access/:hostId/auth/:protocol",
@@ -20,6 +20,30 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
/**
* 2.9.0 could not match a 2.8 OIDC account to its provider and made a second
* account on first sign-in. The original keeps only its "legacy-oidc" link
* with the same subject, so the duplicate may be merged back into it even
* though it has no password.
*/
async function isLegacyDuplicate(
externalUserId: string,
targetLinks: Array<{ providerId: string; subject: string }>,
): Promise<boolean> {
if (targetLinks.length === 0) return false;
const sourceSubjects = new Set(
(
await createCurrentUserAuthRepository().listIdentitiesForUser(
externalUserId,
)
).map((link) => link.subject),
);
return targetLinks.every(
(link) =>
link.providerId === "legacy-oidc" && sourceSubjects.has(link.subject),
);
}
/**
* Merging an account that only signs in externally (SSO, LDAP or any login
* plugin) into a password account, and taking the external sign-in away
@@ -34,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). 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. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* tags:
* - Users
* requestBody:
@@ -98,15 +122,18 @@ export function registerUserExternalAccountRoutes(
.status(404)
.json({ error: "Target password user not found" });
}
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
return res.status(400).json({
error: "Target user already has an external sign-in",
});
const targetLinks = await identities.listIdentitiesForUser(targetUser.id);
if (!(await isLegacyDuplicate(externalUserId, targetLinks))) {
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if (targetLinks.length > 0) {
return res.status(400).json({
error: "Target user already has an external sign-in",
});
}
}
authLogger.info("Linking an external account to a password account", {
@@ -118,7 +145,8 @@ export function registerUserExternalAccountRoutes(
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: externalUser.oidcIdentifier,
oidcIdentifier:
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
+19
View File
@@ -25,3 +25,22 @@ export async function applyDefaultsAfterHostWrite(
});
}
}
/**
* The same for many hosts written in one go (an import), in a single pass
* instead of one per host.
*/
export async function applyDefaultsAfterHostWrites(
hostIds: number[],
): Promise<void> {
if (hostIds.length === 0) return;
try {
await recompute({ hostIds });
} catch (error) {
databaseLogger.warn("Could not apply host defaults after a write", {
operation: "host_defaults_after_write",
hosts: hostIds.length,
error: error instanceof Error ? error.message : String(error),
});
}
}
+76 -73
View File
@@ -30,6 +30,7 @@ import {
import { hostSettingsPlugins } from "../../database/routes/host-plugin-settings.js";
import { notifySettingChange } from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import {
buildCatalog,
catalogNamespaces,
@@ -342,83 +343,85 @@ async function afterHostsChanged(
* Classifies and rewrites a set of hosts. Returns the hosts whose values or
* overrides changed; with `dryRun` nothing is written.
*/
export async function materializeHosts(
target: HostDefaultsTarget,
options: MaterializeOptions = {},
): Promise<MaterializeResult> {
const repository = createCurrentHostDefaultsRepository();
const rows = (await repository.listHosts(target)).filter(
// A desktop's read-only copy of a shared host follows its owner's server.
(row) => !row.sharedSource,
);
if (rows.length === 0) return { changedHostIds: [] };
const { catalog } = currentCatalog();
const userIds = [...new Set(rows.map((row) => row.userId))];
const [index, placement, pluginRows] = await Promise.all([
loadDefaultsIndex(userIds),
loadPlacement(userIds),
loadPluginRows(rows.map((row) => row.id)),
]);
const withOverlay = applyOverlay(index, options.overlay);
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
for (const row of rows) {
const levels = levelsForHost(
withOverlay,
row.userId,
effectiveFolderPath(row.id, placement),
export const materializeHosts = DatabaseSaveTrigger.batched(
async (
target: HostDefaultsTarget,
options: MaterializeOptions = {},
): Promise<MaterializeResult> => {
const repository = createCurrentHostDefaultsRepository();
const rows = (await repository.listHosts(target)).filter(
// A desktop's read-only copy of a shared host follows its owner's server.
(row) => !row.sharedSource,
);
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
keys: options.keys,
});
const valuesChanged =
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
plan.pluginWrites.length > 0;
if (options.dryRun) {
if (valuesChanged) changed.push({ row, plan });
continue;
if (rows.length === 0) return { changedHostIds: [] };
const { catalog } = currentCatalog();
const userIds = [...new Set(rows.map((row) => row.userId))];
const [index, placement, pluginRows] = await Promise.all([
loadDefaultsIndex(userIds),
loadPlacement(userIds),
loadPluginRows(rows.map((row) => row.id)),
]);
const withOverlay = applyOverlay(index, options.overlay);
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
for (const row of rows) {
const levels = levelsForHost(
withOverlay,
row.userId,
effectiveFolderPath(row.id, placement),
);
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
keys: options.keys,
});
const valuesChanged =
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
plan.pluginWrites.length > 0;
if (options.dryRun) {
if (valuesChanged) changed.push({ row, plan });
continue;
}
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
}
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
}
if (options.dryRun) {
return { changedHostIds: changed.map(({ row }) => row.id) };
}
if (options.dryRun) {
return { changedHostIds: changed.map(({ row }) => row.id) };
}
await repository.updateHosts(
changed.map(({ row, plan }) => ({
id: row.id,
values: plan.columns as Partial<HostRow>,
})),
);
const settings = createCurrentPluginSettingsRepository();
for (const { plan } of changed) {
for (const write of plan.pluginWrites) {
if (write.value === undefined) {
await settings.delete(
write.pluginId,
"host",
String(write.hostId),
write.key,
);
} else {
await settings.set(
write.pluginId,
"host",
String(write.hostId),
write.key,
JSON.stringify(write.value),
);
await repository.updateHosts(
changed.map(({ row, plan }) => ({
id: row.id,
values: plan.columns as Partial<HostRow>,
})),
);
const settings = createCurrentPluginSettingsRepository();
for (const { plan } of changed) {
for (const write of plan.pluginWrites) {
if (write.value === undefined) {
await settings.delete(
write.pluginId,
"host",
String(write.hostId),
write.key,
);
} else {
await settings.set(
write.pluginId,
"host",
String(write.hostId),
write.key,
JSON.stringify(write.value),
);
}
}
}
}
const valueChanges = changed.filter(
({ plan }) =>
plan.pluginWrites.length > 0 ||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
);
await afterHostsChanged(valueChanges);
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
}
const valueChanges = changed.filter(
({ plan }) =>
plan.pluginWrites.length > 0 ||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
);
await afterHostsChanged(valueChanges);
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
},
);
+4
View File
@@ -129,6 +129,10 @@ export async function resolveHostById(
}
host.terminalConfig = rest;
}
// Stored as the text "true"/"false"; the string "false" is truthy and would
// make the password provider skip password auth.
host.forceKeyboardInteractive =
String(host.forceKeyboardInteractive) === "true";
// A row the boot copy has not reached yet still has them in terminal_config.
host.sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
+4
View File
@@ -11,6 +11,10 @@ export class HostSessionStatus {
private counts = new Map<number, number>();
private listeners = new Set<HostSessionStatusListener>();
hasActiveSession(hostId: number): boolean {
return (this.counts.get(hostId) ?? 0) > 0;
}
register(hostId: number): () => void {
const count = this.counts.get(hostId) ?? 0;
this.counts.set(hostId, count + 1);
+24 -18
View File
@@ -7,8 +7,10 @@
* failed (a session, a ctx.ssh connection or a plugin's
* ctx.hosts.status.reportLogin said so). It clears on the next working login,
* when the host is edited, or when a changed host key is accepted. Probing
* never logs in: on RADIUS or Duo backed devices a login fires a real 2FA
* push, every interval.
* never logs in to the host itself: on RADIUS or Duo backed devices a login
* fires a real 2FA push, every interval. A host behind jump hosts is probed
* through a chain, which does log in to the hops, so hosts that share a hop
* list share one chain (jump-probe-pool.ts).
*
* Polling is demand driven. A user asking for statuses starts polling their
* own hosts; nothing is probed for a user who never opened the app.
@@ -28,7 +30,8 @@ import {
type HostStatus,
} from "./host-status.js";
import { ConcurrentLimiter } from "./limiter.js";
import { tcpPing, tcpPingThroughJumpHost } from "./tcp-ping.js";
import { tcpPing } from "./tcp-ping.js";
import { JumpProbePool } from "./jump-probe-pool.js";
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
export const DEFAULT_STATUS_INTERVAL = 60;
@@ -78,6 +81,7 @@ export interface HostStatusDeps {
target: StatusTarget,
port: number,
) => Promise<boolean>;
hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number;
emit: (payload: HostStatusPayload) => void;
}
@@ -110,6 +114,11 @@ export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
};
}
const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
const defaultDeps: HostStatusDeps = {
loadTargets: async (filter) =>
(
@@ -126,17 +135,10 @@ const defaultDeps: HostStatusDeps = {
);
return [...new Set(entries.map((entry) => entry.hostId))];
},
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: async (target, port) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
const client = await createJumpHostChain(
target.jumpHosts,
target.userId,
).catch(() => null);
return client
? tcpPingThroughJumpHost(client, target.ip, port, 5000)
: false;
},
pingThroughJumpHosts: (target, port) =>
jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000),
globalInterval: () => {
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
return Number.isInteger(value) && value >= 5
@@ -429,11 +431,15 @@ export class HostStatusService {
this.owners.set(target.id, target.userId);
let reachable = false;
try {
const port = await this.portFor(target);
reachable =
target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
if (this.deps.hasActiveSession?.(target.id)) {
reachable = true;
} else {
const port = await this.portFor(target);
reachable =
target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
}
} catch {
reachable = false;
}
@@ -0,0 +1,87 @@
import type { Client } from "ssh2";
import { tcpPingThroughJumpHost } from "./tcp-ping.js";
type OpenChain = (
jumpHosts: Array<{ hostId: number }>,
userId: string,
) => Promise<Client | null>;
interface Entry {
client: Promise<Client | null>;
idle?: NodeJS.Timeout;
}
/**
* Status probes for hosts behind jump hosts share one chain per hop list.
* Opening a chain logs in to every hop, so doing it for every host on every
* interval made an import of jump-hosted hosts open dozens of SSH sessions
* each minute. A chain closes after it sits idle.
*/
export class JumpProbePool {
private readonly entries = new Map<string, Entry>();
constructor(
private readonly open: OpenChain,
private readonly idleMs = 120_000,
) {}
async ping(
jumpHosts: Array<{ hostId: number }>,
userId: string,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
const key = `${userId}:${jumpHosts.map((hop) => hop.hostId).join(">")}`;
let entry = this.entries.get(key);
if (!entry) {
entry = { client: this.connect(key, jumpHosts, userId) };
this.entries.set(key, entry);
}
const client = await entry.client;
if (!client) {
this.drop(key, entry);
return false;
}
this.touch(key, entry, client);
return tcpPingThroughJumpHost(client, host, port, timeoutMs, true);
}
closeAll(): void {
for (const [key, entry] of [...this.entries]) {
this.drop(key, entry);
void entry.client.then((client) => client?.end()).catch(() => {});
}
}
private async connect(
key: string,
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<Client | null> {
const client = await this.open(jumpHosts, userId).catch(() => null);
if (client) {
const forget = () => {
const current = this.entries.get(key);
if (current) this.drop(key, current);
};
client.once("close", forget);
client.once("error", forget);
}
return client;
}
private touch(key: string, entry: Entry, client: Client): void {
if (entry.idle) clearTimeout(entry.idle);
entry.idle = setTimeout(() => {
this.drop(key, entry);
client.end();
}, this.idleMs);
entry.idle.unref?.();
}
private drop(key: string, entry: Entry): void {
if (entry.idle) clearTimeout(entry.idle);
if (this.entries.get(key) === entry) this.entries.delete(key);
}
}
+4 -3
View File
@@ -2,8 +2,8 @@ import net from "net";
import type { Client } from "ssh2";
/**
* Opens a TCP connection and closes it again. An SSH server gets a polite
* banner back so it does not log a failed handshake.
* Opens a TCP connection and closes it again. SSH probes exchange banners,
* but still close before authentication and may trigger aggressive Fail2Ban rules.
*/
export function tcpPing(
host: string,
@@ -70,6 +70,7 @@ export function tcpPingThroughJumpHost(
host: string,
port: number,
timeoutMs = 5000,
keepOpen = false,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
@@ -78,7 +79,7 @@ export function tcpPingThroughJumpHost(
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
if (!keepOpen) jumpClient.end();
resolve(result);
};
+43
View File
@@ -0,0 +1,43 @@
import { parseSSHKey, type KeyInfo } from "../utils/ssh-key-utils.js";
import {
isSecretReference,
resolveSecretReference,
} from "./external-secrets.js";
/**
* Checks a private key before it is saved. A secret reference (op://...) is
* resolved when connecting, so it is stored as given: there is nothing to
* parse yet, and parsing the reference itself always failed (#1394).
*/
export function parseKeyForStorage(
key: string,
passphrase?: string | null,
): KeyInfo & { reference: boolean } {
if (isSecretReference(key) || isSecretReference(passphrase)) {
return {
success: true,
privateKey: key.trim(),
publicKey: "",
keyType: "",
reference: true,
};
}
return {
...parseSSHKey(key, passphrase ?? undefined),
reference: false,
};
}
/** The real key and passphrase behind any references, for key helpers. */
export async function resolveKeyReferences(
userId: string,
key: string,
passphrase?: string | null,
): Promise<{ key: string; passphrase?: string }> {
const resolve = async (value: string) =>
isSecretReference(value) ? resolveSecretReference(userId, value) : value;
return {
key: await resolve(key),
passphrase: passphrase ? await resolve(passphrase) : undefined,
};
}
+63 -9
View File
@@ -96,6 +96,34 @@ function describeHost(host: number | PluginSshHost): string {
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
}
const OVERRIDE_FIELDS = [
"password",
"key",
"privateKey",
"keyPassword",
"passphrase",
] as const;
/**
* A secret the plugin set on its copy (a password the user just typed) wins
* over the stored one; everything else comes from what core resolved.
*/
function withOverrides(
resolved: SshConnectHost,
given: PluginSshHost,
): SshConnectHost {
if (given === (resolved as unknown)) return resolved;
const source = given as unknown as Record<string, unknown>;
const set = OVERRIDE_FIELDS.filter(
(field) => typeof source[field] === "string" && source[field] !== "",
);
if (set.length === 0) return resolved;
const merged = { ...resolved } as unknown as Record<string, unknown>;
for (const field of set) merged[field] = source[field];
if (typeof source.authType === "string") merged.authType = source.authType;
return merged as unknown as SshConnectHost;
}
const LOGIN_FAILED =
/all configured authentication methods failed|permission denied|authentication failed/i;
@@ -134,8 +162,37 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) =>
resolvedHosts.set(key, { host, userId });
// Plugins often copy a host before connecting ({ ...host, port }). Spread
// copies enumerable symbol keys, so the copy keeps this tag. Its value is an
// opaque token, not the host, so the secrets stay out of the plugin's reach.
const tag = Symbol("resolvedHost");
const tokens = new WeakMap<
object,
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) => {
const entry = { host, userId };
resolvedHosts.set(key, entry);
const token = Object.freeze({});
tokens.set(token, entry);
if (Object.isExtensible(key)) {
Object.defineProperty(key, tag, {
value: token,
enumerable: true,
configurable: true,
});
}
};
const lookup = (host: object) => {
const known = resolvedHosts.get(host);
if (known) return known;
const token = (host as Record<symbol, unknown>)[tag];
return token && typeof token === "object"
? tokens.get(token as object)
: undefined;
};
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
const redacted = redactHostSecrets(
@@ -148,9 +205,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
/** The acting user for a call about this host. */
const userFor = (host: number | PluginSshHost | undefined): string =>
actingUser(
host && typeof host === "object"
? resolvedHosts.get(host)?.userId
: undefined,
host && typeof host === "object" ? lookup(host)?.userId : undefined,
);
/**
@@ -163,8 +218,8 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
userId: string,
): number | SshConnectHost => {
if (typeof host === "number") return host;
const known = resolvedHosts.get(host);
if (known) return known.host;
const known = lookup(host);
if (known) return withOverrides(known.host, host);
return { ...(host as unknown as SshConnectHost), userId };
};
@@ -173,8 +228,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
* auth or host key failure counts: a timeout says nothing about the login.
*/
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
const hostId =
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
if (error !== undefined) {
+49 -20
View File
@@ -66,25 +66,37 @@ export function listUserOwnedTables(): UserOwnedTable[] {
type Row = Record<string, unknown>;
async function readOwnedRows(
owned: UserOwnedTable,
userId: string,
): Promise<Row[]> {
try {
return await selectRows<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
} catch {
// The plugin's migration has not created the table yet.
return [];
}
}
const yieldToEventLoop = () =>
new Promise<void>((resolve) => setImmediate(resolve));
/** The user's rows in every user-owned plugin table, keyed by table. */
export async function readUserPluginRows(
userId: string,
): Promise<Record<string, Row[]>> {
const result: Record<string, Row[]> = {};
for (const owned of listUserOwnedTables()) {
try {
const rows = await selectRows<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
if (rows.length > 0) result[owned.table] = rows;
} catch {
// The plugin's migration has not created the table yet.
}
const rows = await readOwnedRows(owned, userId);
if (rows.length > 0) result[owned.table] = rows;
await yieldToEventLoop();
}
return result;
}
@@ -95,18 +107,25 @@ interface SqliteLike {
run(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
};
transaction?(fn: (rows: Row[]) => void): (rows: Row[]) => unknown;
}
/** Writes the user's plugin rows into a SQLite export file. */
const INSERT_CHUNK = 2000;
/**
* Writes the user's plugin rows into a SQLite export file. One table at a
* time, in chunked transactions with a yield between them: history tables
* (command history, health checks) can hold many thousands of rows, and the
* export runs on the thread that serves every other request.
*/
export async function writeUserPluginTables(
exportDb: SqliteLike,
userId: string,
): Promise<number> {
const rowsByTable = await readUserPluginRows(userId);
let written = 0;
for (const owned of listUserOwnedTables()) {
const rows = rowsByTable[owned.table];
if (!rows?.length) continue;
const rows = await readOwnedRows(owned, userId);
if (!rows.length) continue;
for (const statement of createTableSql(
"sqlite",
owned.pluginId,
@@ -119,9 +138,19 @@ export async function writeUserPluginTables(
.map((column) => `"${column}"`)
.join(", ")}) VALUES (${owned.columns.map(() => "?").join(", ")})`,
);
for (const row of rows) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
written++;
const writeChunk = (chunk: Row[]) => {
for (const row of chunk) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
}
};
const runChunk = exportDb.transaction
? exportDb.transaction(writeChunk)
: writeChunk;
for (let start = 0; start < rows.length; start += INSERT_CHUNK) {
const chunk = rows.slice(start, start + INSERT_CHUNK);
runChunk(chunk);
written += chunk.length;
await yieldToEventLoop();
}
}
return written;
@@ -521,6 +521,89 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it.each(["true", "false"])(
"does not duplicate an unscoped migrated account with provisioning %s",
async (enabled) => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
const { runExternalIdentityMigration } =
await import("../../upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
h.state.settings.set("oidc_auto_provision", enabled);
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
expect([...h.state.users.keys()]).toEqual(["old"]);
expect(h.state.identities).toHaveLength(1);
expect(h.state.identities[0]).toMatchObject({
userId: "old",
providerId: "legacy-oidc",
subject: "sub-1",
});
},
);
it("keeps equal subjects on explicitly different providers separate", async () => {
addUser({
id: "other",
username: "Other Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: 9,
});
h.state.identities.push({
id: 1,
userId: "other",
providerId: "9",
subject: "sub-1",
email: null,
});
h.state.settings.set("oidc_auto_provision", "true");
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(2);
expect(
h.state.identities.find((link) => link.providerId === "3")?.userId,
).not.toBe("other");
});
it("uses a verified provider link even when the old provider column is empty", async () => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
h.state.identities.push({
id: 1,
userId: "old",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
h.state.identities.push({
@@ -4,12 +4,13 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
// getDb() throws unless a database was initialized; the context's handle is
// not what this file is about.
vi.mock("../../../database/db/index.js", () => ({
getDb: () => ({}),
getDb: vi.fn(() => ({})),
getSqlite: () => ({}),
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
}));
const {
createCurrentSessionRepository,
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
createCurrentRepositoryLazyWriteHook,
@@ -86,3 +87,35 @@ describe("createCurrentRepositoryLazyWriteHook", () => {
}
});
});
it("wires SQLite activity to the periodic flush and token changes to immediate persistence", async () => {
const saved = process.env[DATABASE_DIALECT_ENV];
process.env[DATABASE_DIALECT_ENV] = "sqlite";
const { getDb } = await import("../../../database/db/index.js");
const { DatabaseSaveTrigger } =
await import("../../../utils/database-save-trigger.js");
const dirty = vi
.spyOn(DatabaseSaveTrigger, "markDirty")
.mockImplementation(() => {});
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
const query = {
update: () => query,
set: () => query,
where: async () => ({ changes: 1 }),
};
vi.mocked(getDb).mockReturnValue(
query as unknown as ReturnType<typeof getDb>,
);
try {
const repo = createCurrentSessionRepository();
await repo.touch("session-1");
expect(dirty).toHaveBeenCalledOnce();
expect(force).not.toHaveBeenCalled();
await repo.updateToken("session-1", "new-token");
expect(force).toHaveBeenCalledOnce();
} finally {
vi.restoreAllMocks();
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
else process.env[DATABASE_DIALECT_ENV] = saved;
}
});
@@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => {
async function createRepositories(options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
}): Promise<{
users: UserRepository;
sessions: SessionRepository;
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
} = {},
): Promise<{
users: UserRepository;
@@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => {
return {
users: new UserRepository(context, options.onUserWrite),
sessions: new SessionRepository(context, options.onSessionWrite),
sessions: new SessionRepository(
context,
options.onSessionWrite,
options.onSessionActivity,
),
};
}
@@ -228,6 +234,46 @@ describe("UserRepository and SessionRepository", () => {
);
});
it("separates activity persistence from security-critical session writes", async () => {
let writes = 0,
activity = 0;
const repo = await createRepositories({
onSessionWrite: () => {
writes++;
},
onSessionActivity: () => {
activity++;
},
});
await repo.users.create({
id: "user-1",
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
userId: "user-1",
jwtToken: "token",
deviceType: "desktop",
deviceInfo: "Firefox",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: "2026-06-27T00:00:00.000Z",
lastActiveAt: "2026-06-26T00:00:00.000Z",
});
expect(writes).toBe(1);
await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z");
expect(activity).toBe(1);
expect(writes).toBe(1);
await repo.sessions.touch("session-1", "2026-06-26T00:01:10.000Z");
expect(activity).toBe(1);
await repo.sessions.updateToken("session-1", "renewed");
expect(writes).toBe(2);
await repo.sessions.revoke("session-1");
expect(writes).toBe(3);
});
it("revokes all user sessions except an optional current session", async () => {
const repo = await createRepositories();
await repo.users.create({
@@ -0,0 +1,193 @@
/**
* A key credential whose private key is a secret reference (op://...) is
* saved as given and resolved when connecting (#1394). Parsing the reference
* as a key used to reject it, and an update kept the old private key.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
created: [] as Array<Record<string, unknown>>,
updated: [] as Array<Record<string, unknown>>,
existing: null as Record<string, unknown> | null,
}));
const pass = (_req: unknown, _res: unknown, next: () => void) => next();
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "u1";
next();
},
createDataAccessMiddleware: () => pass,
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ requirePermission: () => pass }),
},
}));
vi.mock("../../../sync/shared-copy-guard.js", () => ({
rejectSharedCopyWrites: () => pass,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async () => {},
getAuditUsername: async () => "u1",
getRequestMeta: () => ({ ipAddress: null, userAgent: null }),
}));
vi.mock("../../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, sshLogger: log, databaseLogger: log };
});
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
registerCredentialKeyRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
registerCredentialDeployRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
registerCredentialBulkRoutes: () => {},
}));
vi.mock("../../../hosts/delete-credential.js", () => ({
deleteOwnedCredential: async () => {},
}));
vi.mock("../../../hosts/connect/core-providers.js", () => ({
ensureCoreSshAuthProviders: () => {},
}));
vi.mock("../../../hosts/connect/auth-provider-registry.js", () => ({
listCredentialTypes: () => ["password", "key"],
}));
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ resyncHostsForCredential: async () => {} }),
},
}));
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ resyncCredential: async () => {} }),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
createEncryptedForUser: async (
userId: string,
data: Record<string, unknown>,
) => {
h.created.push(data);
return { id: 1, userId, ...data };
},
findById: async () => (h.existing ? { id: 1, userId: "u1" } : null),
findDecryptedByIdForUser: async () => h.existing,
updateEncryptedForUser: async (
_userId: string,
_id: number,
fields: Record<string, unknown>,
) => {
h.updated.push(fields);
return { ...h.existing, ...fields };
},
}),
createCurrentUserRepository: () => ({}),
createCurrentCredentialAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentHostRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/credentials.js");
// Never parsed here: only the update replacing it matters.
const REAL_KEY = "stored-key-material";
let server: http.Server;
let base: string;
beforeEach(async () => {
h.created = [];
h.updated = [];
h.existing = null;
const app = express();
app.use(express.json());
app.use("/credentials", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function send(method: string, path: string, body: unknown) {
return fetch(`${base}/credentials${path}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("credentials with a secret reference key", () => {
it("saves an op:// private key as given", async () => {
const response = await send("POST", "", {
name: "vault key",
authType: "key",
username: "root",
key: "op://vault/item/private_key",
});
expect(response.status).toBe(201);
expect(h.created[0]).toMatchObject({
key: "op://vault/item/private_key",
privateKey: "op://vault/item/private_key",
publicKey: null,
detectedKeyType: null,
});
});
it("still rejects a key that is neither a key nor a reference", async () => {
const response = await send("POST", "", {
name: "bad",
authType: "key",
key: "not a key",
});
expect(response.status).toBe(400);
});
it("replaces the stored private key when updating to a reference", async () => {
h.existing = {
id: 1,
userId: "u1",
name: "k",
authType: "key",
key: REAL_KEY,
privateKey: REAL_KEY,
publicKey: "ssh-ed25519 AAAA old",
};
const response = await send("PUT", "/1", {
key: "op://vault/item/private_key",
});
expect(response.status).toBe(200);
expect(h.updated[0]).toMatchObject({
key: "op://vault/item/private_key",
privateKey: "op://vault/item/private_key",
publicKey: null,
detectedKeyType: null,
});
});
});
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
update: vi.fn(),
list: vi.fn(),
applyPluginSettings: vi.fn(),
applyDefaults: vi.fn(async () => {}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: mocks.applyPluginSettings,
@@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
applyDefaultsAfterHostWrites: mocks.applyDefaults,
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
@@ -141,6 +143,9 @@ it.each([20, "20"])(
]);
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("exportId");
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id");
// One defaults pass for the whole import, not one per host.
expect(mocks.applyDefaults).toHaveBeenCalledTimes(1);
expect(mocks.applyDefaults).toHaveBeenCalledWith([501, 502]);
},
);
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
const settings = vi.hoisted(() => new Map<string, string>());
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => settings.get(key) ?? null,
set: async (key: string, value: string) => {
settings.set(key, value);
},
}),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn() },
}));
import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js";
function app() {
const server = express();
server.use(express.json());
registerHostTagRoutes(
server,
(req, res, next) =>
req.headers.authorization ? next() : void res.sendStatus(401),
(req, res, next) =>
req.headers.authorization === "admin" ? next() : void res.sendStatus(403),
);
return server;
}
beforeEach(() => settings.clear());
describe("instance host tag catalog", () => {
it("allows authenticated users to read the same admin-managed catalog", async () => {
const server = app();
await request(server)
.put("/tags")
.set("Authorization", "admin")
.send({ tags: [" Linux ", "Domain Controller", "Linux"] })
.expect(200, { tags: ["Linux", "Domain Controller"] });
await request(server)
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: ["Linux", "Domain Controller"] });
});
it("rejects anonymous reads and non-admin writes", async () => {
await request(app()).get("/tags").expect(401);
await request(app())
.put("/tags")
.set("Authorization", "member")
.send({ tags: ["test"] })
.expect(403);
expect(settings.size).toBe(0);
});
it.each([null, [42], [" "], ["x".repeat(101)], Array(501).fill("tag")])(
"rejects invalid catalogs",
async (tags) => {
await request(app())
.put("/tags")
.set("Authorization", "admin")
.send({ tags })
.expect(400);
expect(settings.size).toBe(0);
},
);
it("allows clearing suggestions without modifying hosts", async () => {
settings.set("host_tag_catalog", '["Linux"]');
await request(app())
.put("/tags")
.set("Authorization", "admin")
.send({ tags: [] })
.expect(200, { tags: [] });
});
it("reads a damaged catalog as empty instead of failing", async () => {
settings.set("host_tag_catalog", "{not json");
await request(app())
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: [] });
});
});
@@ -201,6 +201,65 @@ describe("linking an external account to a password account", () => {
});
expect(response.status).toBe(400);
});
it("merges a 2.9.0 duplicate back into its 2.8 SSO account", async () => {
h.users.set("orig", {
id: "orig",
username: "bob",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-9",
});
h.users.set("dup", {
id: "dup",
username: "bob-1",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: null,
});
h.identities.push(
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
{ userId: "dup", providerId: "1", subject: "sub-9" },
);
const response = await post("/link-external-to-password", {
externalUserId: "dup",
targetUsername: "bob",
});
expect(response.status).toBe(200);
expect(h.deleted).toEqual(["dup"]);
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
expect(
h.identities
.filter((row) => row.userId === "orig")
.map((row) => row.providerId),
).toEqual(["legacy-oidc", "1"]);
});
it("does not treat an unrelated SSO account as a duplicate target", async () => {
h.users.set("other", {
id: "other",
username: "carol",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-x",
});
h.identities.push({
userId: "other",
providerId: "legacy-oidc",
subject: "sub-x",
});
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "carol",
});
expect(response.status).toBe(400);
});
});
describe("unlinking", () => {
+32 -1
View File
@@ -5,9 +5,14 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { copyPluginSdk } =
const { copyPluginSdk, resourcesDirFor } =
require("../../../../packaging/build/after-pack.cjs") as {
copyPluginSdk: (resourcesDir: string, sdkDir?: string) => string;
resourcesDirFor: (context: {
electronPlatformName: string;
appOutDir: string;
packager: { appInfo: { productFilename: string } };
}) => string;
};
let dir: string;
@@ -53,4 +58,30 @@ describe("afterPack plugin SDK copy", () => {
"npm run build:sdk",
);
});
it.each(["darwin", "mas"])(
"copies into the app bundle for %s builds",
(electronPlatformName) => {
const context = {
electronPlatformName,
appOutDir: "/out",
packager: { appInfo: { productFilename: "Termix" } },
};
expect(resourcesDirFor(context)).toBe(
path.join("/out", "Termix.app", "Contents", "Resources"),
);
},
);
it.each(["win32", "linux"])(
"copies into resources for %s builds",
(electronPlatformName) => {
const context = {
electronPlatformName,
appOutDir: "/out",
packager: { appInfo: { productFilename: "Termix" } },
};
expect(resourcesDirFor(context)).toBe(path.join("/out", "resources"));
},
);
});
@@ -487,6 +487,16 @@ describe("resolveHostById", () => {
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
});
it("turns the stored force_keyboard_interactive text into a boolean", async () => {
state.host = baseHost({ forceKeyboardInteractive: "false" });
const off = (await resolveHostById(42, "owner")) as Record<string, unknown>;
expect(off.forceKeyboardInteractive).toBe(false);
state.host = baseHost({ forceKeyboardInteractive: "true" });
const on = (await resolveHostById(42, "owner")) as Record<string, unknown>;
expect(on.forceKeyboardInteractive).toBe(true);
});
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
state.isAdminBypass = true;
state.host = baseHost({
@@ -13,10 +13,13 @@ describe("HostSessionStatus", () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(7, true);
expect(status.hasActiveSession(7)).toBe(true);
closeFirst();
expect(status.hasActiveSession(7)).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
closeSecond();
expect(status.hasActiveSession(7)).toBe(false);
expect(listener).toHaveBeenLastCalledWith(7, false);
expect(listener).toHaveBeenCalledTimes(2);
});
@@ -52,6 +52,7 @@ function setup(
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
});
@@ -74,6 +75,34 @@ afterEach(() => {
});
describe("HostStatusService", () => {
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
"skips probes while a session is open, including jump hosts: %j",
async ({ jumpHosts }) => {
const { service, ping, pingThroughJumpHosts } = setup([
target(7, { jumpHosts }),
]);
active = service;
const close = hostSessionStatus.register(7);
try {
await service.statusesFor("owner", null);
await flush();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(service.get(7)?.status).toBe("online");
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
close();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(
jumpHosts.length ? pingThroughJumpHosts : ping,
).toHaveBeenCalledOnce();
} finally {
close();
}
},
);
it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]);
active = service;
@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(
(
_srcIp: string,
_srcPort: number,
_host: string,
_port: number,
callback: (error: Error | null, stream?: { destroy(): void }) => void,
) => {
callback(
reachable ? null : new Error("refused"),
reachable ? { destroy: () => {} } : undefined,
);
},
),
});
return client;
}
afterEach(() => {
vi.useRealTimers();
});
describe("JumpProbePool", () => {
it("shares one chain across hosts behind the same hops", async () => {
const client = fakeClient();
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
const hops = [{ hostId: 1 }, { hostId: 2 }];
const results = await Promise.all([
pool.ping(hops, "u1", "10.0.0.3", 22),
pool.ping(hops, "u1", "10.0.0.4", 22),
pool.ping(hops, "u1", "10.0.0.5", 22),
]);
expect(results).toEqual([true, true, true]);
expect(open).toHaveBeenCalledTimes(1);
expect(client.forwardOut).toHaveBeenCalledTimes(3);
expect(client.end).not.toHaveBeenCalled();
pool.closeAll();
});
it("keeps chains apart per user and hop list", async () => {
const open = vi.fn(async () => fakeClient() as never);
const pool = new JumpProbePool(open);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
it("reports an unreachable target without dropping the chain", async () => {
const client = fakeClient(false);
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
expect(open).toHaveBeenCalledTimes(1);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(fakeClient());
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
pool.closeAll();
});
it("opens a new chain after the old one closed or sat idle", async () => {
vi.useFakeTimers();
const first = fakeClient();
const second = fakeClient();
const third = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second)
.mockResolvedValueOnce(third);
const pool = new JumpProbePool(open, 1000);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
first.emit("close");
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1500);
expect(second.end).toHaveBeenCalled();
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
const resolve = vi.fn(async (_userId: string, ref: string) => `real:${ref}`);
vi.mock("../../hosts/external-secrets.js", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveSecretReference: (userId: string, ref: string) => resolve(userId, ref),
}));
const { parseKeyForStorage, resolveKeyReferences } =
await import("../../hosts/stored-ssh-key.js");
describe("parseKeyForStorage", () => {
it("accepts a secret reference without parsing it", () => {
expect(parseKeyForStorage(" op://v/i/private_key ")).toEqual({
success: true,
privateKey: "op://v/i/private_key",
publicKey: "",
keyType: "",
reference: true,
});
});
it("does not try to open a real key with a referenced passphrase", () => {
const result = parseKeyForStorage("-----BEGIN...", "op://v/i/pass");
expect(result).toMatchObject({ success: true, reference: true });
});
it("parses anything else as a key", () => {
const result = parseKeyForStorage("not a key");
expect(result.success).toBe(false);
expect(result.reference).toBe(false);
});
});
describe("resolveKeyReferences", () => {
it("resolves only the values that are references", async () => {
expect(await resolveKeyReferences("u1", "op://v/i/key", "plain")).toEqual({
key: "real:op://v/i/key",
passphrase: "plain",
});
expect(resolve).toHaveBeenCalledTimes(1);
});
});
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number, userId: string) => ({
id: hostId,
userId,
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "stored-secret",
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
reportLogin: (hostId: number, outcome: unknown) =>
@@ -253,6 +265,50 @@ describe("ctx.ssh", () => {
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
});
it("connects a copied redacted host with its stored login", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
const host = (await ssh.resolveHost(5))!;
expect((host as Record<string, unknown>).password).toBeUndefined();
expect(JSON.stringify(host)).not.toContain("stored-secret");
for (const symbol of Object.getOwnPropertySymbols(host)) {
expect(JSON.stringify((host as never)[symbol])).not.toContain(
"stored-secret",
);
}
await ssh.connect({ ...host, port: host.port || 22 });
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
await ssh.connect({ ...host, password: "typed", authType: "password" });
expect(h.connects[1].target).toMatchObject({ password: "typed" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
});
it("does not honor another plugin's resolved host", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const deps = {
manifest: manifest(["ssh:connect", "credentials:use"]),
audit: vi.fn(async () => {}),
};
const first = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const second = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const host = (await first.resolveHost(5))!;
await second.connect({ ...host });
expect(h.connects[0].target).not.toHaveProperty("password");
});
it("passes a given stream through to the pipeline, gated like any connect", async () => {
const stream = { throughSource: true };
const ssh = createPluginSsh({
@@ -117,6 +117,32 @@ describe("plugin user data", () => {
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("writes large tables in chunks and lets the event loop run between them", async () => {
const insert = sqlite.prepare(
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
);
sqlite.transaction(() => {
for (let i = 0; i < 4999; i++) insert.run(`/p/${i}`);
})();
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
const transaction = vi.spyOn(file, "transaction");
let ticks = 0;
const ticker = setInterval(() => ticks++, 0);
const written = await writeUserPluginTables(file, "u1");
clearInterval(ticker);
expect(written).toBe(5000);
expect(transaction).toHaveBeenCalled();
expect(ticks).toBeGreaterThan(0);
expect(
file.prepare("SELECT COUNT(*) AS n FROM p_fixture_bookmark").get(),
).toEqual({ n: 5000 });
});
it("reads a pre-2.9 export by the legacy table name", async () => {
const file = new Database(":memory:");
file.exec(
+1
View File
@@ -26,6 +26,7 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
batched: <T>(work: T) => work,
},
}));
vi.mock("../../utils/system-crypto.js", () => ({
@@ -111,4 +111,20 @@ describe("runExternalIdentityMigration", () => {
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
it("does not recreate a legacy-oidc link that was moved to its provider", async () => {
user("oidc-user", { oidcIdentifier: "sub-1" });
h.state.identities.push({
id: 1,
userId: "oidc-user",
providerId: "1",
subject: "sub-1",
email: null,
} as never);
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -0,0 +1,57 @@
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
let sqlite: Database.Database;
beforeEach(() => {
sqlite = new Database(":memory:");
});
afterEach(() => {
sqlite.close();
});
describe("snippet note column upgrade", () => {
it.each(["snippets", "p_snippets_snippets"])(
"repairs %s without losing rows",
(table) => {
sqlite.exec(
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, user_id TEXT, content TEXT)`,
);
sqlite
.prepare(`INSERT INTO "${table}" VALUES (7, 'user', 'echo hello')`)
.run();
expect(() => sqlite.prepare(`SELECT "is_note" FROM "${table}"`)).toThrow(
/is_note/,
);
repairSnippetsNoteColumn(sqlite);
if (table === "snippets") {
sqlite.exec(
readFileSync(
"plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql",
"utf8",
),
);
}
expect(
sqlite
.prepare("SELECT id, content, is_note FROM p_snippets_snippets")
.get(),
).toEqual({ id: 7, content: "echo hello", is_note: 0 });
sqlite.exec("UPDATE p_snippets_snippets SET is_note = 1");
repairSnippetsNoteColumn(sqlite);
expect(
sqlite.prepare("SELECT is_note FROM p_snippets_snippets").get(),
).toEqual({ is_note: 1 });
},
);
it("does not create tables on a fresh install", () => {
repairSnippetsNoteColumn(sqlite);
expect(
sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all(),
).toEqual([]);
});
});
@@ -0,0 +1,200 @@
/**
* 2.8 OIDC accounts with no provider id were filed under "legacy-oidc", but
* the sso plugin signs in through the migrated provider row (#1381). They move
* to that row when it is the only one from before 2.9 and the env provider is
* not in use; anything ambiguous is left alone.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
db: null as unknown,
warn: null as unknown as ReturnType<typeof vi.fn>,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
h.warn = log.warn;
return { databaseLogger: log };
});
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
const ENV = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
"OIDC_ENV_OVERRIDE",
];
let sqlite: Database.Database;
function setup(providers: Array<[number, string, number, number]>) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, oidc_identifier TEXT);
CREATE TABLE p_sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT 'x',
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
legacy_callback INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO p_sso_providers (id, type, enabled, legacy_callback) VALUES (?, ?, ?, ?)",
);
for (const row of providers) insert.run(...row);
h.db = drizzle(sqlite);
}
function addUser(id: string, identifier: string, providerId: string) {
sqlite
.prepare("INSERT INTO users (id, oidc_identifier) VALUES (?, ?)")
.run(id, identifier);
const subject = identifier.replace(/^github:[^:]+:/, "");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run(id, providerId, subject);
}
function links() {
return sqlite
.prepare(
"SELECT user_id, provider_id, subject FROM user_external_identities ORDER BY id",
)
.all();
}
beforeEach(() => {
for (const name of ENV) delete process.env[name];
});
afterEach(() => {
for (const name of ENV) delete process.env[name];
});
describe("runSsoLegacyIdentityMigration", () => {
it("moves legacy OIDC sign-ins to the one migrated provider", async () => {
setup([
[1, "oidc", 1, 1],
[2, "github", 1, 1],
]);
addUser("alice", "sub-a", "legacy-oidc");
addUser("gh", "github:null:42", "legacy-oidc");
const result = await runSsoLegacyIdentityMigration();
expect(result).toEqual({ moved: 1, conflicts: 0 });
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
{ user_id: "gh", provider_id: "legacy-oidc", subject: "42" },
]);
});
it("ignores OIDC providers added after the upgrade", async () => {
setup([
[1, "oidc", 1, 1],
[5, "oidc", 1, 0],
]);
addUser("alice", "sub-a", "legacy-oidc");
await runSsoLegacyIdentityMigration();
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
]);
});
it("leaves them when two providers are from before 2.9", async () => {
setup([
[1, "oidc", 1, 1],
[2, "oidc", 1, 1],
]);
addUser("alice", "sub-a", "legacy-oidc");
expect(await runSsoLegacyIdentityMigration()).toEqual({
moved: 0,
conflicts: 0,
});
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
});
it("leaves them when the env provider signs in", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
process.env.OIDC_ENV_OVERRIDE = "true";
await runSsoLegacyIdentityMigration();
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
});
it("still moves them when env settings exist but the row is what signs in", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
await runSsoLegacyIdentityMigration();
expect(links()[0]).toMatchObject({ provider_id: "1" });
});
it("skips a subject a 2.9.0 duplicate account already holds", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('dup', '1', 'sub-a')",
)
.run();
const result = await runSsoLegacyIdentityMigration();
expect(result).toEqual({ moved: 0, conflicts: 1 });
expect(h.warn).toHaveBeenCalled();
expect(links()).toHaveLength(2);
});
it("drops the legacy link once the same account holds the real one", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('alice', '1', 'sub-a')",
)
.run();
await runSsoLegacyIdentityMigration();
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
]);
});
it("is idempotent", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
await runSsoLegacyIdentityMigration();
expect(await runSsoLegacyIdentityMigration()).toEqual({
moved: 0,
conflicts: 0,
});
});
});
@@ -7,6 +7,25 @@ describe("DatabaseSaveTrigger", () => {
DatabaseSaveTrigger.cleanup();
});
it("batches informational touches without scheduling per-session saves", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
for (let minute = 0; minute < 4; minute++) {
DatabaseSaveTrigger.markDirty();
await vi.advanceTimersByTimeAsync(60_000);
}
expect(save).not.toHaveBeenCalled();
expect(DatabaseSaveTrigger.isDirty).toBe(true);
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(false);
const flushed = DatabaseSaveTrigger.forceSave("periodic_flush");
// A save yields one event-loop turn, which fake timers also control.
await vi.runOnlyPendingTimersAsync();
await flushed;
expect(save).toHaveBeenCalledOnce();
expect(DatabaseSaveTrigger.isDirty).toBe(false);
});
it("force saves through the initialized save function", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
@@ -21,6 +40,18 @@ describe("DatabaseSaveTrigger", () => {
});
});
it("lets the event loop turn before a force save resolves", async () => {
DatabaseSaveTrigger.initialize(vi.fn().mockResolvedValue(undefined));
let turned = false;
setImmediate(() => {
turned = true;
});
await DatabaseSaveTrigger.forceSave("bulk_write");
expect(turned).toBe(true);
});
it("debounces dirty saves and marks the database clean after saving", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
@@ -33,12 +64,24 @@ describe("DatabaseSaveTrigger", () => {
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
await vi.runOnlyPendingTimersAsync();
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
it("saves within the max wait even while writes keep coming", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
for (let second = 0; second < 40; second++) {
void DatabaseSaveTrigger.triggerSave("sample");
await vi.advanceTimersByTimeAsync(1000);
}
expect(save).toHaveBeenCalledTimes(1);
});
it("queues a force save behind an in-flight save", async () => {
let finishFirstSave: (() => void) | undefined;
const firstSave = new Promise<void>((resolve) => {
@@ -62,4 +105,77 @@ describe("DatabaseSaveTrigger", () => {
expect(save).toHaveBeenCalledTimes(2);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
it("collapses force saves inside a batch into one save", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
const result = await DatabaseSaveTrigger.batched(async (rows: number) => {
for (let i = 0; i < rows; i++) {
await DatabaseSaveTrigger.forceSave("row_write");
}
expect(save).not.toHaveBeenCalled();
return rows;
})(50);
expect(result).toBe(50);
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
});
it("folds a nested batch into the outer one", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
const inner = DatabaseSaveTrigger.batched(() =>
DatabaseSaveTrigger.forceSave("inner_write"),
);
await DatabaseSaveTrigger.batched(async () => {
await inner();
await inner();
})();
expect(save).toHaveBeenCalledTimes(1);
});
it("skips the save when a batch wrote nothing", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await DatabaseSaveTrigger.batched(async () => {})();
expect(save).not.toHaveBeenCalled();
});
it("still saves what a failed batch wrote", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await expect(
DatabaseSaveTrigger.batched(async () => {
await DatabaseSaveTrigger.forceSave("row_write");
throw new Error("boom");
})(),
).rejects.toThrow("boom");
expect(save).toHaveBeenCalledTimes(1);
});
it("saves immediately for work that outlives its batch", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let detached: Promise<void> | undefined;
await DatabaseSaveTrigger.batched(async () => {
detached = gate.then(() => DatabaseSaveTrigger.forceSave("late_write"));
})();
release?.();
await detached;
expect(save).toHaveBeenCalledTimes(1);
});
});
+17 -12
View File
@@ -8,6 +8,7 @@
*/
import { databaseLogger } from "../utils/logger.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "./tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "./proxmox-settings-migration.js";
@@ -36,6 +37,7 @@ import { runTotpMigration } from "./totp-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
import { runSsoLegacyIdentityMigration } from "./sso-legacy-identity-migration.js";
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
@@ -68,24 +70,27 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
["runSsoSettingsMigration", runSsoSettingsMigration],
["runSsoLegacyIdentityMigration", runSsoLegacyIdentityMigration],
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
// Last: it reads what the moves above wrote.
["runHostDefaultsMigration", runHostDefaultsMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
async (): Promise<void> => {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
}
await applyHostDefaults();
}
await applyHostDefaults();
},
);
/**
* Classifies hosts nobody classified yet (from an older release, or for a
@@ -79,7 +79,15 @@ export async function runExternalIdentityMigration(): Promise<ExternalIdentityMi
parsed.providerId,
parsed.subject,
);
if (existing) {
// sso-legacy-identity-migration.ts moves "legacy-oidc" rows to
// their provider; recreating one would undo that on every boot.
const moved =
!existing &&
parsed.providerId === LEGACY_OIDC_PROVIDER_ID &&
(await auth.listIdentitiesForUser(user.id)).some(
(link) => link.subject === parsed.subject,
);
if (existing || moved) {
result.skipped++;
} else {
await auth.linkIdentity({
@@ -0,0 +1,19 @@
import type Database from "better-sqlite3";
/** Repair older SQLite tables before adoption, or after an incomplete 2.9 upgrade. */
export function repairSnippetsNoteColumn(sqlite: Database.Database): void {
for (const table of ["snippets", "p_snippets_snippets"]) {
const columns = sqlite.pragma(`table_info("${table}")`) as Array<{
name: string;
}>;
if (
columns.length === 0 ||
columns.some(({ name }) => name === "is_note")
) {
continue;
}
sqlite.exec(
`ALTER TABLE "${table}" ADD COLUMN "is_note" INTEGER NOT NULL DEFAULT 0`,
);
}
}
@@ -0,0 +1,160 @@
/**
* Points 2.8 OIDC sign-ins at the provider row they came from.
*
* external-identity-migration.ts files a bare OIDC subject with no
* users.sso_provider_id under "legacy-oidc", the id the sso plugin gives its
* env-configured provider. Those accounts came from the single 2.8 OIDC
* config, which 2.8 copied into an sso_providers row, and the plugin signs in
* through that row with its id. Without this they could not sign in at all
* (#1381).
*
* Only runs when the answer is clear: the env provider is not what signs in,
* and exactly one OIDC row is from before 2.9. An identity whose target is
* already taken (a duplicate account made by 2.9.0) is left for an admin.
* Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
const SOURCES = ["p_sso_providers", "sso_providers"];
export interface SsoLegacyIdentityMigrationResult {
moved: number;
conflicts: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
function envProviderConfigured(): boolean {
return [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
].every((name) => !!process.env[name]);
}
interface ProviderRow {
id: number;
type: string;
enabled: unknown;
legacy_callback?: unknown;
}
async function readProviders(table: string): Promise<ProviderRow[]> {
try {
return await selectRows<ProviderRow>(
sql`SELECT id, type, enabled, legacy_callback FROM ${sql.identifier(table)}`,
);
} catch {
return await selectRows<ProviderRow>(
sql`SELECT id, type, enabled FROM ${sql.identifier(table)}`,
);
}
}
/** The row "legacy-oidc" sign-ins belong to, or null when that is unclear. */
async function findTargetProvider(): Promise<number | null> {
for (const table of SOURCES) {
if (!(await tableExists(table))) continue;
const rows = await readProviders(table);
// The env provider signs in as "legacy-oidc" when it overrides the rows
// or no row is enabled, so those identities are already right.
const enabled = rows.filter((row) => isOn(row.enabled));
if (
envProviderConfigured() &&
(process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true" ||
enabled.length === 0)
) {
return null;
}
const oidc = rows.filter((row) => row.type === "oidc");
const legacy = oidc.filter(
(row) => row.legacy_callback === undefined || isOn(row.legacy_callback),
);
return legacy.length === 1 ? Number(legacy[0].id) : null;
}
return null;
}
export async function runSsoLegacyIdentityMigration(): Promise<SsoLegacyIdentityMigrationResult> {
const result: SsoLegacyIdentityMigrationResult = { moved: 0, conflicts: 0 };
try {
const target = await findTargetProvider();
if (target === null) return result;
const providerId = String(target);
// "github:null:<id>" also landed on "legacy-oidc", but its identifier
// kept the prefix, so matching the bare column picks only OIDC ones.
const identities = await selectRows<{
id: number;
user_id: string;
subject: string;
}>(
sql`SELECT i.id, i.user_id, i.subject FROM user_external_identities i JOIN users u ON u.id = i.user_id WHERE i.provider_id = ${LEGACY_OIDC_PROVIDER_ID} AND u.oidc_identifier = i.subject`,
);
for (const identity of identities) {
const [taken] = await selectRows<{ user_id: string }>(
sql`SELECT user_id FROM user_external_identities WHERE provider_id = ${providerId} AND subject = ${identity.subject}`,
);
if (taken) {
if (taken.user_id !== identity.user_id) {
result.conflicts++;
databaseLogger.warn(
"An SSO subject is linked to two accounts; merge or delete the duplicate",
{
operation: "sso_legacy_identity_conflict",
userId: identity.user_id,
duplicateUserId: taken.user_id,
providerId,
},
);
continue;
}
await runStatement(
sql`DELETE FROM user_external_identities WHERE id = ${identity.id}`,
);
result.moved++;
continue;
}
await runStatement(
sql`UPDATE user_external_identities SET provider_id = ${providerId} WHERE id = ${identity.id}`,
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info("Moved 2.8 OIDC sign-ins to their provider", {
operation: "sso_legacy_identity_migration",
providerId,
...result,
});
}
} catch (error) {
databaseLogger.error("SSO legacy identity migration failed", error, {
operation: "sso_legacy_identity_migration_failed",
});
}
return result;
}
+63 -2
View File
@@ -1,13 +1,22 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { getErrorMessage } from "./error-message.js";
import { databaseLogger } from "./logger.js";
const DEBOUNCE_MS = 2000;
const MAX_DEBOUNCE_WAIT_MS = 30_000;
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
private static isInitialized = false;
private static pendingSave = false;
private static activeSave: Promise<void> | null = null;
private static saveTimeout: NodeJS.Timeout | null = null;
private static firstTriggerAt: number | null = null;
private static _dirty = false;
private static batch = new AsyncLocalStorage<{
open: boolean;
pending: boolean;
}>();
static initialize(saveFunction: () => Promise<void>): void {
this.saveFunction = saveFunction;
@@ -18,6 +27,11 @@ export class DatabaseSaveTrigger {
return this._dirty;
}
/** Informational changes join the next periodic or critical save. */
static markDirty(): void {
if (this.isInitialized) this._dirty = true;
}
static markClean(): void {
this._dirty = false;
}
@@ -39,8 +53,18 @@ export class DatabaseSaveTrigger {
clearTimeout(this.saveTimeout);
}
// Each call pushes the save back, so steady writes (samples every few
// seconds) would keep it from ever running. Cap how long it can wait.
const now = Date.now();
this.firstTriggerAt ??= now;
const delay = Math.max(
0,
Math.min(DEBOUNCE_MS, this.firstTriggerAt + MAX_DEBOUNCE_WAIT_MS - now),
);
this.saveTimeout = setTimeout(async () => {
this.saveTimeout = null;
this.firstTriggerAt = null;
try {
await this.runSave();
@@ -52,7 +76,28 @@ export class DatabaseSaveTrigger {
error: getErrorMessage(error),
});
}
}, 2000);
}, delay);
}
/**
* Wraps `work` so the force saves made while it runs collapse into one save
* when it finishes. Every save serializes and encrypts the whole database,
* so a loop writing one row per host per setting otherwise rewrites the
* file once per row. Nested scopes fold into the outer one; work that
* outlives its scope (fire-and-forget) saves normally again.
*/
static batched<A extends unknown[], T>(
work: (...args: A) => Promise<T>,
): (...args: A) => Promise<T> {
return async (...args) => {
const scope = { open: true, pending: false };
try {
return await this.batch.run(scope, () => work(...args));
} finally {
scope.open = false;
if (scope.pending) await this.forceSave("batched_writes");
}
};
}
static async forceSave(reason: string = "critical_operation"): Promise<void> {
@@ -67,10 +112,18 @@ export class DatabaseSaveTrigger {
return;
}
const scope = this.batch.getStore();
if (scope?.open) {
scope.pending = true;
this._dirty = true;
return;
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.firstTriggerAt = null;
try {
await this.runSave();
@@ -94,7 +147,13 @@ export class DatabaseSaveTrigger {
}
}
const save = Promise.resolve().then(() => this.saveFunction!());
// Each save serializes the whole database into fresh buffers, and V8 only
// frees them once the event loop turns. A loop of awaited writes (boot
// migrations, bulk import) never yields, so every save's copies piled up
// until the process ran out of memory.
const save = Promise.resolve()
.then(() => this.saveFunction!())
.finally(() => new Promise<void>((resolve) => setImmediate(resolve)));
this.activeSave = save;
this.pendingSave = true;
@@ -125,10 +184,12 @@ export class DatabaseSaveTrigger {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.firstTriggerAt = null;
this.pendingSave = false;
this.activeSave = null;
this.isInitialized = false;
this._dirty = false;
this.saveFunction = null;
}
}
+21
View File
@@ -65,6 +65,21 @@ const swaggerOptions: SwaggerJSDocOptions = {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "Session JWT sent as Authorization: Bearer <JWT>.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "jwt",
description:
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
},
apiKeyAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "tmx_<api key>",
description:
"API key sent as Authorization: Bearer tmx_<api key>. Permissions follow the key owner's account; encrypted data must be unlocked separately. API keys cannot impersonate another user.",
},
},
schemas: {
@@ -81,6 +96,12 @@ const swaggerOptions: SwaggerJSDocOptions = {
{
bearerAuth: [],
},
{
cookieAuth: [],
},
{
apiKeyAuth: [],
},
],
tags: [
{