feat: host lock api so a plugin can load hosts from a config file (#1581)

This commit is contained in:
LukeGus committed 2026-10-19 12:02:05 -05:00
1 parent 1cdbfc674c
commit 2c785fe35b
34 files changed
+1388 -52

No files matched your search

@@ -461,6 +461,40 @@ describe("ctx.credentials.registerSecretResolver", () => {
});
});
describe("ctx.credentials.list", () => {
beforeEach(() => {
state.userCredentials.set("owner", [
{ id: 3, name: "pw", authType: "password", password: "secret" },
{ id: 4, name: "key", authType: "key", privateKey: "PRIVATE KEY" },
]);
});
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.list()),
).rejects.toThrow(/credentials:use/);
});
it("lists the actor's credentials without secrets", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
const list = await runAsActor("owner", "request", () =>
ctx.credentials.list(),
);
expect(list).toEqual([
{ id: 3, name: "pw", username: null, authType: "password", folder: null },
{ id: 4, name: "key", username: null, authType: "key", folder: null },
]);
expect(JSON.stringify(list)).not.toMatch(/secret|PRIVATE/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_list",
success: true,
});
});
});
describe("ctx.credentials.listSshKeys", () => {
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
const derived = (() => {
+256
View File
@@ -27,6 +27,9 @@ const h = vi.hoisted(() => ({
statusPortsRemoved: [] as string[],
userPermissions: new Set<string>(),
deleted: [] as Array<{ userId: string; hostId: number }>,
existingAccess: [] as Array<{ id: number }>,
revoked: [] as number[],
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
roles: [] as Array<{
id: number;
name: string;
@@ -85,6 +88,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.upserts.push(input);
return { id: h.upserts.length, created: true };
},
listHostAccess: async () => h.existingAccess,
revokeHostAccess: async (accessId: number) => {
h.revoked.push(accessId);
},
}),
createCurrentUserRepository: () => ({
listAll: async () => h.users,
@@ -145,6 +152,13 @@ vi.mock("../../plugins/host-status-provider.js", () => ({
},
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [
{
id: "metrics",
contributes: { settings: { host: { enableKey: "enableMetrics" } } },
},
{ id: "noswitch", contributes: { settings: { host: { fields: [] } } } },
],
loadHostPluginSettings: async (hostIds: number[]) =>
new Map(
hostIds.map((id) => [
@@ -198,6 +212,25 @@ vi.mock("../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../database/routes/rbac.js", () => ({
pruneHostGrants: async () => 0,
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
credentialId === 5 ? { id: 5 } : null,
}));
vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({
readProtocolAuthPayload: (data: { protocolAuth: Record<string, unknown> }) =>
new Map(Object.entries(data.protocolAuth)),
writeProtocolAuth: async (
_ownerId: string,
hostId: number,
patch: Map<string, unknown>,
) => {
h.protocolWrites.push({ hostId, protocols: [...patch.keys()] });
},
}));
vi.mock("../../hosts/delete-host.js", () => ({
deleteOwnedHost: async (userId: string, hostId: number) => {
h.deleted.push({ userId, hostId });
@@ -209,6 +242,8 @@ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const { clearHostLocks, hostLock, provideHostLocks } =
await import("../../hosts/locks.js");
function manifest(capabilities: string[]) {
return {
@@ -236,6 +271,10 @@ beforeEach(() => {
h.roles = [];
h.userPermissions = new Set();
h.deleted = [];
h.existingAccess = [];
h.revoked = [];
h.protocolWrites = [];
clearHostLocks();
});
describe("ctx.hosts", () => {
@@ -705,3 +744,220 @@ describe("ctx.hosts.status", () => {
expect(h.statusPortsRemoved).toEqual(["rdp"]);
});
});
describe("ctx.hosts secrets and protocol logins", () => {
it("stores secrets on create and writes protocol logins", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const created = await hosts.create({
name: "rdp",
ip: "10.0.0.9",
port: 3389,
username: "",
authType: "password",
password: "s3cret",
sudoPassword: "",
protocolAuth: { rdp: { authType: "direct", username: "admin" } },
});
expect(h.created[0]).toMatchObject({
password: "s3cret",
sudoPassword: null,
});
expect(created).not.toHaveProperty("password");
expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]);
});
it("refuses a protocol credential the owner cannot use", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.create({
name: "vnc",
ip: "10.0.0.9",
port: 5900,
username: "",
authType: "password",
protocolAuth: { vnc: { authType: "credential", credentialId: 6 } },
}),
).rejects.toThrow(/credential 6/);
});
it("refuses a secret that is not a string", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow(
/password/,
);
});
});
describe("ctx.hosts pluginEnable", () => {
it("writes each plugin's switch under its enableKey", async () => {
h.granted = new Set(["hosts:write"]);
h.importRows = [];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await hosts.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
pluginEnable: { metrics: false },
});
expect(h.importRows).toEqual([
{ hostId: 99, row: { metrics: { enableMetrics: false } } },
]);
});
it("refuses a plugin with no switch", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.update(5, { pluginEnable: { noswitch: true } }),
).rejects.toThrow(/no host switch/);
expect(h.updates).toHaveLength(0);
});
});
describe("ctx.hosts.setShares", () => {
it("upserts the listed grants and revokes the rest", async () => {
h.granted = new Set(["hosts:write"]);
h.existingAccess = [{ id: 1 }, { id: 7 }];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const result = await hosts.setShares(3, [
{ target: { type: "role", id: 2 }, permissionLevel: "connect" },
]);
expect(result).toEqual({ hostId: 3, shared: true });
expect(h.upserts).toEqual([
expect.objectContaining({
hostId: 3,
targetType: "role",
targetRoleId: 2,
permissionLevel: "connect",
expiresAt: null,
}),
]);
expect(h.revoked).toEqual([7]);
});
it("only works for the host owner", async () => {
h.granted = new Set(["hosts:write"]);
h.ownerId = "someone-else";
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
expect(await hosts.setShares(3, [])).toEqual({
hostId: 3,
shared: false,
reason: "forbidden",
});
expect(h.revoked).toEqual([]);
});
it("refuses a bad level", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.setShares(3, [
{ target: { type: "user", id: "u2" }, permissionLevel: "own" as never },
]),
).rejects.toThrow(/share level/);
});
});
describe("ctx.hosts.locks", () => {
const provider = (locked: number[], allowManualCreate = true) => ({
isLocked: (hostId: number) => locked.includes(hostId),
note: () => "From hosts.yaml",
allowManualCreate: () => allowManualCreate,
});
it("needs hosts:lock", () => {
const hosts = createPluginHosts({
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.locks.provide(provider([1]))).toThrow(
PluginCapabilityError,
);
});
it("locks hosts until the plugin removes its provider", () => {
const hosts = createPluginHosts({
manifest: manifest(["hosts:lock"]),
audit: vi.fn(async () => {}),
});
const remove = hosts.locks.provide(provider([4]));
expect(hostLock(4)).toEqual({
pluginId: "fixture",
pluginName: "Fixture",
note: "From hosts.yaml",
});
expect(hostLock(5)).toBeNull();
remove();
expect(hostLock(4)).toBeNull();
});
it("blocks other plugins on a locked host but not the plugin that locks it", async () => {
h.granted = new Set(["hosts:write"]);
h.updateResult = { id: 4, name: "x" };
h.userPermissions = new Set(["hosts.delete"]);
provideHostLocks("hosts-file", "Hosts File", provider([4], false));
const other = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(other.update(4, { name: "y" })).rejects.toThrow(
/managed by Hosts File/,
);
await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/);
await expect(
other.share(4, [{ type: "user", id: "u2" }], "view"),
).rejects.toThrow(/managed by Hosts File/);
await expect(
other.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
}),
).rejects.toThrow(/new hosts can't be added/);
expect(h.updates).toHaveLength(0);
expect(h.deleted).toHaveLength(0);
const owner = createPluginHosts({
manifest: {
id: "hosts-file",
name: "Hosts File",
version: "1.0.0",
capabilities: ["hosts:write"],
} as never,
audit: vi.fn(async () => {}),
});
await owner.update(4, { name: "y" });
expect(h.updates).toHaveLength(1);
});
});