mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
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'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
@@ -47,13 +48,13 @@ describe("containsOwnerPrivateAuthUpdate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps protocol field definitions isolated", () => {
|
||||
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "rdp")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "ssh")).toBe(
|
||||
false,
|
||||
);
|
||||
it("leaves plugin protocol logins to their own guard", () => {
|
||||
expect(
|
||||
containsOwnerPrivateAuthUpdate(
|
||||
{ protocolAuth: { spice: { credentialId: 7 } } },
|
||||
"ssh",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows shared editors to update non-authentication host settings", () => {
|
||||
@@ -171,11 +172,41 @@ describe("normalizeImportedHost", () => {
|
||||
expect(host.enableSsh).toBe(true);
|
||||
});
|
||||
|
||||
it("infers rdp from enableRdp and uses default rdp port", () => {
|
||||
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("rdp");
|
||||
expect(host.port).toBe(3389);
|
||||
expect(host.enableSsh).toBe(false);
|
||||
describe("with a plugin protocol declared", () => {
|
||||
afterEach(() => setHostProtocolSource(() => []));
|
||||
|
||||
function declareSpice() {
|
||||
setHostProtocolSource(() => [
|
||||
{
|
||||
id: "spice",
|
||||
defaultPort: 5930,
|
||||
pluginId: "spice-plugin",
|
||||
pluginName: "Spice",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("infers the protocol from its enable flag and uses its default port", () => {
|
||||
declareSpice();
|
||||
const host = normalizeImportedHost({ enableSpice: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("spice");
|
||||
expect(host.port).toBe(5930);
|
||||
expect(host.enableSsh).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the protocol's own port field", () => {
|
||||
declareSpice();
|
||||
const host = normalizeImportedHost({
|
||||
connectionType: "spice",
|
||||
spicePort: 5999,
|
||||
});
|
||||
expect(host.port).toBe(5999);
|
||||
});
|
||||
|
||||
it("ignores the flag of a protocol nobody declares", () => {
|
||||
const host = normalizeImportedHost({ enableSpice: true });
|
||||
expect(host.connectionType).toBe("ssh");
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit port over protocol defaults", () => {
|
||||
@@ -267,26 +298,13 @@ describe("stripSensitiveFields", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips rdp/vnc/telnet passwords and adds their presence flags", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "rdp-box",
|
||||
rdpPassword: "rdp-secret",
|
||||
vncPassword: "vnc-secret",
|
||||
telnetPassword: "telnet-secret",
|
||||
});
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.vncPassword).toBeUndefined();
|
||||
expect(result.telnetPassword).toBeUndefined();
|
||||
expect(result.hasRdpPassword).toBe(true);
|
||||
expect(result.hasVncPassword).toBe(true);
|
||||
expect(result.hasTelnetPassword).toBe(true);
|
||||
});
|
||||
|
||||
it("marks rdp/vnc/telnet presence flags false when absent", () => {
|
||||
const result = stripSensitiveFields({ name: "rdp-box" });
|
||||
expect(result.hasRdpPassword).toBe(false);
|
||||
expect(result.hasVncPassword).toBe(false);
|
||||
expect(result.hasTelnetPassword).toBe(false);
|
||||
it("leaves protocol login summaries alone for the owner", () => {
|
||||
const protocolAuth = {
|
||||
spice: { authType: "direct", hasPassword: true, secretFieldKeys: [] },
|
||||
};
|
||||
expect(stripSensitiveFields({ protocolAuth }).protocolAuth).toEqual(
|
||||
protocolAuth,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,8 +370,17 @@ describe("sanitizeHostForRecipient", () => {
|
||||
password: "hunter2",
|
||||
key: "PRIVATE",
|
||||
sudoPassword: "sudo",
|
||||
rdpPassword: "rdp",
|
||||
socks5Password: "socks",
|
||||
protocolAuth: {
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "0" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
},
|
||||
enableSsh: true,
|
||||
enableRdp: true,
|
||||
sshPort: 22,
|
||||
@@ -371,8 +398,15 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.password).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
expect(result.sudoPassword).toBeUndefined();
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.socks5Password).toBeUndefined();
|
||||
expect(result.protocolAuth).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "0" },
|
||||
},
|
||||
});
|
||||
expect(result.credentialId).toBeUndefined();
|
||||
expect(result.overrideCredentialUsername).toBeUndefined();
|
||||
expect(result.terminalConfig).toEqual({ theme: "termix" });
|
||||
@@ -404,7 +438,6 @@ describe("sanitizeHostForRecipient", () => {
|
||||
{
|
||||
...sharedHost,
|
||||
permissionLevel: "connect",
|
||||
rdpAuthType: "none",
|
||||
authOverrides: {
|
||||
ssh: {
|
||||
credentialId: 9,
|
||||
@@ -417,7 +450,7 @@ describe("sanitizeHostForRecipient", () => {
|
||||
);
|
||||
expect(result.name).toBe("prod");
|
||||
expect(result.ip).toBe("10.0.0.42");
|
||||
expect(result.rdpAuthType).toBe("none");
|
||||
expect(result.protocolAuth).toEqual({ spice: { authType: "direct" } });
|
||||
expect(result.permissionLevel).toBe("connect");
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
expect(result.authOverrides).toEqual({
|
||||
|
||||
@@ -263,12 +263,21 @@ describe("shared host authentication override routes", () => {
|
||||
expect(unauthenticatedResponse.status).toBe(401);
|
||||
});
|
||||
|
||||
it("serves every real protocol and rejects invalid protocol names", async () => {
|
||||
const rdpResponse = await invoke("get", {}, "rdp");
|
||||
expect(rdpResponse.status).toBe(200);
|
||||
expect(rdpResponse.body.protocol).toBe("rdp");
|
||||
it("serves any protocol a plugin declares and rejects the rest", async () => {
|
||||
const { setHostProtocolSource } =
|
||||
await import("../../../hosts/protocol-auth/registry.js");
|
||||
setHostProtocolSource(() => [
|
||||
{ id: "spice", pluginId: "spice-plugin", pluginName: "Spice" },
|
||||
]);
|
||||
const declared = await invoke("get", {}, "spice");
|
||||
setHostProtocolSource(() => []);
|
||||
expect(declared.status).toBe(200);
|
||||
expect(declared.body.protocol).toBe("spice");
|
||||
expect(state.writes).toEqual([]);
|
||||
|
||||
const undeclared = await invoke("get", {}, "spice");
|
||||
expect(undeclared.status).toBe(400);
|
||||
|
||||
const invalidResponse = await invoke("get", {}, "smtp");
|
||||
expect(invalidResponse).toEqual({
|
||||
status: 400,
|
||||
|
||||
BIN
Binary file not shown.
+10
@@ -117,6 +117,8 @@ export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [
|
||||
["ssh_data", "key_password", "keyPassword", "user_id"],
|
||||
["ssh_data", "sudo_password", "sudoPassword", "user_id"],
|
||||
["ssh_data", "rdp_password", "rdpPassword", "user_id"],
|
||||
["ssh_data", "vnc_password", "vncPassword", "user_id"],
|
||||
["ssh_data", "telnet_password", "telnetPassword", "user_id"],
|
||||
["ssh_credentials", "password", "password", "user_id"],
|
||||
["ssh_credentials", "private_key", "privateKey", "user_id"],
|
||||
["ssh_credentials", "key", "key", "user_id"],
|
||||
@@ -251,6 +253,14 @@ const HOST_ROWS: Row[] = [
|
||||
telnet_port: 2323,
|
||||
rdp_user: "d4-rdp-user",
|
||||
rdp_password: "d4-rdp-password",
|
||||
rdp_domain: "D4-DOMAIN",
|
||||
rdp_auth_type: "direct",
|
||||
vnc_user: "d4-vnc-user",
|
||||
vnc_password: "d4-vnc-password",
|
||||
vnc_auth_type: "direct",
|
||||
// Telnet through the saved credential, which has no password of its own.
|
||||
telnet_auth_type: "credential",
|
||||
telnet_credential_id: 1,
|
||||
rdp_security: "nla",
|
||||
rdp_ignore_cert: 1,
|
||||
guacamole_config: JSON.stringify({ colorDepth: 24 }),
|
||||
|
||||
@@ -415,6 +415,34 @@ ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','
|
||||
-- ==== core 0062_smooth_speed ====
|
||||
ALTER TABLE `ssh_data` ADD `ssh_options` text;
|
||||
|
||||
-- ==== core 0063_needy_radioactive_man ====
|
||||
-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth.
|
||||
-- A boot migration copies them after this runs, so the old ssh_data columns
|
||||
-- and their foreign keys stay in place, unused, until 3.0.0.
|
||||
CREATE TABLE `host_protocol_auth` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`protocol` varchar(255) NOT NULL,
|
||||
`auth_type` text NOT NULL DEFAULT ('direct'),
|
||||
`credential_id` int,
|
||||
`username` text,
|
||||
`password` text,
|
||||
`fields` text,
|
||||
`secret_fields` text,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `host_protocol_auth_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `idx_host_protocol_auth_host_protocol` UNIQUE(`host_id`,`protocol`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `shared_host_secrets` ADD `encrypted_fields` text;--> statement-breakpoint
|
||||
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`);
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -435,6 +435,33 @@ DROP TABLE "sync_tombstones" CASCADE;
|
||||
-- ==== core 0062_curvy_killmonger ====
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "ssh_options" text;
|
||||
|
||||
-- ==== core 0063_giant_starfox ====
|
||||
-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth.
|
||||
-- A boot migration copies them after this runs, so the old ssh_data columns
|
||||
-- and their foreign keys stay in place, unused, until 3.0.0.
|
||||
CREATE TABLE "host_protocol_auth" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"protocol" varchar(255) NOT NULL,
|
||||
"auth_type" text DEFAULT 'direct' NOT NULL,
|
||||
"credential_id" integer,
|
||||
"username" text,
|
||||
"password" text,
|
||||
"fields" text,
|
||||
"secret_fields" text,
|
||||
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_secrets" ADD COLUMN "encrypted_fields" text;--> statement-breakpoint
|
||||
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_auth" USING btree ("host_id","protocol");--> statement-breakpoint
|
||||
CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id");
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => null,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (id: number, userId: string) =>
|
||||
id === 5 && userId === "importer" ? { id, username: "u" } : null,
|
||||
}));
|
||||
|
||||
import {
|
||||
fromPortableLogin,
|
||||
keepUsableProtocolCredentials,
|
||||
mergeProtocolLogin,
|
||||
readProtocolAuthPayload,
|
||||
toPortableLogins,
|
||||
} from "../../../hosts/protocol-auth/protocol-auth.js";
|
||||
import {
|
||||
setHostProtocolSource,
|
||||
type DeclaredHostProtocol,
|
||||
} from "../../../hosts/protocol-auth/registry.js";
|
||||
import type { HostProtocolLogin } from "../../../database/repositories/host-protocol-auth-repository.js";
|
||||
|
||||
const SPICE: DeclaredHostProtocol = {
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
pluginId: "spice-plugin",
|
||||
pluginName: "Spice",
|
||||
};
|
||||
const REMOTE_X: DeclaredHostProtocol = {
|
||||
id: "remote-x",
|
||||
credentialFields: [{ key: "domain" }],
|
||||
pluginId: "x-plugin",
|
||||
pluginName: "X",
|
||||
};
|
||||
|
||||
const stored: HostProtocolLogin = {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
password: "saved",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-saved" },
|
||||
};
|
||||
|
||||
beforeEach(() => setHostProtocolSource(() => [SPICE, REMOTE_X]));
|
||||
afterEach(() => setHostProtocolSource(() => []));
|
||||
|
||||
describe("readProtocolAuthPayload", () => {
|
||||
it("reads protocolAuth for declared protocols only", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: {
|
||||
spice: { username: "a" },
|
||||
"remote-x": null,
|
||||
stranger: { username: "b" },
|
||||
},
|
||||
});
|
||||
expect(patch && Object.fromEntries(patch)).toEqual({
|
||||
spice: { username: "a" },
|
||||
"remote-x": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the flat fields a 2.8 client sends, named after the protocol", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
spiceUser: "viewer",
|
||||
spicePassword: "pw",
|
||||
spiceDisplay: "3",
|
||||
spiceAuthType: "direct",
|
||||
remoteXUser: "x-user",
|
||||
remoteXDomain: "CORP",
|
||||
remoteXCredentialId: 9,
|
||||
});
|
||||
expect(patch && Object.fromEntries(patch)).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "pw",
|
||||
fields: { display: "3" },
|
||||
},
|
||||
"remote-x": {
|
||||
credentialId: 9,
|
||||
username: "x-user",
|
||||
fields: { domain: "CORP" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a 2.8 editor's null password as leaving it alone", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
spiceAuthType: "direct",
|
||||
spiceUser: "viewer",
|
||||
spicePassword: null,
|
||||
});
|
||||
expect(patch?.get("spice")).toEqual({
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a 2.8 editor's all-null protocol as removed", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
spiceAuthType: null,
|
||||
spiceUser: null,
|
||||
spicePassword: null,
|
||||
spiceCredentialId: null,
|
||||
});
|
||||
expect(patch?.get("spice")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers null when a write carries no logins", () => {
|
||||
expect(readProtocolAuthPayload({ name: "host" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeProtocolLogin", () => {
|
||||
it("keeps what the input leaves out, including secrets", () => {
|
||||
expect(mergeProtocolLogin("spice", SPICE, stored, {})).toEqual(stored);
|
||||
});
|
||||
|
||||
it("keeps a saved password and secret for an empty value", () => {
|
||||
const next = mergeProtocolLogin("spice", SPICE, stored, {
|
||||
password: "",
|
||||
fields: { ticket: "", display: "" },
|
||||
});
|
||||
expect(next.password).toBe("saved");
|
||||
expect(next.secretFields).toEqual({ ticket: "t-saved" });
|
||||
// A plain field can be cleared.
|
||||
expect(next.fields).toEqual({});
|
||||
});
|
||||
|
||||
it("drops the direct login and undeclared fields for a credential", () => {
|
||||
const next = mergeProtocolLogin("spice", SPICE, stored, {
|
||||
authType: "credential",
|
||||
credentialId: "12",
|
||||
fields: { unknown: "x" },
|
||||
});
|
||||
expect(next).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 12,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "2" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a new login from the input", () => {
|
||||
expect(
|
||||
mergeProtocolLogin("spice", SPICE, null, {
|
||||
username: "new",
|
||||
password: "pw",
|
||||
fields: { ticket: "t" },
|
||||
}),
|
||||
).toEqual({
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "new",
|
||||
password: "pw",
|
||||
fields: {},
|
||||
secretFields: { ticket: "t" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to direct for an auth type it does not know", () => {
|
||||
expect(
|
||||
mergeProtocolLogin("spice", SPICE, null, { authType: "magic" }).authType,
|
||||
).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
describe("portable logins", () => {
|
||||
it("round-trips through an export, splitting fields by the declaration", () => {
|
||||
const portable = toPortableLogins([stored]);
|
||||
expect(portable.spice.fields).toEqual({ display: "2", ticket: "t-saved" });
|
||||
expect(fromPortableLogin("spice", portable.spice, null)).toEqual(stored);
|
||||
});
|
||||
|
||||
it("keeps every field of an undeclared protocol secret", () => {
|
||||
expect(
|
||||
fromPortableLogin("gone", { fields: { display: "1" } }, null)
|
||||
?.secretFields,
|
||||
).toEqual({ display: "1" });
|
||||
});
|
||||
|
||||
it("drops a credential link a user cannot use", async () => {
|
||||
const patch = await keepUsableProtocolCredentials(
|
||||
new Map([
|
||||
["spice", { authType: "credential", credentialId: 5 }],
|
||||
["remote-x", { authType: "credential", credentialId: 6 }],
|
||||
]),
|
||||
"importer",
|
||||
);
|
||||
expect(patch.get("spice")).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 5,
|
||||
});
|
||||
expect(patch.get("remote-x")).toEqual({
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
findHostProtocol,
|
||||
isAuthOverrideProtocol,
|
||||
listHostProtocols,
|
||||
plainFieldKeys,
|
||||
secretFieldKeys,
|
||||
setHostProtocolSource,
|
||||
type DeclaredHostProtocol,
|
||||
} from "../../../hosts/protocol-auth/registry.js";
|
||||
|
||||
const SPICE: DeclaredHostProtocol = {
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
pluginId: "spice-plugin",
|
||||
pluginName: "Spice",
|
||||
};
|
||||
|
||||
afterEach(() => setHostProtocolSource(() => []));
|
||||
|
||||
describe("the host protocol registry", () => {
|
||||
it("knows nothing until a manifest declares it", () => {
|
||||
expect(listHostProtocols()).toEqual([]);
|
||||
expect(findHostProtocol("spice")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the first plugin to declare an id", () => {
|
||||
setHostProtocolSource(() => [
|
||||
SPICE,
|
||||
{ ...SPICE, pluginId: "late", pluginName: "Late" },
|
||||
]);
|
||||
expect(listHostProtocols()).toHaveLength(1);
|
||||
expect(findHostProtocol("spice")?.pluginId).toBe("spice-plugin");
|
||||
});
|
||||
|
||||
it("allows overrides for ssh and declared protocols only", () => {
|
||||
setHostProtocolSource(() => [SPICE]);
|
||||
expect(isAuthOverrideProtocol("ssh")).toBe(true);
|
||||
expect(isAuthOverrideProtocol("spice")).toBe(true);
|
||||
expect(isAuthOverrideProtocol("rdp")).toBe(false);
|
||||
expect(isAuthOverrideProtocol(7)).toBe(false);
|
||||
});
|
||||
|
||||
it("splits declared fields into plain and secret", () => {
|
||||
expect(plainFieldKeys(SPICE)).toEqual(["display"]);
|
||||
expect(secretFieldKeys(SPICE)).toEqual(["ticket"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeProtocolAuthForRecipient } from "../../../hosts/protocol-auth/summary.js";
|
||||
|
||||
const summaries = {
|
||||
spice: {
|
||||
authType: "credential",
|
||||
credentialId: 4,
|
||||
username: null,
|
||||
fields: { display: "2" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("sanitizeProtocolAuthForRecipient", () => {
|
||||
it("keeps only the auth type at connect level", () => {
|
||||
expect(sanitizeProtocolAuthForRecipient(summaries, "connect")).toEqual({
|
||||
spice: { authType: "credential" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never says whether secrets are set, at any level", () => {
|
||||
for (const level of ["view", "edit", "manage"]) {
|
||||
expect(sanitizeProtocolAuthForRecipient(summaries, level)).toEqual({
|
||||
spice: {
|
||||
authType: "credential",
|
||||
credentialId: 4,
|
||||
username: null,
|
||||
fields: { display: "2" },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("answers an empty map for anything else", () => {
|
||||
expect(sanitizeProtocolAuthForRecipient(null, "view")).toEqual({});
|
||||
expect(sanitizeProtocolAuthForRecipient({ spice: 3 }, "view")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
|
||||
savedCredentials: [] as Array<Record<string, unknown>>,
|
||||
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
|
||||
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
|
||||
logins: new Map<string, Record<string, unknown>>(),
|
||||
ownerDecrypts: 0,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -40,6 +42,30 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
return row;
|
||||
},
|
||||
}),
|
||||
createCurrentHostProtocolAuthRepository: () => ({
|
||||
find: async (hostId: number, protocol: string) => {
|
||||
state.ownerDecrypts++;
|
||||
return state.logins.get(`${hostId}:${protocol}`) ?? null;
|
||||
},
|
||||
findRow: async (hostId: number, protocol: string) => {
|
||||
const login = state.logins.get(`${hostId}:${protocol}`);
|
||||
return login
|
||||
? {
|
||||
hostId,
|
||||
protocol,
|
||||
authType: login.authType,
|
||||
credentialId: login.credentialId ?? null,
|
||||
username: login.username ?? null,
|
||||
password: "sealed",
|
||||
fields: JSON.stringify(login.fields ?? {}),
|
||||
secretFields: "sealed",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
listCredentialIds: async () => ({}),
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||
@@ -49,6 +75,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
validateUserAccess: () => Buffer.alloc(32),
|
||||
getUserDataKey: () => Buffer.alloc(32),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (id: number) => state.credentials.get(id) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
@@ -84,6 +121,13 @@ import {
|
||||
} from "../../hosts/connect/secret-resolver-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import ssh2 from "ssh2";
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
|
||||
const SPICE = {
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
hostLoginFallback: ["password" as const],
|
||||
};
|
||||
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
@@ -114,13 +158,13 @@ const baseHost = {
|
||||
username: "sshuser",
|
||||
password: "ssh-secret",
|
||||
jumpHosts: JSON.stringify([{ hostId: 3 }]),
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "rdp-secret",
|
||||
rdpDomain: "CORP",
|
||||
rdpAuthType: null,
|
||||
rdpCredentialId: null,
|
||||
};
|
||||
|
||||
/** The demo plugin declares SPICE; nothing in core knows it. */
|
||||
function spiceContext(capabilities: string[]) {
|
||||
return contextFor(capabilities, { protocols: [SPICE] });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
@@ -134,17 +178,32 @@ beforeEach(() => {
|
||||
state.userCredentials.clear();
|
||||
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
|
||||
state.hosts.set(7, { ...baseHost });
|
||||
state.logins.clear();
|
||||
state.ownerDecrypts = 0;
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "admin",
|
||||
password: "spice-secret",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-123" },
|
||||
});
|
||||
setHostProtocolSource(() => [
|
||||
{ ...SPICE, pluginId: "demo", pluginName: "demo" },
|
||||
{ id: "rival", pluginId: "other", pluginName: "other" },
|
||||
]);
|
||||
resetSecretResolverRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
it("refuses without credentials:read and audits the refusal", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor([]);
|
||||
const ctx = spiceContext([]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
),
|
||||
).rejects.toThrow(/credentials:read/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
@@ -153,12 +212,31 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a protocol the plugin does not declare, or another plugin owns", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = contextFor(["credentials:read"], {
|
||||
protocols: [SPICE, { id: "rival" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "vnc"),
|
||||
),
|
||||
).rejects.toThrow(/does not declare/);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rival"),
|
||||
),
|
||||
).rejects.toThrow(/does not declare/);
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the owner's stored login and audits it", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target).toEqual({
|
||||
host: {
|
||||
@@ -173,8 +251,8 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
auth: {
|
||||
authType: "direct",
|
||||
username: "admin",
|
||||
password: "rdp-secret",
|
||||
domain: "CORP",
|
||||
password: "spice-secret",
|
||||
fields: { display: "2", ticket: "t-123" },
|
||||
},
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
@@ -183,23 +261,52 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stored credential in credential mode", async () => {
|
||||
it("falls back to the host's own password as declared", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.hosts.set(7, {
|
||||
...baseHost,
|
||||
vncCredentialId: 11,
|
||||
vncAuthType: "credential",
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
});
|
||||
state.credentials.set(11, { username: "viewer", password: "vnc-pass" });
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "vnc"),
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "",
|
||||
password: "ssh-secret",
|
||||
fields: { display: "", ticket: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stored credential in credential mode", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "credential",
|
||||
credentialId: 11,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "1" },
|
||||
secretFields: {},
|
||||
});
|
||||
state.credentials.set(11, { username: "viewer", password: "cred-pass" });
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "viewer",
|
||||
password: "vnc-pass",
|
||||
password: "cred-pass",
|
||||
fields: { display: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,50 +315,56 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
state.resolution = {
|
||||
source: "owner-shared",
|
||||
authType: "direct",
|
||||
secret: { username: "shared", password: "snap", domain: null },
|
||||
secret: {
|
||||
username: "shared",
|
||||
password: "snap",
|
||||
fields: { ticket: "snap-ticket" },
|
||||
},
|
||||
};
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.shared).toBe(true);
|
||||
expect(target?.auth).toMatchObject({
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "shared",
|
||||
password: "snap",
|
||||
fields: { display: "2", ticket: "snap-ticket" },
|
||||
});
|
||||
expect(state.resolverCalls[0]).toMatchObject({
|
||||
password: null,
|
||||
rdpUser: null,
|
||||
rdpPassword: null,
|
||||
});
|
||||
expect(state.resolverCalls[0]).toMatchObject({ password: null });
|
||||
// The owner's login is never decrypted for a recipient.
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("gives a recipient with nothing shared an empty login", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = { source: "required" };
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "telnet"),
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth.username).toBe("");
|
||||
expect(target?.auth.password).toBe("");
|
||||
expect(target?.auth.fields.ticket).toBe("");
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null without connect access or for a missing host", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.canConnect = false;
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(99, "rdp"),
|
||||
ctx.credentials.resolveHostProtocol(99, "spice"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
|
||||
@@ -759,3 +759,67 @@ describe("contributes.keybindingActions", () => {
|
||||
expect(errors).toMatch(/d/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.protocols", () => {
|
||||
const withProtocols = (protocols: unknown) =>
|
||||
validateManifest(base({ contributes: { protocols } }));
|
||||
|
||||
it("accepts protocols with credential fields", () => {
|
||||
expect(
|
||||
withProtocols([
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [
|
||||
{ key: "display" },
|
||||
{ key: "ticket", secret: true },
|
||||
],
|
||||
defaultPort: 5930,
|
||||
hostLoginFallback: ["username", "password"],
|
||||
},
|
||||
{ id: "x2go" },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses core's protocol, duplicates and bad ids", () => {
|
||||
const errors = withProtocols([
|
||||
{ id: "ssh" },
|
||||
{ id: "spice" },
|
||||
{ id: "spice" },
|
||||
{ id: "Bad_Id" },
|
||||
]);
|
||||
expect(errors.join("\n")).toMatch(/"ssh" belongs to core/);
|
||||
expect(errors.join("\n")).toMatch(/duplicates "spice"/);
|
||||
expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/);
|
||||
});
|
||||
|
||||
it("refuses field keys every login already has, and duplicate keys", () => {
|
||||
const errors = withProtocols([
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [
|
||||
{ key: "password" },
|
||||
{ key: "display" },
|
||||
{ key: "display", secret: "yes" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(errors.join("\n")).toMatch(/"password" is part of every login/);
|
||||
expect(errors.join("\n")).toMatch(/duplicates "display"/);
|
||||
expect(errors.join("\n")).toMatch(/secret must be a boolean/);
|
||||
});
|
||||
|
||||
it("refuses unknown keys, bad ports and bad fallbacks", () => {
|
||||
const errors = withProtocols([
|
||||
{
|
||||
id: "spice",
|
||||
defaultPort: 70000,
|
||||
hostLoginFallback: ["key"],
|
||||
extra: true,
|
||||
},
|
||||
]);
|
||||
expect(errors.join("\n")).toMatch(/defaultPort must be a port number/);
|
||||
expect(errors.join("\n")).toMatch(/hostLoginFallback/);
|
||||
expect(errors.join("\n")).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* A fixture plugin declares a protocol core has never heard of ("spice") and
|
||||
* gets a per-host login stored, encrypted, shared with a recipient (and
|
||||
* overridden by them) and resolved, with no core change: only the manifest
|
||||
* declaration. Runs on a real database with real repositories, sharing
|
||||
* manager and override service; only keys, permissions and audit are faked.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
|
||||
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as null | { drizzle: unknown },
|
||||
keys: new Map<string, Buffer>(),
|
||||
levels: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => state.db!.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: async (id: string) => id,
|
||||
getRequestMeta: () => ({}),
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../utils/data-crypto.js")>();
|
||||
const DataCrypto = actual.DataCrypto as unknown as Record<string, unknown>;
|
||||
DataCrypto.getUserDataKey = (userId: string) =>
|
||||
state.keys.get(userId) ?? null;
|
||||
DataCrypto.validateUserAccess = (userId: string) => {
|
||||
const key = state.keys.get(userId);
|
||||
if (!key) throw new Error(`User ${userId} has no data encryption key`);
|
||||
return key;
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (userId: string, _hostId: number) => {
|
||||
const level = state.levels.get(userId);
|
||||
return level
|
||||
? { hasAccess: true, isShared: true, permissionLevel: level }
|
||||
: { hasAccess: false };
|
||||
},
|
||||
hasPermission: async () => true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
loadProtocolAuthSummaries,
|
||||
ProtocolAuthWriteError,
|
||||
readProtocolAuthPayload,
|
||||
writeProtocolAuth,
|
||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
|
||||
import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js";
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const HOST_ID = 10;
|
||||
const GRANT_ID = 1;
|
||||
|
||||
const manifest = {
|
||||
id: "fixture-spice",
|
||||
name: "Fixture Spice",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["credentials:read"],
|
||||
contributes: {
|
||||
protocols: [
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PluginManifest;
|
||||
|
||||
let database: TestSqliteDatabase | null = null;
|
||||
let context: DatabaseContext;
|
||||
|
||||
async function exec(query: string): Promise<void> {
|
||||
await context.drizzle.run(sql.raw(query));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
database = new TestSqliteDatabase("sqlite");
|
||||
context = await database.connect();
|
||||
state.db = context;
|
||||
state.keys = new Map([
|
||||
["owner", crypto.randomBytes(32)],
|
||||
["recipient", crypto.randomBytes(32)],
|
||||
]);
|
||||
state.levels = new Map([["recipient", "connect"]]);
|
||||
for (const statement of [
|
||||
`INSERT INTO users (id, username, password_hash) VALUES
|
||||
('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`,
|
||||
`INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type)
|
||||
VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'),
|
||||
(6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`,
|
||||
`INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type)
|
||||
VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`,
|
||||
`INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level)
|
||||
VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`,
|
||||
`INSERT INTO plugins (id, name, version, state, manifest_json)
|
||||
VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`,
|
||||
`INSERT INTO plugin_permission_grants (plugin_id, capability, source)
|
||||
VALUES ('fixture-spice', 'credentials:read', 'bundled')`,
|
||||
]) {
|
||||
await exec(statement);
|
||||
}
|
||||
invalidatePluginPermissionCache();
|
||||
// What the loader does with every installed manifest.
|
||||
setHostProtocolSource(() =>
|
||||
manifest.contributes!.protocols!.map((protocol) => ({
|
||||
...protocol,
|
||||
pluginId: manifest.id,
|
||||
pluginName: manifest.name,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setHostProtocolSource(() => []);
|
||||
await database?.close();
|
||||
database = null;
|
||||
});
|
||||
|
||||
function pluginCtx() {
|
||||
return createPluginContext(
|
||||
manifest,
|
||||
createPluginHandle(manifest.id, { activate: () => {} }),
|
||||
);
|
||||
}
|
||||
|
||||
async function storeLogin(): Promise<void> {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: {
|
||||
spice: {
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-secret" },
|
||||
},
|
||||
unknown: { username: "ignored" },
|
||||
},
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
||||
}
|
||||
|
||||
async function storedRow(): Promise<Record<string, unknown>> {
|
||||
const rows = (await context.drizzle.all(
|
||||
sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`),
|
||||
)) as Record<string, unknown>[];
|
||||
expect(rows).toHaveLength(1);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
describe("a plugin protocol core does not know", () => {
|
||||
it("stores the login encrypted with the owner's key, secrets apart", async () => {
|
||||
await storeLogin();
|
||||
const row = await storedRow();
|
||||
|
||||
expect(row.protocol).toBe("spice");
|
||||
expect(row.username).toBe("viewer");
|
||||
expect(JSON.parse(row.fields as string)).toEqual({ display: "2" });
|
||||
expect(String(row.password)).not.toContain("spice-pass");
|
||||
expect(String(row.secret_fields)).not.toContain("t-secret");
|
||||
expect(
|
||||
FieldCrypto.decryptField(
|
||||
row.password as string,
|
||||
state.keys.get("owner")!,
|
||||
"",
|
||||
"password",
|
||||
),
|
||||
).toBe("spice-pass");
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(
|
||||
row.password as string,
|
||||
state.keys.get("recipient")!,
|
||||
"",
|
||||
"password",
|
||||
),
|
||||
).toThrow();
|
||||
|
||||
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
||||
expect(summaries.get(HOST_ID)).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "2" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the owner's login for the declaring plugin", async () => {
|
||||
await storeLogin();
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.shared).toBe(false);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a tampered password as missing, never as a value", async () => {
|
||||
await storeLogin();
|
||||
const row = await storedRow();
|
||||
const envelope = JSON.parse(row.password as string);
|
||||
envelope.tag = "0".repeat(envelope.tag.length);
|
||||
await exec(
|
||||
`UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`,
|
||||
);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth.password).toBe("");
|
||||
expect(target?.auth.fields.ticket).toBe("t-secret");
|
||||
});
|
||||
|
||||
it("gives a recipient the shared snapshot, not the owner's secret", async () => {
|
||||
await storeLogin();
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUser(
|
||||
GRANT_ID,
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"owner",
|
||||
);
|
||||
// The owner's row loses its password without a resync: the recipient
|
||||
// keeps reading their own copy, never the owner's row.
|
||||
await exec(
|
||||
`UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`,
|
||||
);
|
||||
|
||||
const target = await runAsActor("recipient", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.shared).toBe(true);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the recipient's own override over the shared snapshot", async () => {
|
||||
await storeLogin();
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUser(
|
||||
GRANT_ID,
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"owner",
|
||||
);
|
||||
await SharedHostAuthOverrideService.getInstance().setCredentialId(
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"spice",
|
||||
5,
|
||||
);
|
||||
|
||||
const target = await runAsActor("recipient", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "credential",
|
||||
username: "own-user",
|
||||
password: "own-pass",
|
||||
// The owner's plain fields, never their secret ones.
|
||||
fields: { display: "2", ticket: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an override for a protocol nobody declares", async () => {
|
||||
await expect(
|
||||
SharedHostAuthOverrideService.getInstance().setCredentialId(
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"made-up",
|
||||
5,
|
||||
),
|
||||
).rejects.toThrow(/No plugin declares/);
|
||||
});
|
||||
|
||||
it("follows a credential the owner points the login at", async () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "cred-user",
|
||||
password: "cred-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a shared editor change the login but not its credential", async () => {
|
||||
await storeLogin();
|
||||
const rename = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { username: "renamed", password: "" } },
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false });
|
||||
expect((await storedRow()).username).toBe("renamed");
|
||||
// An empty password from an editor who cannot see it keeps it.
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth.password).toBe("spice-pass");
|
||||
|
||||
const repoint = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
||||
});
|
||||
await expect(
|
||||
writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }),
|
||||
).rejects.toBeInstanceOf(ProtocolAuthWriteError);
|
||||
const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } });
|
||||
await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false });
|
||||
expect(
|
||||
(await context.drizzle.all(
|
||||
sql.raw(`SELECT id FROM host_protocol_auth`),
|
||||
)) as unknown[],
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows each share level only what it may see", async () => {
|
||||
await storeLogin();
|
||||
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
||||
const host = {
|
||||
id: HOST_ID,
|
||||
name: "desk",
|
||||
ip: "10.0.0.10",
|
||||
protocolAuth: summaries.get(HOST_ID),
|
||||
};
|
||||
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...host }, "connect").protocolAuth,
|
||||
).toEqual({ spice: { authType: "direct" } });
|
||||
for (const level of ["view", "edit", "manage"]) {
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...host }, level).protocolAuth,
|
||||
level,
|
||||
).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "2" },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,6 @@ function secretHost(id: number): Record<string, unknown> {
|
||||
keyPassword: "s3cret-value",
|
||||
sudoPassword: "s3cret-value",
|
||||
socks5Password: "s3cret-value",
|
||||
rdpPassword: "s3cret-value",
|
||||
vncPassword: "s3cret-value",
|
||||
telnetPassword: "s3cret-value",
|
||||
autostartPassword: "s3cret-value",
|
||||
autostartKey: "s3cret-value",
|
||||
autostartKeyPassword: "s3cret-value",
|
||||
|
||||
@@ -420,11 +420,45 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
});
|
||||
|
||||
// Every 2.8 RDP, VNC and Telnet login reads back through
|
||||
// host_protocol_auth, the only place core reads them from now.
|
||||
const ctx = await pluginCtx("remote-desktop");
|
||||
const target = await asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
|
||||
const resolve = (hostId: number, protocol: string) =>
|
||||
asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(hostId, protocol),
|
||||
);
|
||||
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
fields: { domain: "D4-DOMAIN" },
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-vnc-user",
|
||||
password: "d4-vnc-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "deploy",
|
||||
});
|
||||
|
||||
const logins = await selectRows<{ host_id: number; protocol: string }>(
|
||||
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
|
||||
);
|
||||
expect(target?.auth.password).toBe("d4-rdp-password");
|
||||
expect(
|
||||
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
|
||||
).toEqual([
|
||||
`${HOSTS.rdpOnly}:rdp`,
|
||||
`${HOSTS.desktop}:rdp`,
|
||||
`${HOSTS.desktop}:telnet`,
|
||||
`${HOSTS.desktop}:vnc`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers an SSH auth provider for every 2.8 auth type", async () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* A host's plugin protocol logins over sync: out with the credential named
|
||||
* by syncId, back in with it translated to the local id.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
logins: [] as Array<Record<string, unknown>>,
|
||||
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../hosts/protocol-auth/protocol-auth.js",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("../../hosts/protocol-auth/protocol-auth.js")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
listProtocolLogins: async () => state.logins,
|
||||
replaceProtocolLogins: async (
|
||||
ownerId: string,
|
||||
hostId: number,
|
||||
logins: unknown,
|
||||
) => {
|
||||
state.replaced = { ownerId, hostId, logins };
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => null,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
|
||||
import {
|
||||
exportProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "../../sync/host-protocol-auth.js";
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.replaced = null;
|
||||
state.logins = [
|
||||
{
|
||||
protocol: "spice",
|
||||
authType: "credential",
|
||||
credentialId: 4,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t" },
|
||||
},
|
||||
];
|
||||
setHostProtocolSource(() => [
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
pluginId: "p",
|
||||
pluginName: "P",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("host protocol logins over sync", () => {
|
||||
it("sends each login with its credential as a syncId", async () => {
|
||||
const wire = await exportProtocolLogins(1, "owner", async (type, id) =>
|
||||
type === "sshCredentials" && id === 4 ? "cred-sync-4" : null,
|
||||
);
|
||||
expect(wire).toEqual({
|
||||
spice: {
|
||||
authType: "credential",
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "2", ticket: "t" },
|
||||
credentialSyncId: "cred-sync-4",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes what arrived with the credential's local id", async () => {
|
||||
await importProtocolLogins(
|
||||
9,
|
||||
"owner",
|
||||
{
|
||||
spice: {
|
||||
authType: "credential",
|
||||
fields: { display: "3", ticket: "t2" },
|
||||
credentialSyncId: "cred-sync-4",
|
||||
},
|
||||
},
|
||||
async (type, syncId) =>
|
||||
type === "sshCredentials" && syncId === "cred-sync-4" ? 40 : null,
|
||||
);
|
||||
expect(state.replaced).toEqual({
|
||||
ownerId: "owner",
|
||||
hostId: 9,
|
||||
logins: [
|
||||
{
|
||||
protocol: "spice",
|
||||
authType: "credential",
|
||||
credentialId: 40,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "3" },
|
||||
secretFields: { ticket: "t2" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the current credential while its target has not arrived", async () => {
|
||||
await importProtocolLogins(
|
||||
9,
|
||||
"owner",
|
||||
{ spice: { authType: "credential", credentialSyncId: "later" } },
|
||||
async () => null,
|
||||
);
|
||||
expect(
|
||||
(state.replaced?.logins as Array<{ credentialId: number }>)[0]
|
||||
.credentialId,
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("leaves the logins alone for a peer that sends none", async () => {
|
||||
await importProtocolLogins(9, "owner", undefined, async () => null);
|
||||
expect(state.replaced).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* The 2.8 RDP, VNC and Telnet logins move out of the ssh_data columns into
|
||||
* host_protocol_auth. Every shape a 2.8 install could hold must come across
|
||||
* (encrypted under the column's name, the blob 2.8 copied from `password`,
|
||||
* plaintext, credential mode, the pre-protocol-columns hosts), a second run
|
||||
* must change nothing, and a login the user removed afterwards must not come
|
||||
* back. Runs on SQLite, or on TEST_DIALECT's server.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as null | { drizzle: unknown },
|
||||
keys: new Map<string, Buffer>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => state.db!.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: (userId: string) => state.keys.get(userId) ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
copiedMarker,
|
||||
runProtocolAuthMigration,
|
||||
} from "../../upgrade/protocol-auth-migration.js";
|
||||
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const ownerKey = crypto.randomBytes(32);
|
||||
|
||||
let database: TestSqliteDatabase | null = null;
|
||||
|
||||
function sealed(value: string, hostId: number, field: string): string {
|
||||
return FieldCrypto.encryptField(value, ownerKey, String(hostId), field);
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
database = new TestSqliteDatabase();
|
||||
state.db = await database.connect();
|
||||
state.keys = new Map([["owner", ownerKey]]);
|
||||
await database.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('owner', 'owner', 'x'), ('locked', 'locked', 'x');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(5, 'owner', 'vnc-cred', 'viewer', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
|
||||
rdp_user, rdp_password, rdp_domain, rdp_auth_type)
|
||||
VALUES (1, 'owner', 'ssh', 'win', '10.0.0.1', 22, 'root', 'password',
|
||||
'admin', ${quote(sealed("rdp-pass", 1, "rdpPassword"))}, 'CORP', 'direct');
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
|
||||
vnc_auth_type, vnc_credential_id)
|
||||
VALUES (2, 'owner', 'ssh', 'vnc', '10.0.0.2', 22, 'root', 'password', 'credential', 5);
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
|
||||
telnet_user, telnet_password)
|
||||
VALUES (3, 'owner', 'ssh', 'switch', '10.0.0.3', 22, 'root', 'password', 'tu', 'tel-plain');
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, password,
|
||||
domain, auth_type)
|
||||
VALUES (4, 'owner', 'rdp', 'old', '10.0.0.4', 3389, 'old-user',
|
||||
${quote(sealed("old-pass", 4, "password"))}, 'OLDDOM', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
|
||||
rdp_user, rdp_password, rdp_auth_type)
|
||||
VALUES (5, 'owner', 'ssh', 'copied', '10.0.0.5', 22, 'root', 'password',
|
||||
'copied', ${quote(sealed("copied-pass", 5, "password"))}, 'none');
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type)
|
||||
VALUES (6, 'owner', 'ssh', 'plain-ssh', '10.0.0.6', 22, 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
|
||||
rdp_user)
|
||||
VALUES (7, 'locked', 'ssh', 'later', '10.0.0.7', 22, 'root', 'password', 'waits');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close();
|
||||
database = null;
|
||||
});
|
||||
|
||||
async function logins() {
|
||||
const repository = createCurrentHostProtocolAuthRepository();
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const hostId of [1, 2, 3, 4, 5, 6]) {
|
||||
for (const login of await repository.listForHost(hostId, ownerKey)) {
|
||||
out[`${hostId}:${login.protocol}`] = login;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const EXPECTED = {
|
||||
"1:rdp": {
|
||||
protocol: "rdp",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "admin",
|
||||
password: "rdp-pass",
|
||||
fields: { domain: "CORP" },
|
||||
secretFields: {},
|
||||
},
|
||||
"2:vnc": {
|
||||
protocol: "vnc",
|
||||
authType: "credential",
|
||||
credentialId: 5,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
},
|
||||
"3:telnet": {
|
||||
protocol: "telnet",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "tu",
|
||||
password: "tel-plain",
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
},
|
||||
"4:rdp": {
|
||||
protocol: "rdp",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "old-user",
|
||||
password: "old-pass",
|
||||
fields: { domain: "OLDDOM" },
|
||||
secretFields: {},
|
||||
},
|
||||
"5:rdp": {
|
||||
protocol: "rdp",
|
||||
authType: "none",
|
||||
credentialId: null,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
describe("runProtocolAuthMigration", () => {
|
||||
it("copies every 2.8 login shape, and a second run changes nothing", async () => {
|
||||
const first = await runProtocolAuthMigration();
|
||||
expect(first).toEqual({ copied: 5, pendingUsers: 1 });
|
||||
expect(await logins()).toEqual(EXPECTED);
|
||||
|
||||
const second = await runProtocolAuthMigration();
|
||||
expect(second).toEqual({ copied: 0, pendingUsers: 1 });
|
||||
expect(await logins()).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it("leaves the legacy columns in place", async () => {
|
||||
await runProtocolAuthMigration();
|
||||
const [row] = await database!.query<{ rdp_user: string }>(
|
||||
sql`SELECT rdp_user FROM ssh_data WHERE id = 1`,
|
||||
);
|
||||
expect(row.rdp_user).toBe("admin");
|
||||
});
|
||||
|
||||
it("does not bring back a login the user removed", async () => {
|
||||
await runProtocolAuthMigration();
|
||||
await createCurrentHostProtocolAuthRepository().delete(1, "rdp");
|
||||
|
||||
await runProtocolAuthMigration();
|
||||
|
||||
expect(await logins()).not.toHaveProperty("1:rdp");
|
||||
});
|
||||
|
||||
it("waits for a locked data key, then copies at that user's login", async () => {
|
||||
await runProtocolAuthMigration();
|
||||
const repository = createCurrentHostProtocolAuthRepository();
|
||||
expect(await repository.listRowsForHost(7)).toEqual([]);
|
||||
|
||||
const lockedKey = crypto.randomBytes(32);
|
||||
state.keys.set("locked", lockedKey);
|
||||
expect(await runProtocolAuthMigration("locked")).toEqual({
|
||||
copied: 1,
|
||||
pendingUsers: 0,
|
||||
});
|
||||
expect(await repository.find(7, "rdp", lockedKey)).toMatchObject({
|
||||
username: "waits",
|
||||
});
|
||||
const [marker] = await database!.query<{ value: string }>(
|
||||
sql`SELECT value FROM settings WHERE key = ${copiedMarker("locked")}`,
|
||||
);
|
||||
expect(marker.value).toBe("done");
|
||||
});
|
||||
|
||||
it("skips a host that already has a login, as after a partial run", async () => {
|
||||
await createCurrentHostProtocolAuthRepository().upsert(
|
||||
"owner",
|
||||
1,
|
||||
{
|
||||
protocol: "rdp",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "edited",
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
},
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
await runProtocolAuthMigration();
|
||||
|
||||
expect((await logins())["1:rdp"]).toMatchObject({ username: "edited" });
|
||||
expect(await logins()).toHaveProperty("3:telnet");
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,19 @@ const state = vi.hoisted(() => ({
|
||||
accessToHost: new Map<number, number>(),
|
||||
grants: [] as Array<Record<string, unknown>>,
|
||||
roleMembers: new Map<number, string[]>(),
|
||||
// hostId -> that host's plugin protocol logins, decrypted
|
||||
logins: new Map<number, Array<Record<string, unknown>>>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (credentialId: number) =>
|
||||
state.credentials.get(credentialId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostProtocolAuthRepository: () => ({
|
||||
listForHost: async (hostId: number) => state.logins.get(hostId) ?? [],
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
@@ -137,22 +147,20 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
credentialId: null,
|
||||
shareSshAuth: false,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
rdpAuthType: null,
|
||||
vncAuthType: null,
|
||||
telnetAuthType: null,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
rdpUser: null,
|
||||
rdpPassword: null,
|
||||
rdpDomain: null,
|
||||
vncUser: null,
|
||||
vncPassword: null,
|
||||
telnetUser: null,
|
||||
telnetPassword: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A plugin protocol login as the repository hands it back, decrypted. */
|
||||
function login(protocol: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
protocol,
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -164,6 +172,7 @@ beforeEach(() => {
|
||||
state.accessToHost = new Map([[7, 42]]);
|
||||
state.grants = [];
|
||||
state.roleMembers.clear();
|
||||
state.logins.clear();
|
||||
});
|
||||
|
||||
describe("SharedHostSecretsManager", () => {
|
||||
@@ -189,7 +198,7 @@ describe("SharedHostSecretsManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots opted-in SSH credential auth alongside enabled non-SSH protocols", async () => {
|
||||
it("snapshots opted-in SSH credential auth alongside plugin protocol logins", async () => {
|
||||
state.credentials.set(123, {
|
||||
id: 123,
|
||||
userId: "owner",
|
||||
@@ -208,22 +217,24 @@ describe("SharedHostSecretsManager", () => {
|
||||
credentialId: 123,
|
||||
shareSshAuth: true,
|
||||
password: null,
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
rdpDomain: "CORP",
|
||||
enableTelnet: true,
|
||||
telnetUser: "tel-user",
|
||||
telnetPassword: "tel-pass",
|
||||
}),
|
||||
);
|
||||
state.logins.set(42, [
|
||||
login("spice", {
|
||||
username: "spice-admin",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-1" },
|
||||
}),
|
||||
login("x2go", { username: "x-user", password: "x-pass" }),
|
||||
]);
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([
|
||||
"rdp",
|
||||
"spice",
|
||||
"ssh",
|
||||
"telnet",
|
||||
"x2go",
|
||||
]);
|
||||
|
||||
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
||||
@@ -234,21 +245,101 @@ describe("SharedHostSecretsManager", () => {
|
||||
keyType: "ssh-ed25519",
|
||||
});
|
||||
|
||||
const rdp = await manager.getSecretForUser(42, "target", "rdp");
|
||||
expect(rdp).toMatchObject({
|
||||
username: "rdp-admin",
|
||||
password: "rdp-pass",
|
||||
domain: "CORP",
|
||||
const spice = await manager.getSecretForUser(42, "target", "spice");
|
||||
expect(spice).toMatchObject({
|
||||
username: "spice-admin",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-1" },
|
||||
authType: "direct",
|
||||
});
|
||||
|
||||
const telnet = await manager.getSecretForUser(42, "target", "telnet");
|
||||
expect(telnet).toMatchObject({
|
||||
username: "tel-user",
|
||||
password: "tel-pass",
|
||||
const x2go = await manager.getSecretForUser(42, "target", "x2go");
|
||||
expect(x2go).toMatchObject({
|
||||
username: "x-user",
|
||||
password: "x-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots a credential-backed protocol login from the credential", async () => {
|
||||
state.credentials.set(55, {
|
||||
id: 55,
|
||||
userId: "owner",
|
||||
username: "cred-viewer",
|
||||
password: "cred-pass",
|
||||
});
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [
|
||||
login("spice", {
|
||||
authType: "credential",
|
||||
credentialId: 55,
|
||||
fields: { display: "1" },
|
||||
}),
|
||||
]);
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows[0]).toMatchObject({
|
||||
protocol: "spice",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: 55,
|
||||
});
|
||||
expect(await manager.getSecretForUser(42, "target", "spice")).toMatchObject(
|
||||
{
|
||||
authType: "credential",
|
||||
username: "cred-viewer",
|
||||
password: "cred-pass",
|
||||
fields: { display: "1" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("takes no snapshot of a login that asks at connect time", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [login("spice", { authType: "none" })]);
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads a snapshot taken before 2.9.0 with its domain column", async () => {
|
||||
const recordId = "shared-7-target-legacy";
|
||||
state.secretRows.push({
|
||||
id: 1,
|
||||
hostAccessId: 7,
|
||||
targetUserId: "target",
|
||||
protocol: "legacy",
|
||||
encryptedUsername: FieldCrypto.encryptField(
|
||||
"admin",
|
||||
targetDEK,
|
||||
recordId,
|
||||
"username",
|
||||
),
|
||||
encryptedAuthType: "direct",
|
||||
encryptedDomain: FieldCrypto.encryptField(
|
||||
"CORP",
|
||||
targetDEK,
|
||||
recordId,
|
||||
"domain",
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
await manager.getSecretForUser(42, "target", "legacy"),
|
||||
).toMatchObject({ username: "admin", fields: { domain: "CORP" } });
|
||||
});
|
||||
|
||||
it("drops a protocol's snapshot once its login is gone", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [login("spice", { username: "u", password: "p" })]);
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows).toHaveLength(1);
|
||||
|
||||
state.logins.set(42, []);
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("produces no snapshot rows for secret-less auth types", async () => {
|
||||
state.hosts.set(
|
||||
42,
|
||||
@@ -283,36 +374,54 @@ describe("SharedHostSecretsManager", () => {
|
||||
});
|
||||
|
||||
it("cannot be decrypted with the wrong DEK", async () => {
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [
|
||||
login("spice", {
|
||||
username: "spice-admin",
|
||||
password: "spice-pass",
|
||||
secretFields: { ticket: "t-1" },
|
||||
}),
|
||||
);
|
||||
]);
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
const row = state.secretRows[0];
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(
|
||||
row.encryptedPassword as string,
|
||||
ownerDEK,
|
||||
"shared-7-target-rdp",
|
||||
"password",
|
||||
),
|
||||
).toThrow();
|
||||
for (const [column, field] of [
|
||||
["encryptedPassword", "password"],
|
||||
["encryptedFields", "fields"],
|
||||
]) {
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(
|
||||
row[column] as string,
|
||||
ownerDEK,
|
||||
"shared-7-target-spice",
|
||||
field,
|
||||
),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a tampered snapshot rather than handing it out", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [login("spice", { password: "spice-pass" })]);
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
const row = state.secretRows[0];
|
||||
const envelope = JSON.parse(row.encryptedPassword as string);
|
||||
envelope.data = envelope.data.replace(/^./, (c: string) =>
|
||||
c === "0" ? "1" : "0",
|
||||
);
|
||||
row.encryptedPassword = JSON.stringify(envelope);
|
||||
|
||||
await expect(
|
||||
manager.getSecretForUser(42, "target", "spice"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("resyncHost re-snapshots direct grants and role members", async () => {
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [
|
||||
login("spice", { username: "spice-admin", password: "spice-pass" }),
|
||||
]);
|
||||
state.accessToHost = new Map([
|
||||
[1, 42],
|
||||
[2, 42],
|
||||
@@ -334,29 +443,20 @@ describe("SharedHostSecretsManager", () => {
|
||||
]);
|
||||
|
||||
// Owner rotates the non-SSH password; resync updates those copies.
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rotated",
|
||||
}),
|
||||
);
|
||||
state.logins.set(42, [
|
||||
login("spice", { username: "spice-admin", password: "rotated" }),
|
||||
]);
|
||||
await manager.resyncHost(42);
|
||||
|
||||
const secret = await manager.getSecretForUser(42, "target", "rdp");
|
||||
const secret = await manager.getSecretForUser(42, "target", "spice");
|
||||
expect(secret?.password).toBe("rotated");
|
||||
});
|
||||
|
||||
it("snapshotForRoleMember fans out from role grants", async () => {
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
state.hosts.set(42, baseHost());
|
||||
state.logins.set(42, [
|
||||
login("spice", { username: "spice-admin", password: "spice-pass" }),
|
||||
]);
|
||||
state.accessToHost = new Map([[2, 42]]);
|
||||
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
|
||||
|
||||
@@ -366,7 +466,7 @@ describe("SharedHostSecretsManager", () => {
|
||||
expect(state.secretRows[0]).toMatchObject({
|
||||
hostAccessId: 2,
|
||||
targetUserId: "member-1",
|
||||
protocol: "rdp",
|
||||
protocol: "spice",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user