mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -0,0 +1,253 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
|
||||
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
|
||||
actor: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
connectHost: async (target: unknown, options: Record<string, unknown>) => {
|
||||
h.connects.push({ target, options });
|
||||
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
|
||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
recordSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
h.factors.push({ userId, pluginId, factorId });
|
||||
},
|
||||
removeSecondFactor: async () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginAuth, createPluginSsh } =
|
||||
await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { getSshAuthProvider } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { getLoginMethod, getSecondFactor } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
contributes: { auth },
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set();
|
||||
h.connects = [];
|
||||
h.factors = [];
|
||||
h.actor = "user-1";
|
||||
});
|
||||
|
||||
describe("ctx.ssh", () => {
|
||||
it("refuses without ssh:connect and credentials:use, and audits the refusal", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
await expect(ssh.connect(7)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"ssh_connect",
|
||||
"host 7",
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(h.connects).toEqual([]);
|
||||
});
|
||||
|
||||
it("connects as the acting user through the pipeline and audits", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
await ssh.connect(7, { purpose: "docker" });
|
||||
expect(h.connects).toEqual([
|
||||
{
|
||||
target: 7,
|
||||
options: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
purpose: "docker",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_connect", "host 7", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs background work on a resolved host as its owner", async () => {
|
||||
h.actor = undefined;
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await ssh.connect({
|
||||
id: 3,
|
||||
ip: "h",
|
||||
port: 22,
|
||||
username: "u",
|
||||
userId: "owner",
|
||||
});
|
||||
expect(h.connects[0].options).toMatchObject({ userId: "owner" });
|
||||
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("closes open connections when the plugin is disposed", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const connection = await ssh.connect(7);
|
||||
await bag.disposeAll();
|
||||
expect(
|
||||
(connection.client as { end: ReturnType<typeof vi.fn> }).end,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth", () => {
|
||||
it("needs auth:provide and a contributes.auth entry to register anything", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest([], { sshAuthTypes: ["corp"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
auth.registerSshAuthProvider({
|
||||
type: "corp",
|
||||
labelKey: "corp",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(PluginCapabilityError);
|
||||
|
||||
const declared = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
declared.registerSshAuthProvider({
|
||||
type: "other",
|
||||
labelKey: "other",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(/contributes\.auth\.sshAuthTypes/);
|
||||
});
|
||||
|
||||
it("registers an SSH auth type, checks the grant on use and removes it on dispose", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
auth.registerSshAuthProvider({
|
||||
type: "corp",
|
||||
labelKey: "corp",
|
||||
prepare: async (config) => {
|
||||
config.password = "from-corp";
|
||||
return { status: "ready" };
|
||||
},
|
||||
});
|
||||
const provider = getSshAuthProvider("corp")!;
|
||||
expect(provider.pluginId).toBe("fixture");
|
||||
|
||||
await expect(
|
||||
provider.prepare({} as never, {} as never, {} as never),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const config: Record<string, unknown> = {};
|
||||
await provider.prepare(config as never, {} as never, {} as never);
|
||||
expect(config.password).toBe("from-corp");
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(getSshAuthProvider("corp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers login methods and second factors under the plugin, and records enrolment", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const audit = vi.fn(async () => {});
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], {
|
||||
loginMethods: ["corp-sso"],
|
||||
secondFactors: ["pin"],
|
||||
}),
|
||||
bag,
|
||||
audit,
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
labelKey: "corp",
|
||||
kind: "redirect",
|
||||
start: async () => ({ redirectUrl: "https://corp" }),
|
||||
});
|
||||
auth.registerSecondFactor({
|
||||
id: "pin",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => true,
|
||||
verify: async () => true,
|
||||
});
|
||||
expect(getLoginMethod("corp-sso")?.pluginId).toBe("fixture");
|
||||
expect(getSecondFactor("fixture", "pin")).toBeDefined();
|
||||
|
||||
await auth.recordEnrollment("user-1", "pin");
|
||||
expect(h.factors).toEqual([
|
||||
{ userId: "user-1", pluginId: "fixture", factorId: "pin" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"auth_factor_enrolled",
|
||||
"pin for user-1",
|
||||
{ success: true },
|
||||
);
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(getLoginMethod("corp-sso")).toBeUndefined();
|
||||
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -69,8 +69,8 @@ vi.mock("../../plugins/permissions.js", () => ({
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
|
||||
|
||||
const http = await import("../../plugins/http.js");
|
||||
const pluginApi = (await import("../../database/routes/plugin-api-routes.js"))
|
||||
.default;
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
|
||||
function manifest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -86,9 +86,9 @@ let server: Server | null = null;
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const app = express();
|
||||
// Exactly how database.ts mounts it: no auth in front, because the plugin
|
||||
// router brings its own.
|
||||
app.use("/plugin-api", pluginApi);
|
||||
// The same mount database.ts uses. It used to put core auth in front of the
|
||||
// whole prefix, which made every public plugin route unreachable.
|
||||
mountPluginApi(app);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", () => {
|
||||
|
||||
Reference in new issue
Block a user