feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+42 -2
View File
@@ -149,7 +149,7 @@ describe("capability gate", () => {
describe("ctx.asUser", () => {
it("makes the named user the actor and audits the switch", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
const ctx = context(["kv:own", "users:impersonate"]);
let seen: string | undefined;
await ctx.asUser("user-7", async () => {
@@ -169,7 +169,7 @@ describe("ctx.asUser", () => {
});
it("restores the previous actor afterwards", async () => {
const ctx = context([]);
const ctx = context(["users:impersonate"]);
await runAsActor("outer-user", "request", async () => {
await ctx.asUser("inner-user", async () => {
@@ -186,6 +186,46 @@ describe("ctx.asUser", () => {
/without a user id/,
);
});
it("refuses without users:impersonate", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.asUser("user-7", async () => {})).rejects.toThrow(
/users:impersonate/,
);
});
});
describe("ctx.registry namespacing", () => {
it("lets a plugin provide and revoke its own keys", () => {
const ctx = context([]);
const value = { ok: true };
ctx.registry.provide("sample-plugin.thing", value);
expect(ctx.registry.consume("sample-plugin.thing")).toBe(value);
expect(ctx.registry.revoke("sample-plugin.thing", value)).toBe(true);
});
it("refuses a key outside the plugin's namespace", () => {
const ctx = context([]);
expect(() => ctx.registry.provide("other.thing", {})).toThrow(
/registry keys under "sample-plugin\."/,
);
expect(() => ctx.registry.revoke("other.thing")).toThrow(
/registry keys under/,
);
});
});
describe("ctx.services.get", () => {
it("refuses a service the manifest does not require", () => {
const ctx = context([]);
expect(() => ctx.services.get("someone.else")).toThrow(
/not declared in the manifest's requires/,
);
});
});
describe("ctx.events namespacing", () => {
@@ -140,7 +140,7 @@ function setup() {
kind: "redirect",
external: true,
describe: async () => [
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
{ id: "3", label: "Corp", enabled: true, type: "oidc", autoStart: true },
],
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
callback: async () => ({
@@ -161,7 +161,9 @@ function setup() {
labelKey: "signIn",
kind: "form",
external: true,
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
describe: async () => [
{ id: "4", label: "Directory", enabled: true, autoStart: true },
],
verify: async (request) => {
if (request.body.password !== "hunter2") {
await auth.loginRateLimit.recordFailure(
@@ -303,7 +305,7 @@ describe("the other ctx.auth helpers", () => {
it("revokes sessions by logout claims and audits it", async () => {
const { auth } = setup();
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
ssoProviderId: 3,
sub: null,
sid: "sid-1",
@@ -352,6 +354,17 @@ describe("a disabled plugin", () => {
]);
});
it("passes autoStart through for redirect methods only", async () => {
setup();
const methods = await listPublicLoginMethods();
expect(methods.find((m) => m.id === "corp-sso")?.instances).toEqual([
{ id: "3", label: "Corp", autoStart: true },
]);
expect(methods.find((m) => m.id === "corp-dir")?.instances).toEqual([
{ id: "4", label: "Directory" },
]);
});
it("hides its buttons and leaves no route that signs anyone in", async () => {
const { bag } = setup();
await bag.disposeAll();
+8 -1
View File
@@ -190,10 +190,17 @@ describe("ctx.db capability", () => {
});
});
it("hands back read-only refs to the core tables a plugin may join", async () => {
it("refuses core refs with only db:own", async () => {
grants.set("demo", ["db:own"]);
const { ctx } = contextFor("demo", ["db:own"]);
await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/);
});
it("hands back refs to the core tables a plugin may join", async () => {
grants.set("demo", ["db:core-refs"]);
const { ctx } = contextFor("demo", ["db:core-refs"]);
const refs = (await ctx.db.refs()) as Record<string, unknown>;
expect(Object.keys(refs).sort()).toEqual([
+13 -8
View File
@@ -122,19 +122,24 @@ describe("ctx.desktop.openIsolatedWindow", () => {
});
});
describe("ctx.desktop.launchNativeRdp", () => {
const request = { host: "win.example.test", port: 3389, username: "bob" };
describe("ctx.desktop.launchExternalClient", () => {
const request = {
protocol: "rdp",
host: "win.example.test",
port: 3389,
username: "bob",
};
it("refuses without desktop:window", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
/desktop:window/,
);
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_launch_native_rdp",
action: "plugin_desktop_launch_external_client",
success: false,
});
});
@@ -144,7 +149,7 @@ describe("ctx.desktop.launchNativeRdp", () => {
const { ctx } = contextFor("demo", ["desktop:window"]);
expect(ctx.desktop.available()).toBe(false);
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
/desktop app/,
);
});
@@ -155,15 +160,15 @@ describe("ctx.desktop.launchNativeRdp", () => {
const { ctx } = contextFor("demo", ["desktop:window"]);
expect(ctx.desktop.available()).toBe(true);
await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({
await expect(ctx.desktop.launchExternalClient(request)).resolves.toEqual({
success: true,
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"launch-native-rdp",
"launch-external-client",
request,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_launch_native_rdp",
action: "plugin_desktop_launch_external_client",
success: true,
});
});
+25
View File
@@ -401,3 +401,28 @@ describe("ctx.http streaming", () => {
expect(rest).toContain("data: done");
});
});
describe("public route rate limit", () => {
afterEach(() => {
http.resetPublicRateLimits();
delete process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT;
});
it("allows up to the limit per address and plugin, then refuses", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "3";
const now = 1_000;
const hits = [1, 2, 3, 4].map(() =>
http.overPublicRateLimit("demo", "1.2.3.4", now),
);
expect(hits).toEqual([false, false, false, true]);
expect(http.overPublicRateLimit("demo", "5.6.7.8", now)).toBe(false);
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
});
it("starts a new window after a minute", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
expect(http.overPublicRateLimit("demo", "ip", 10)).toBe(true);
expect(http.overPublicRateLimit("demo", "ip", 60_001)).toBe(false);
});
});
-13
View File
@@ -472,19 +472,6 @@ describe("PluginLoader error budget", () => {
expect(loader.get("sample-plugin")?.state).toBe("active");
});
it("wrap() counts a throwing callback instead of letting it escape", async () => {
const loader = await failing(2);
const wrapped = loader.wrap("sample-plugin", () => {
throw new Error("callback failed");
});
await expect(wrapped()).resolves.toBeUndefined();
await wrapped();
expect(loader.get("sample-plugin")?.state).toBe("failed");
});
it("retry clears the budget and starts the plugin again", async () => {
const loader = await failing(1);
+30 -1
View File
@@ -11,6 +11,7 @@ import {
parseManifest,
validateManifest,
SUPPORTED_PLUGIN_API_VERSION,
isTermixCompatible,
} from "../../plugins/manifest.js";
function base(overrides: Record<string, unknown> = {}) {
@@ -91,7 +92,35 @@ describe("manifest v2 validation", () => {
);
expect(manifest).toBeUndefined();
expect(errors.join()).toMatch(/SDK API version/);
expect(errors.join()).toMatch(/needs plugin API 2/);
});
it("accepts an api range this build satisfies", () => {
for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) {
expect(
parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors,
).toEqual([]);
}
});
it("refuses an api range needing a newer minor", () => {
const { errors } = parseManifest(
base({ engine: { termix: ">=2.9.0", api: "^1.99" } }),
);
expect(errors.join()).toMatch(/needs plugin API \^1\.99/);
});
it("refuses an engine that is not a semver range", () => {
expect(
validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(),
).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/);
});
it("checks engine.termix against the core version", () => {
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false);
expect(isTermixCompatible(">=3.0.0", null)).toBe(true);
});
it("fills in the entry point defaults", () => {
@@ -7,6 +7,7 @@
* group was declared twice.
*/
import type { PluginLoader } from "../../plugins/loader.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
@@ -113,7 +114,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { createFixturePlugin } = await import("./fixture-plugin.js");
const {
getPermissionCatalog,
@@ -433,7 +433,7 @@ describe("shipped manifests keep their permission ids", () => {
describe("role defaults apply exactly once", () => {
let fixture: Fixture;
let loader: InstanceType<typeof PluginLoader>;
let loader: PluginLoader;
beforeEach(() => {
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
@@ -128,7 +128,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: CONSUMER_ID,
capabilities: ["kv:own"],
capabilities: ["kv:own", "users:impersonate"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requiresSecret },
});
@@ -81,7 +81,13 @@ function contextFor(pluginId: string, extra: Record<string, unknown> = {}) {
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["hosts:read", "ssh:connect", "credentials:use"],
capabilities: [
"hosts:read",
"ssh:connect",
"credentials:use",
"users:impersonate",
],
requires: [{ service: "sample.echo", versionRange: "^1.0.0" }],
...extra,
} as never,
handle,
@@ -11,7 +11,10 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findUnownedTableWrites } from "@termix/plugin-sdk/ddl";
import {
collectOwnedIndexes,
findUnownedTableWrites,
} from "@termix/plugin-sdk/ddl";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
const state = vi.hoisted(() => ({
@@ -74,6 +77,40 @@ describe("the migration checker", () => {
["a truncate", "TRUNCATE TABLE users"],
["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"],
["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"],
[
"a Postgres dollar-quoted string hiding a statement",
"SELECT $$'$$; UPDATE users SET is_admin = true; SELECT $$'$$",
],
[
"a MySQL backslash escape hiding a statement",
"SELECT 'a\\''; UPDATE users SET is_admin = 1; SELECT ''",
],
["dropping a core index by name", "DROP INDEX idx_sessions_user_id"],
["altering a core index by name", "ALTER INDEX users_pkey RENAME TO x"],
["dropping a core sequence", "DROP SEQUENCE users_id_seq"],
["a cascade", "DROP TABLE p_foo_a CASCADE"],
[
"a MySQL multi-table update",
"UPDATE p_foo_a JOIN users ON 1 = 1 SET users.is_admin = 1",
],
["a MySQL comma update", "UPDATE p_foo_a, users SET users.is_admin = 1"],
[
"a MySQL multi-table delete",
"DELETE p_foo_a, users FROM p_foo_a JOIN users",
],
[
"a delete with USING",
"DELETE FROM p_foo_a, users USING p_foo_a JOIN users",
],
["SELECT INTO", "SELECT * INTO users_copy FROM users"],
["writing a file", "SELECT 1 INTO OUTFILE '/tmp/x'"],
["reading a server file", "SELECT pg_read_file('/etc/passwd')"],
["changing a server setting", "SELECT set_config('x', 'y', false)"],
[
"running a string as SQL",
"SELECT query_to_xml('DELETE FROM users', true, true, '')",
],
["setval on a core sequence", "SELECT setval('users_id_seq', 1)"],
])("refuses %s", (_label, sql) => {
expect(findUnownedTableWrites("foo", sql)).not.toEqual([]);
});
@@ -90,10 +127,34 @@ describe("the migration checker", () => {
"a quoted name in a string",
"INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')",
],
[
"dropping an index it created in the same file",
"CREATE INDEX idx_foo_a ON p_foo_a (id); DROP INDEX idx_foo_a",
],
["dropping a prefixed index", "DROP INDEX IF EXISTS p_foo_a_idx"],
[
"setval on its own sequence",
"SELECT setval(pg_get_serial_sequence('p_foo_a', 'id'), 1)",
],
["its own sequence", "CREATE SEQUENCE IF NOT EXISTS p_foo_seq"],
])("allows %s", (_label, sql) => {
expect(findUnownedTableWrites("foo", sql)).toEqual([]);
});
it("allows dropping an index an earlier migration created", () => {
const owned = collectOwnedIndexes("foo", [
"CREATE INDEX idx_foo_a ON p_foo_a (id)",
"CREATE INDEX idx_core ON users (id)",
]);
expect([...owned]).toEqual(["idx_foo_a"]);
expect(
findUnownedTableWrites("foo", "DROP INDEX idx_foo_a", new Set(), owned),
).toEqual([]);
expect(
findUnownedTableWrites("foo", "DROP INDEX idx_core", new Set(), owned),
).not.toEqual([]);
});
it("passes every migration a bundled plugin ships", () => {
const plugins = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -121,7 +121,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
capabilities: ["kv:own", "users:impersonate"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requires },
});
@@ -219,6 +219,7 @@ describe("plugin service contracts", () => {
it("activates anyway when an unsatisfied requirement is optional", async () => {
consumer = consumerFixture([
{ service: SERVICE, versionRange: "^1.2.0", optional: true },
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
]);
@@ -69,10 +69,11 @@ async function writeArtifact(
dir: string,
id: string,
sig: "valid" | "wrong-key" | "none",
version = "1.0.0",
): Promise<string> {
const build = tempRoot("termix-build-");
createFixturePlugin({ id, root: build });
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
createFixturePlugin({ id, root: build, manifestOverrides: { version } });
const file = path.join(dir, `${id}-${version}.tmxplug`);
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
"manifest.json",
"backend",
@@ -146,13 +147,57 @@ describe("signed plugins gate on", () => {
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
});
it("refuses a signed archive that takes a bundled plugin's id", async () => {
it("refuses a signed archive of a bundled id that is not newer", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
await writeArtifact(userDir, "bundled-one", "valid");
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
expect(loader.get("bundled-one")?.source).toBe("bundled");
expect(loader.get("bundled-one")?.artifact).toBeUndefined();
});
});
describe("updating a bundled plugin", () => {
it("loads a signed, newer archive in place of the bundled copy", async () => {
const { userDir } = setup();
const file = await writeArtifact(userDir, "bundled-one", "valid", "1.0.1");
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
const plugin = loader.get("bundled-one");
expect(plugin?.manifest.version).toBe("1.0.1");
expect(plugin?.source).toBe("bundled");
expect(plugin?.artifact).toBe(file);
expect(plugin?.signedBy).toBe("test");
});
it("keeps the bundled copy for an unsigned newer archive", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "bundled-one", "none", "2.0.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
});
it("keeps the bundled copy for a signed older archive", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
});
it("refuses a downgrade after the bundled copy was unloaded", async () => {
const { userDir } = setup();
const loader = new PluginLoader();
await loader.loadAll();
loader.forget("bundled-one");
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.5.0");
await expect(
loader.loadArtifact(file, loader.bundledIds()),
).rejects.toThrow(/not newer than the bundled 1\.0\.0/);
});
});
@@ -11,6 +11,7 @@ import {
defineTable,
encryptedText,
id,
refHost,
refUser,
text,
varchar,
@@ -134,4 +135,37 @@ describe("plugin user data", () => {
.get(),
).toEqual({ user_id: "u1", note: "x" });
});
it("skips imported rows that point at another user or host", async () => {
sqlite.exec("DROP TABLE ssh_data");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
sqlite.exec(
"INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')",
);
const grants = defineTable("grant", {
id: id(),
ownerId: refUser(),
granteeId: refUser(),
hostId: refHost(),
});
for (const statement of createTableSql("sqlite", "fixture", grants)) {
sqlite.exec(statement);
}
registerTable("fixture", grants);
const file = new Database(":memory:");
file.exec(
"CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)",
);
file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES
('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`);
const result = await importUserPluginRows(file, "u2");
expect(result).toMatchObject({ imported: 1, skipped: 2 });
expect(
sqlite
.prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant")
.all(),
).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]);
});
});