diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts index f068e15c1..c0ca69878 100644 --- a/src/backend/tests/hosts/host-resolver.test.ts +++ b/src/backend/tests/hosts/host-resolver.test.ts @@ -10,6 +10,9 @@ const state = vi.hoisted(() => ({ auditCalls: [] as Record[], folderCredentialId: null as number | null, sharedSecret: null as Record | null, + activeHostAccessId: null as number | null, + snapshotForUserCalls: [] as Record[], + 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", diff --git a/src/backend/utils/shared-host-auth-resolver.ts b/src/backend/utils/shared-host-auth-resolver.ts index 611063297..b5a76288b 100644 --- a/src/backend/utils/shared-host-auth-resolver.ts +++ b/src/backend/utils/shared-host-auth-resolver.ts @@ -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 { + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + const access = await createCurrentRbacAccessRepository().findActiveHostAccess( + hostId, + userId, + roleIds, + ); + return access?.id ?? null; +}