fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295
This commit is contained in:
LukeGus committed 2026-09-20 19:46:04 -05:00
1 parent beb5145475
commit f694023a65
2 files changed
+105 -7

No files matched your search

+59 -1
View File
@@ -10,6 +10,9 @@ const state = vi.hoisted(() => ({
auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null,
snapshotForUserCalls: [] as Record<string, unknown>[],
snapshotHeals: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -29,6 +32,15 @@ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (userId: string) => ({ id: userId, username: userId }),
}),
createCurrentRoleRepository: () => ({
listUserRoleIds: async () => [],
}),
createCurrentRbacAccessRepository: () => ({
findActiveHostAccess: async () =>
state.activeHostAccessId !== null
? { id: state.activeHostAccessId }
: null,
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
@@ -52,6 +64,26 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
getSecretForUser: async () => state.sharedSecret,
snapshotForUser: async (
hostAccessId: number,
hostId: number,
targetUserId: string,
ownerId: string,
) => {
state.snapshotForUserCalls.push({
hostAccessId,
hostId,
targetUserId,
ownerId,
});
if (state.snapshotHeals) {
state.sharedSecret = {
username: "shared-user",
authType: "key",
key: "HEALED-KEY",
};
}
},
}),
},
}));
@@ -109,6 +141,9 @@ beforeEach(() => {
state.auditCalls = [];
state.folderCredentialId = null;
state.sharedSecret = null;
state.activeHostAccessId = null;
state.snapshotForUserCalls = [];
state.snapshotHeals = false;
});
describe("resolveHostById", () => {
@@ -224,11 +259,34 @@ describe("resolveHostById", () => {
expect(host.authType).toBe("password");
});
it("denies shared secret-backed auth when the opted-in snapshot is missing", async () => {
it("denies shared secret-backed auth when the opted-in snapshot is missing and cannot be healed", async () => {
state.host = baseHost({ shareSshAuth: true });
expect(await resolveHostById(42, "recipient")).toBeNull();
});
it("lazily re-snapshots and resolves shared auth when the grant exists but its snapshot was never created", async () => {
// e.g. the owner shared the host while the recipient's data key was
// unavailable, so snapshotForUser silently skipped at share time.
state.host = baseHost({ shareSshAuth: true, username: "host-user" });
state.activeHostAccessId = 77;
state.snapshotHeals = true;
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(state.snapshotForUserCalls).toEqual([
{
hostAccessId: 77,
hostId: 42,
targetUserId: "recipient",
ownerId: "owner",
},
]);
expect(host.key).toBe("HEALED-KEY");
expect(host.authType).toBe("key");
});
it("keeps SSH agent authentication private unless the owner opts in", async () => {
state.host = baseHost({
authType: "agent",
+46 -6
View File
@@ -4,6 +4,8 @@ import {
} from "../../types/auth-protocols.js";
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../database/repositories/factory.js";
import type {
@@ -107,12 +109,36 @@ export async function resolveRecipientSharedHostAuthentication(
}
try {
const secret =
await SharedHostSecretsManager.getInstance().getSecretForUser(
hostId,
userId,
protocol,
);
const secretsManager = SharedHostSecretsManager.getInstance();
let secret = await secretsManager.getSecretForUser(
hostId,
userId,
protocol,
);
if (!secret) {
// No snapshot yet -- most often the grant was created while this
// recipient's data key was unavailable (snapshotForUser silently
// skips in that case). Mirrors the self-heal in findUsableCredential.
const hostAccessId = await findActiveHostAccessId(hostId, userId);
if (hostAccessId !== null) {
const ownerId = await repository.findHostOwnerId(hostId);
if (ownerId) {
await secretsManager.snapshotForUser(
hostAccessId,
hostId,
userId,
ownerId,
);
secret = await secretsManager.getSecretForUser(
hostId,
userId,
protocol,
);
}
}
}
if (secret) {
return {
source: "owner-shared",
@@ -129,3 +155,17 @@ export async function resolveRecipientSharedHostAuthentication(
? { source: "required" }
: { source: "secretless" };
}
/** The grant id for this user's access to the host, direct or via a role. */
async function findActiveHostAccessId(
hostId: number,
userId: string,
): Promise<number | null> {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const access = await createCurrentRbacAccessRepository().findActiveHostAccess(
hostId,
userId,
roleIds,
);
return access?.id ?? null;
}