mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15:24 +00:00
fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
This commit is contained in:
1 parent
beb5145475
commit
f694023a65
2 files changed
+101
-3
No files matched your search
@@ -10,6 +10,9 @@ const state = vi.hoisted(() => ({
|
|||||||
auditCalls: [] as Record<string, unknown>[],
|
auditCalls: [] as Record<string, unknown>[],
|
||||||
folderCredentialId: null as number | null,
|
folderCredentialId: null as number | null,
|
||||||
sharedSecret: null as Record<string, unknown> | 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", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
@@ -29,6 +32,15 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
createCurrentUserRepository: () => ({
|
createCurrentUserRepository: () => ({
|
||||||
findById: async (userId: string) => ({ id: userId, username: userId }),
|
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", () => ({
|
vi.mock("../../utils/audit-logger.js", () => ({
|
||||||
@@ -52,6 +64,26 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
|||||||
SharedHostSecretsManager: {
|
SharedHostSecretsManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
getSecretForUser: async () => state.sharedSecret,
|
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.auditCalls = [];
|
||||||
state.folderCredentialId = null;
|
state.folderCredentialId = null;
|
||||||
state.sharedSecret = null;
|
state.sharedSecret = null;
|
||||||
|
state.activeHostAccessId = null;
|
||||||
|
state.snapshotForUserCalls = [];
|
||||||
|
state.snapshotHeals = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveHostById", () => {
|
describe("resolveHostById", () => {
|
||||||
@@ -224,11 +259,34 @@ describe("resolveHostById", () => {
|
|||||||
expect(host.authType).toBe("password");
|
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 });
|
state.host = baseHost({ shareSshAuth: true });
|
||||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
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 () => {
|
it("keeps SSH agent authentication private unless the owner opts in", async () => {
|
||||||
state.host = baseHost({
|
state.host = baseHost({
|
||||||
authType: "agent",
|
authType: "agent",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
} from "../../types/auth-protocols.js";
|
} from "../../types/auth-protocols.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRbacAccessRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
createCurrentSharedHostAuthOverrideRepository,
|
createCurrentSharedHostAuthOverrideRepository,
|
||||||
} from "../database/repositories/factory.js";
|
} from "../database/repositories/factory.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -107,12 +109,36 @@ export async function resolveRecipientSharedHostAuthentication(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const secret =
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
let secret = await secretsManager.getSecretForUser(
|
||||||
hostId,
|
hostId,
|
||||||
userId,
|
userId,
|
||||||
protocol,
|
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) {
|
if (secret) {
|
||||||
return {
|
return {
|
||||||
source: "owner-shared",
|
source: "owner-shared",
|
||||||
@@ -129,3 +155,17 @@ export async function resolveRecipientSharedHostAuthentication(
|
|||||||
? { source: "required" }
|
? { source: "required" }
|
||||||
: { source: "secretless" };
|
: { 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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user