mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted capabilities, performed by the main thread, then audited. A capability must be both declared in the manifest and granted in the database, so widening a plugin's reach always needs a new manifest the user can see, never just a database row. Audit attribution is set by the broker, not the plugin. The plugin supplies only the details, so it cannot forge the actor. fix: stop plugin workers seeing plaintext credentials in error messages A failing ctx.ssh.connect handed the worker the raw error from deep in the SSH stack, which embeds the connect config. The worker received, in full: Authentication failed for root using password <SECRET> where <SECRET> was the host's real plaintext password. Any plugin with ssh.exec could read it by catching the error, defeating the whole point of the handle-based ssh API. Errors crossing the boundary are now sanitised. Only PluginFacingError and PluginPermissionError, both authored here, reach a plugin verbatim; anything else becomes a generic message and the real one goes to the log. This uses a marker class rather than matching on message text, because matching would start leaking again the moment an upstream error happened to contain a familiar phrase. Covered by credential-isolation.test.ts, which plants a sentinel in every secret-bearing field and asserts it appears in no message the worker ever receives.
This commit is contained in:
1 parent
1393fa7581
commit
4311a0e98a
4 files changed
+1779
No files matched your search
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The grant check every privileged ctx call goes through.
|
||||
*
|
||||
* Capability strings are the manifest's own permission enum, stored verbatim in
|
||||
* plugin_permission_grants.capability -- no translation layer, so the manifest
|
||||
* stays the single source of truth for what is grantable.
|
||||
*
|
||||
* Two separate things have to be true for a capability to count as granted:
|
||||
* the manifest must declare it (the plugin asked for it up front) and an admin
|
||||
* must have granted it (someone said yes). A grant for something the manifest
|
||||
* never declared is ignored rather than honoured, so widening a plugin's reach
|
||||
* always requires a new manifest the user can see.
|
||||
*/
|
||||
|
||||
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
|
||||
import type { PluginPermission } from "./manifest.js";
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
interface CacheEntry {
|
||||
capabilities: Set<string>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
export class PluginPermissionError extends Error {
|
||||
readonly code = "EPLUGINPERM";
|
||||
|
||||
constructor(pluginId: string, capability: string) {
|
||||
super(
|
||||
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
|
||||
`Declare it in the manifest's permissions array and grant it in the plugin's settings.`,
|
||||
);
|
||||
this.name = "PluginPermissionError";
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidatePluginPermissionCache(pluginId?: string): void {
|
||||
if (pluginId) cache.delete(pluginId);
|
||||
else cache.clear();
|
||||
}
|
||||
|
||||
async function loadGrants(pluginId: string): Promise<Set<string>> {
|
||||
const cached = cache.get(pluginId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.capabilities;
|
||||
|
||||
const rows =
|
||||
await createCurrentPluginPermissionGrantRepository().listByPlugin(pluginId);
|
||||
const capabilities = new Set(rows.map((row) => row.capability));
|
||||
|
||||
cache.set(pluginId, { capabilities, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
export async function hasCapability(
|
||||
pluginId: string,
|
||||
capability: PluginPermission | string,
|
||||
declared: readonly string[],
|
||||
): Promise<boolean> {
|
||||
if (!declared.includes(capability)) return false;
|
||||
return (await loadGrants(pluginId)).has(capability);
|
||||
}
|
||||
|
||||
export async function assertCapability(
|
||||
pluginId: string,
|
||||
capability: PluginPermission | string,
|
||||
declared: readonly string[],
|
||||
): Promise<void> {
|
||||
if (!(await hasCapability(pluginId, capability, declared))) {
|
||||
throw new PluginPermissionError(pluginId, capability);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user