feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

@@ -0,0 +1,178 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import {
HostProtocolAuthRepository,
decryptProtocolLogin,
protocolAuthRecordId,
type HostProtocolLogin,
} from "../../../database/repositories/host-protocol-auth-repository.js";
import { FieldCrypto } from "../../../utils/field-crypto.js";
const ownerKey = crypto.randomBytes(32);
const otherKey = crypto.randomBytes(32);
function login(overrides: Partial<HostProtocolLogin> = {}): HostProtocolLogin {
return {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "viewer",
password: "spice-pass",
fields: { display: "2" },
secretFields: { ticket: "t-secret" },
...overrides,
};
}
describe("HostProtocolAuthRepository", () => {
let adapter: TestSqliteDatabase | null = null;
let writes = 0;
afterEach(async () => {
await adapter?.close();
adapter = null;
});
async function createRepository(): Promise<HostProtocolAuthRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('owner-1', 'owner-1', 'hash'),
('owner-2', 'owner-2', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(5, 'owner-1', 'cred-5', 'root', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 'password'),
(43, 'owner-1', 'stage', '10.0.0.43', 22, 'root', 'password'),
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 'password');
`);
writes = 0;
return new HostProtocolAuthRepository(context, () => {
writes++;
});
}
it("round-trips a login with its secrets encrypted at rest", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
const row = await repository.findRow(42, "spice");
expect(row).toMatchObject({
hostId: 42,
userId: "owner-1",
protocol: "spice",
authType: "direct",
username: "viewer",
fields: JSON.stringify({ display: "2" }),
});
expect(FieldCrypto.isEncrypted(row!.password!)).toBe(true);
expect(row!.password).not.toContain("spice-pass");
expect(row!.secretFields).not.toContain("t-secret");
expect(await repository.find(42, "spice", ownerKey)).toEqual(login());
expect(writes).toBe(1);
});
it("updates in place, one row per host and protocol", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
await repository.upsert(
"owner-1",
42,
login({ username: "other", password: null, secretFields: {} }),
ownerKey,
);
expect(await repository.listRowsForHost(42)).toHaveLength(1);
expect(await repository.find(42, "spice", ownerKey)).toMatchObject({
username: "other",
password: null,
secretFields: {},
});
});
it("opens nothing with another user's key", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
expect(await repository.find(42, "spice", otherKey)).toMatchObject({
username: "viewer",
password: null,
secretFields: {},
});
});
it("treats a tampered secret as missing", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
const row = (await repository.findRow(42, "spice"))!;
const envelope = JSON.parse(row.password!);
envelope.data = envelope.data.replace(/^./, (c: string) =>
c === "0" ? "1" : "0",
);
expect(
decryptProtocolLogin(
{ ...row, password: JSON.stringify(envelope) },
ownerKey,
).password,
).toBeNull();
});
it("binds each secret to its host and protocol", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
const row = (await repository.findRow(42, "spice"))!;
expect(JSON.parse(row.password!).recordId).toBe(
protocolAuthRecordId(42, "spice"),
);
expect(() =>
FieldCrypto.decryptField(row.password!, ownerKey, "", "secretFields"),
).toThrow();
});
it("finds the owner's hosts whose login uses a credential", async () => {
const repository = await createRepository();
await repository.upsert(
"owner-1",
42,
login({ authType: "credential", credentialId: 5, password: null }),
ownerKey,
);
await repository.upsert(
"owner-1",
43,
login({ protocol: "x2go" }),
ownerKey,
);
expect(await repository.listHostIdsForCredential("owner-1", 5)).toEqual([
42,
]);
expect(await repository.listHostIdsForCredential("owner-2", 5)).toEqual([]);
expect(
(await repository.listRowsForUser("owner-1"))
.map((row) => row.hostId)
.sort(),
).toEqual([42, 43]);
});
it("deletes one protocol's login", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
await repository.upsert(
"owner-1",
42,
login({ protocol: "x2go" }),
ownerKey,
);
expect(await repository.delete(42, "spice")).toBe(true);
expect(await repository.delete(42, "spice")).toBe(false);
expect(
(await repository.listRowsForHost(42)).map((row) => row.protocol),
).toEqual(["x2go"]);
});
});
@@ -192,9 +192,6 @@ describe("HostResolutionRepository", () => {
await expect(repository.findHostUpdateState(1)).resolves.toEqual({
userId: "user-1",
credentialId: 7,
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
authType: "password",
parentHostId: null,
folder: null,
@@ -36,8 +36,8 @@ describe("RbacAccessRepository", () => {
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
(44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password');
INSERT INTO ssh_data (
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
id, user_id, name, ip, port, username, credential_id, folder, tags, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'servers', 'linux', 'password');
INSERT INTO host_access (
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
@@ -138,9 +138,6 @@ describe("RbacAccessRepository", () => {
{
hostAccessId: 2,
credentialId: 123,
rdpCredentialId: 124,
vncCredentialId: 125,
telnetCredentialId: 126,
hostId: 42,
hostOwnerId: "owner-1",
},
@@ -31,10 +31,13 @@ describe("SharedHostSecretsRepository", () => {
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(123, 'user-1', 'cred-123', 'root', 'password'),
(124, 'user-1', 'cred-124', 'root', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'),
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'),
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'password'),
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, 'password'),
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, 'password');
INSERT INTO host_protocol_auth (host_id, user_id, protocol, auth_type, credential_id)
VALUES (42, 'owner-1', 'spice', 'credential', 124),
(43, 'owner-1', 'spice', 'direct', NULL);
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
VALUES
(1, 42, 'user-1', NULL, 'owner-1'),