mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes behind admin.plugins.manage, and thread the calling user through worker ctx.hosts/ctx.ssh calls instead of always using the install owner. Also adds the plugin permissions grant/revoke UI.
This commit is contained in:
1 parent
19b70430a1
commit
c58ccbfc3f
16 files changed
+1317
-78
No files matched your search
@@ -1781,7 +1781,7 @@ app.use("/ai", aiRoutes);
|
|||||||
app.use("/", alertRulesRoutes);
|
app.use("/", alertRulesRoutes);
|
||||||
app.use("/sync", syncRoutes);
|
app.use("/sync", syncRoutes);
|
||||||
app.use("/plugins", pluginRoutes);
|
app.use("/plugins", pluginRoutes);
|
||||||
app.use("/plugin-api", pluginApiRoutes);
|
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
|
||||||
|
|
||||||
const frontendDistPaths = [
|
const frontendDistPaths = [
|
||||||
path.join(__dirname, "../../../dist"),
|
path.join(__dirname, "../../../dist"),
|
||||||
|
|||||||
@@ -7,20 +7,36 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { createCurrentPluginRepository } from "../repositories/factory.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
import {
|
||||||
|
createCurrentPluginPermissionGrantRepository,
|
||||||
|
createCurrentPluginRepository,
|
||||||
|
} from "../repositories/factory.js";
|
||||||
import { getPluginRuntime } from "../../plugins/index.js";
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
|
const requireManagePlugins = permissionManager.requirePermission(
|
||||||
|
"admin.plugins.manage",
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /plugins:
|
* /plugins:
|
||||||
* get:
|
* get:
|
||||||
* summary: List installed plugins and their runtime state
|
* summary: List installed plugins and their runtime state
|
||||||
* description: Returns every plugin known to the database, merged with the loader's live state so the UI can tell "enabled but crashed" from "disabled".
|
* description: >
|
||||||
|
* Returns every plugin known to the database, merged with the loader's
|
||||||
|
* live state so the UI can tell "enabled but crashed" from "disabled".
|
||||||
|
* Open to any authenticated user, not just admins: the app shell calls
|
||||||
|
* this on every session to decide which plugin-contributed tabs and
|
||||||
|
* rail items to register, so gating it behind admin.plugins.manage
|
||||||
|
* would break the shell for non-admin users. Only the mutating routes
|
||||||
|
* below (enable/disable, grant/revoke) require that permission.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* responses:
|
* responses:
|
||||||
@@ -32,28 +48,43 @@ router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
|
|||||||
const records = await createCurrentPluginRepository().listAll();
|
const records = await createCurrentPluginRepository().listAll();
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
const live = new Map(loader.list().map((p) => [p.id, p]));
|
const live = new Map(loader.list().map((p) => [p.id, p]));
|
||||||
|
const grantRepository = createCurrentPluginPermissionGrantRepository();
|
||||||
|
|
||||||
const plugins = records.map((record) => {
|
const plugins = await Promise.all(
|
||||||
const loaded = live.get(record.id);
|
records.map(async (record) => {
|
||||||
let contributes: unknown = null;
|
const loaded = live.get(record.id);
|
||||||
try {
|
let contributes: unknown = null;
|
||||||
contributes = JSON.parse(record.manifestJson)?.contributes ?? null;
|
let permissions: string[] = [];
|
||||||
} catch {
|
try {
|
||||||
contributes = null;
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
}
|
contributes?: unknown;
|
||||||
|
permissions?: unknown;
|
||||||
|
};
|
||||||
|
contributes = manifest?.contributes ?? null;
|
||||||
|
permissions = Array.isArray(manifest?.permissions)
|
||||||
|
? (manifest.permissions as string[])
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
contributes = null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const grants = await grantRepository.listByPlugin(record.id);
|
||||||
id: record.id,
|
|
||||||
name: record.name,
|
return {
|
||||||
version: record.version,
|
id: record.id,
|
||||||
tier: record.tier,
|
name: record.name,
|
||||||
source: record.source,
|
version: record.version,
|
||||||
enabled: record.state === "enabled",
|
tier: record.tier,
|
||||||
runtimeState: loaded?.state ?? "stopped",
|
source: record.source,
|
||||||
lastError: loaded?.lastError ?? null,
|
enabled: record.state === "enabled",
|
||||||
contributes,
|
runtimeState: loaded?.state ?? "stopped",
|
||||||
};
|
lastError: loaded?.lastError ?? null,
|
||||||
});
|
contributes,
|
||||||
|
permissions,
|
||||||
|
grantedCapabilities: grants.map((grant) => grant.capability),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
res.json(plugins);
|
res.json(plugins);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,12 +125,15 @@ router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
|
|||||||
* description: The plugin's new state.
|
* description: The plugin's new state.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid request body.
|
* description: Invalid request body.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
* 404:
|
* 404:
|
||||||
* description: No such plugin.
|
* description: No such plugin.
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
"/:id/state",
|
"/:id/state",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const pluginId = String(req.params.id);
|
const pluginId = String(req.params.id);
|
||||||
@@ -148,4 +182,173 @@ router.patch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/grants:
|
||||||
|
* post:
|
||||||
|
* summary: Grant a plugin one of its manifest-declared capabilities
|
||||||
|
* description: >
|
||||||
|
* The grant is install-wide, not per-user: it unlocks the capability for
|
||||||
|
* the plugin as a whole. What each call then does with the capability
|
||||||
|
* (e.g. whose hosts ctx.hosts.list() returns) is still scoped to
|
||||||
|
* whichever user's request triggered it.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* capability:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The capability is now granted.
|
||||||
|
* 400:
|
||||||
|
* description: The capability is not declared in the plugin's manifest.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/grants",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const { capability } = req.body ?? {};
|
||||||
|
|
||||||
|
if (typeof capability !== "string" || !capability) {
|
||||||
|
res.status(400).json({ error: "capability is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repository = createCurrentPluginRepository();
|
||||||
|
const record = await repository.findById(pluginId);
|
||||||
|
if (!record) {
|
||||||
|
res.status(404).json({ error: "Plugin not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let declared: string[] = [];
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
|
permissions?: unknown;
|
||||||
|
};
|
||||||
|
declared = Array.isArray(manifest?.permissions)
|
||||||
|
? (manifest.permissions as string[])
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
declared = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!declared.includes(capability)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "This capability is not declared in the plugin's manifest",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantRepository = createCurrentPluginPermissionGrantRepository();
|
||||||
|
const existing = await grantRepository.findGrant(pluginId, capability);
|
||||||
|
if (!existing) {
|
||||||
|
await grantRepository.grant({
|
||||||
|
pluginId,
|
||||||
|
capability,
|
||||||
|
grantedBy: userId,
|
||||||
|
});
|
||||||
|
invalidatePluginPermissionCache(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseLogger.info(`Granted ${capability} to plugin ${pluginId}`, {
|
||||||
|
operation: "plugin_grant",
|
||||||
|
pluginId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ id: pluginId, capability, granted: true });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to grant ${capability} to plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_grant" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to grant the capability" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/grants/{capability}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke a previously granted plugin capability
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: capability
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The capability is no longer granted.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin, or the capability was not granted.
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/:id/grants/:capability",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const capability = String(req.params.capability);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const revoked =
|
||||||
|
await createCurrentPluginPermissionGrantRepository().revoke(
|
||||||
|
pluginId,
|
||||||
|
capability,
|
||||||
|
);
|
||||||
|
if (!revoked) {
|
||||||
|
res.status(404).json({ error: "That capability was not granted" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invalidatePluginPermissionCache(pluginId);
|
||||||
|
|
||||||
|
databaseLogger.info(`Revoked ${capability} from plugin ${pluginId}`, {
|
||||||
|
operation: "plugin_grant_revoke",
|
||||||
|
pluginId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ id: pluginId, capability, granted: false });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to revoke ${capability} from plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_grant_revoke" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to revoke the capability" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -216,7 +216,7 @@ export class PluginBroker {
|
|||||||
runtime: PluginRuntime,
|
runtime: PluginRuntime,
|
||||||
request: PluginRequest,
|
request: PluginRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { method, args } = request;
|
const { method, args, callerUserId } = request;
|
||||||
let failure: Error | null = null;
|
let failure: Error | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -229,7 +229,7 @@ export class PluginBroker {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = await this.invoke(runtime, method, args);
|
const value = await this.invoke(runtime, method, args, callerUserId);
|
||||||
this.reply(runtime, { id: request.id, ok: true, value });
|
this.reply(runtime, { id: request.id, ok: true, value });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failure = error instanceof Error ? error : new Error(String(error));
|
failure = error instanceof Error ? error : new Error(String(error));
|
||||||
@@ -246,7 +246,7 @@ export class PluginBroker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (AUDITED.has(method)) {
|
if (AUDITED.has(method)) {
|
||||||
void this.audit(runtime, method, args, failure);
|
void this.audit(runtime, method, args, failure, callerUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +263,7 @@ export class PluginBroker {
|
|||||||
runtime: PluginRuntime,
|
runtime: PluginRuntime,
|
||||||
method: string,
|
method: string,
|
||||||
args: unknown[],
|
args: unknown[],
|
||||||
|
callerUserId?: string,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case "log.debug":
|
case "log.debug":
|
||||||
@@ -277,10 +278,10 @@ export class PluginBroker {
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
case "hosts.list":
|
case "hosts.list":
|
||||||
return this.handleHostsList(runtime);
|
return this.handleHostsList(runtime, callerUserId);
|
||||||
|
|
||||||
case "hosts.get":
|
case "hosts.get":
|
||||||
return this.handleHostsGet(runtime, Number(args[0]));
|
return this.handleHostsGet(runtime, Number(args[0]), callerUserId);
|
||||||
|
|
||||||
case "storage.get":
|
case "storage.get":
|
||||||
return this.handleStorageGet(runtime, storageKey(args[0]));
|
return this.handleStorageGet(runtime, storageKey(args[0]));
|
||||||
@@ -304,7 +305,7 @@ export class PluginBroker {
|
|||||||
return this.handleHttpRoute(runtime, String(args[0]), String(args[1]));
|
return this.handleHttpRoute(runtime, String(args[0]), String(args[1]));
|
||||||
|
|
||||||
case "ssh.connect":
|
case "ssh.connect":
|
||||||
return this.handleSshConnect(runtime, Number(args[0]));
|
return this.handleSshConnect(runtime, Number(args[0]), callerUserId);
|
||||||
|
|
||||||
case "ssh.exec":
|
case "ssh.exec":
|
||||||
return this.handleSshExec(
|
return this.handleSshExec(
|
||||||
@@ -349,8 +350,9 @@ export class PluginBroker {
|
|||||||
|
|
||||||
private async handleHostsList(
|
private async handleHostsList(
|
||||||
runtime: PluginRuntime,
|
runtime: PluginRuntime,
|
||||||
|
callerUserId?: string,
|
||||||
): Promise<PluginHostView[]> {
|
): Promise<PluginHostView[]> {
|
||||||
const userId = this.requireOwner(runtime);
|
const userId = this.requireActor(runtime, callerUserId);
|
||||||
const hosts = await this.deps.listHosts(userId);
|
const hosts = await this.deps.listHosts(userId);
|
||||||
return hosts.map(toPluginHostView);
|
return hosts.map(toPluginHostView);
|
||||||
}
|
}
|
||||||
@@ -358,8 +360,9 @@ export class PluginBroker {
|
|||||||
private async handleHostsGet(
|
private async handleHostsGet(
|
||||||
runtime: PluginRuntime,
|
runtime: PluginRuntime,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
|
callerUserId?: string,
|
||||||
): Promise<PluginHostView | null> {
|
): Promise<PluginHostView | null> {
|
||||||
const userId = this.requireOwner(runtime);
|
const userId = this.requireActor(runtime, callerUserId);
|
||||||
if (!Number.isFinite(hostId)) {
|
if (!Number.isFinite(hostId)) {
|
||||||
throw new PluginFacingError("hosts.get requires a numeric host id");
|
throw new PluginFacingError("hosts.get requires a numeric host id");
|
||||||
}
|
}
|
||||||
@@ -453,8 +456,9 @@ export class PluginBroker {
|
|||||||
private async handleSshConnect(
|
private async handleSshConnect(
|
||||||
runtime: PluginRuntime,
|
runtime: PluginRuntime,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
|
callerUserId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const userId = this.requireOwner(runtime);
|
const userId = this.requireActor(runtime, callerUserId);
|
||||||
if (!Number.isFinite(hostId)) {
|
if (!Number.isFinite(hostId)) {
|
||||||
throw new PluginFacingError("ssh.connect requires a numeric host id");
|
throw new PluginFacingError("ssh.connect requires a numeric host id");
|
||||||
}
|
}
|
||||||
@@ -651,8 +655,30 @@ export class PluginBroker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireOwner(runtime: PluginRuntime): string {
|
/**
|
||||||
const userId = runtime.plugin.ownerUserId;
|
* Resolves the user a privileged ctx call acts as. This is where the two
|
||||||
|
* identity models that coexist in one plugin meet:
|
||||||
|
*
|
||||||
|
* - Inside an HTTP handler, `callerUserId` is set -- the worker attached
|
||||||
|
* it from the AsyncLocalStorage context it entered around that specific
|
||||||
|
* ctx.http.route invocation (see worker-bootstrap.ts). The call acts as
|
||||||
|
* whichever user's request is currently being served, so
|
||||||
|
* ctx.hosts.list() returns THEIR hosts, not the plugin installer's.
|
||||||
|
* Grants stay install-wide (an admin unlocks the capability for the
|
||||||
|
* plugin as a whole); this is only about whose DATA the call touches.
|
||||||
|
*
|
||||||
|
* - From a schedule.every timer or an events.on listener, there is no
|
||||||
|
* inbound request and so no AsyncLocalStorage context to read from --
|
||||||
|
* `callerUserId` is undefined. These fall back to ownerUserId, the user
|
||||||
|
* who last enabled the plugin, which is what background work has
|
||||||
|
* always run as.
|
||||||
|
*
|
||||||
|
* A plugin cannot influence which branch it gets: no ctx method takes a
|
||||||
|
* userId argument, so `callerUserId` only ever reflects a real inbound
|
||||||
|
* request the server's own auth middleware verified.
|
||||||
|
*/
|
||||||
|
private requireActor(runtime: PluginRuntime, callerUserId?: string): string {
|
||||||
|
const userId = callerUserId ?? runtime.plugin.ownerUserId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new PluginFacingError(
|
throw new PluginFacingError(
|
||||||
`Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`,
|
`Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`,
|
||||||
@@ -666,13 +692,17 @@ export class PluginBroker {
|
|||||||
method: string,
|
method: string,
|
||||||
args: unknown[],
|
args: unknown[],
|
||||||
failure: Error | null,
|
failure: Error | null,
|
||||||
|
callerUserId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = runtime.plugin.ownerUserId;
|
const userId = callerUserId ?? runtime.plugin.ownerUserId;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
// Attribution comes from the broker, never from the plugin: username is
|
// Attribution comes from the broker, never from the plugin: username is
|
||||||
// the plugin id, not the user's, so a plugin action is never mistaken for
|
// the plugin id, not the user's, so a plugin action is never mistaken for
|
||||||
// something the person did themselves.
|
// something the person did themselves. The userId this is logged against
|
||||||
|
// is whichever identity requireActor would have used for the same call
|
||||||
|
// (see its comment), so a request-triggered action is attributed to the
|
||||||
|
// requester, not always to whoever installed the plugin.
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId,
|
userId,
|
||||||
username: `plugin:${runtime.plugin.id}`,
|
username: `plugin:${runtime.plugin.id}`,
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export function buildPluginRouter(
|
|||||||
query: req.query,
|
query: req.query,
|
||||||
body: req.body ?? null,
|
body: req.body ?? null,
|
||||||
headers: pickHeaders(req),
|
headers: pickHeaders(req),
|
||||||
// The acting user, resolved by the server's own auth middleware.
|
// The acting user, verified by authenticateJWT ahead of the
|
||||||
// A plugin cannot set or spoof this.
|
// /plugin-api mount in database.ts. A plugin cannot set or spoof this.
|
||||||
userId: (req as Request & { userId?: string }).userId ?? null,
|
userId: (req as Request & { userId?: string }).userId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,9 @@ export class PluginLoader {
|
|||||||
/**
|
/**
|
||||||
* `ownerUserId` is optional only for in-process first-party plugins, which
|
* `ownerUserId` is optional only for in-process first-party plugins, which
|
||||||
* do not use the gated ctx and so never act as a user. A worker plugin
|
* do not use the gated ctx and so never act as a user. A worker plugin
|
||||||
* without one cannot reach any user data (see broker.requireOwner).
|
* without one cannot reach any user data outside of a per-request caller
|
||||||
|
* identity (see broker.requireActor), which only exists inside an HTTP
|
||||||
|
* handler invocation and is never a substitute for having an owner at all.
|
||||||
*/
|
*/
|
||||||
async activate(pluginId: string, ownerUserId?: string): Promise<void> {
|
async activate(pluginId: string, ownerUserId?: string): Promise<void> {
|
||||||
const plugin = this.requirePlugin(pluginId);
|
const plugin = this.requirePlugin(pluginId);
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export interface PluginRequest {
|
|||||||
/** Dotted ctx path, e.g. "hosts.get" or "storage.set". */
|
/** Dotted ctx path, e.g. "hosts.get" or "storage.set". */
|
||||||
method: string;
|
method: string;
|
||||||
args: unknown[];
|
args: unknown[];
|
||||||
|
/**
|
||||||
|
* The user whose HTTP request is currently executing inside the worker, if
|
||||||
|
* any. Set by the worker's own call() from its AsyncLocalStorage context,
|
||||||
|
* never by plugin code -- no ctx method accepts a userId argument, so a
|
||||||
|
* plugin has no way to set or forge this itself. Absent for a ctx call made
|
||||||
|
* from a timer or event listener, which has no inbound request to derive an
|
||||||
|
* actor from; the broker falls back to the plugin's ownerUserId then.
|
||||||
|
*/
|
||||||
|
callerUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginResponseOk {
|
export interface PluginResponseOk {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* account of what that does and does not guarantee.
|
* account of what that does and does not guarantee.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { parentPort, workerData } from "node:worker_threads";
|
import { parentPort, workerData } from "node:worker_threads";
|
||||||
import type {
|
import type {
|
||||||
PluginBootstrapData,
|
PluginBootstrapData,
|
||||||
@@ -33,11 +34,30 @@ const httpRoutes = new Map<string, (req: unknown) => unknown>();
|
|||||||
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
|
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||||
const timers = new Map<string, () => void>();
|
const timers = new Map<string, () => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks which user's HTTP request is currently executing, so a ctx call made
|
||||||
|
* anywhere in that request's call stack -- including deep inside plugin code
|
||||||
|
* that has no idea this exists -- can be attributed to them without adding a
|
||||||
|
* userId parameter to every ctx method.
|
||||||
|
*
|
||||||
|
* Only entered around an ctx.http.route handler invocation (see handlePush
|
||||||
|
* below). A ctx call made from a schedule.every timer or an events.on
|
||||||
|
* listener runs outside any run() call, so getStore() correctly returns
|
||||||
|
* undefined for those and the broker falls back to the plugin's owner.
|
||||||
|
*/
|
||||||
|
const requestActor = new AsyncLocalStorage<{ userId: string }>();
|
||||||
|
|
||||||
function call(method: string, ...args: unknown[]): Promise<unknown> {
|
function call(method: string, ...args: unknown[]): Promise<unknown> {
|
||||||
const id = nextRequestId++;
|
const id = nextRequestId++;
|
||||||
|
const callerUserId = requestActor.getStore()?.userId;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
pending.set(id, { resolve, reject });
|
pending.set(id, { resolve, reject });
|
||||||
port!.postMessage({ id, method, args });
|
port!.postMessage({
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
...(callerUserId && { callerUserId }),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +133,16 @@ async function handlePush(push: PluginPush): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestUserId =
|
||||||
|
(push.payload as { userId?: string | null } | undefined)?.userId ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = await handler(push.payload);
|
const value = await (requestUserId
|
||||||
|
? requestActor.run({ userId: requestUserId }, () =>
|
||||||
|
handler(push.payload),
|
||||||
|
)
|
||||||
|
: handler(push.payload));
|
||||||
port!.postMessage({ id: push.replyTo, ok: true, value });
|
port!.postMessage({ id: push.replyTo, ok: true, value });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
port!.postMessage({
|
port!.postMessage({
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* database.ts mounts /plugin-api behind authenticateJWT, the same way every
|
||||||
|
* other router is gated. This exercises that exact wiring (rather than
|
||||||
|
* http-bridge.test.ts's bare app, which mounts the dispatcher with no auth
|
||||||
|
* in front of it on purpose, to test the dispatcher in isolation) so a
|
||||||
|
* regression here - an unauthenticated caller reaching a plugin route - is
|
||||||
|
* caught.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
import {
|
||||||
|
createFixturePlugin,
|
||||||
|
type Fixture,
|
||||||
|
} from "../../plugins/fixture-plugin.js";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
validToken: "valid-token",
|
||||||
|
userId: "user-1",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
pluginLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
databaseLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
req: Record<string, unknown> & { headers: Record<string, string> },
|
||||||
|
res: { status: (code: number) => { json: (body: unknown) => void } },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
const auth = req.headers["authorization"];
|
||||||
|
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||||
|
if (token !== state.validToken) {
|
||||||
|
res.status(401).json({ error: "Missing authentication token" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.userId = state.userId;
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { AuthManager } = await import("../../../utils/auth-manager.js");
|
||||||
|
const { PluginBroker } = await import("../../../plugins/broker.js");
|
||||||
|
const { PluginLoader } = await import("../../../plugins/loader.js");
|
||||||
|
const { buildPluginRouter } = await import("../../../plugins/http-bridge.js");
|
||||||
|
const pluginApi = await import("../../../database/routes/plugin-api-routes.js");
|
||||||
|
|
||||||
|
const WORKER_TIMEOUT = 30_000;
|
||||||
|
|
||||||
|
describe("/plugin-api behind the app's real auth middleware", () => {
|
||||||
|
const fixtures: Fixture[] = [];
|
||||||
|
let loader: InstanceType<typeof PluginLoader> | null = null;
|
||||||
|
let broker: InstanceType<typeof PluginBroker> | null = null;
|
||||||
|
let server: Server | null = null;
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
// Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes).
|
||||||
|
app.use("/plugin-api", authenticateJWT, pluginApi.default);
|
||||||
|
|
||||||
|
server = await new Promise<Server>((resolve) => {
|
||||||
|
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||||
|
});
|
||||||
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const fixture = createFixturePlugin({
|
||||||
|
backendSource: `
|
||||||
|
export async function activate(ctx) {
|
||||||
|
await ctx.http.route("GET", "/whoami", async (req) => ({
|
||||||
|
userId: req.userId ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
fixtures.push(fixture);
|
||||||
|
|
||||||
|
broker = new PluginBroker({
|
||||||
|
listHosts: async () => [],
|
||||||
|
resolveHost: async () => null,
|
||||||
|
onRoutesChanged: (runtime) => {
|
||||||
|
pluginApi.registerPluginRouter(
|
||||||
|
runtime.plugin.id,
|
||||||
|
buildPluginRouter(broker!, runtime),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loader = new PluginLoader({
|
||||||
|
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
|
||||||
|
onWorkerGone: (plugin) => {
|
||||||
|
broker!.detach(plugin.id);
|
||||||
|
pluginApi.unregisterPluginRouter(plugin.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await loader.load(fixture.dir);
|
||||||
|
await loader.activate("sample-plugin", "user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await loader?.shutdown();
|
||||||
|
loader = null;
|
||||||
|
broker = null;
|
||||||
|
|
||||||
|
for (const id of pluginApi.getRegisteredPluginIds()) {
|
||||||
|
pluginApi.unregisterPluginRouter(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||||
|
server = null;
|
||||||
|
while (fixtures.length) fixtures.pop()!.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"rejects a request with no token before it reaches the plugin",
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
},
|
||||||
|
WORKER_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"rejects a request with an invalid token",
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
|
||||||
|
headers: { authorization: "Bearer not-the-right-token" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
},
|
||||||
|
WORKER_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"forwards the verified userId once authenticated",
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
|
||||||
|
headers: { authorization: `Bearer ${state.validToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ userId: state.userId });
|
||||||
|
},
|
||||||
|
WORKER_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* The plugin control plane: listing installed plugins (with their declared
|
||||||
|
* and granted capabilities) and granting/revoking a capability. Enable/disable
|
||||||
|
* is not re-tested here beyond what already existed; the grant/revoke routes
|
||||||
|
* are the new surface this file covers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
interface PluginRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
tier: string;
|
||||||
|
source: string;
|
||||||
|
state: string;
|
||||||
|
manifestJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
plugins: new Map<string, PluginRow>(),
|
||||||
|
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
|
||||||
|
// Which users have admin.plugins.manage, keyed by userId.
|
||||||
|
managers: new Set<string>(["admin-1"]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller identifies as whichever user the x-test-user-id header names,
|
||||||
|
* mirroring the real JWT middleware's job of setting req.userId -- these
|
||||||
|
* tests only need to vary which user is calling, not verify real tokens.
|
||||||
|
*/
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
req: Record<string, unknown> & { headers: Record<string, string> },
|
||||||
|
_res: unknown,
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
req.userId = req.headers["x-test-user-id"] ?? "admin-1";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
requirePermission:
|
||||||
|
(_permission: string) =>
|
||||||
|
(
|
||||||
|
req: Record<string, unknown>,
|
||||||
|
res: {
|
||||||
|
status: (code: number) => { json: (body: unknown) => void };
|
||||||
|
},
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
const userId = req.userId as string;
|
||||||
|
if (!state.managers.has(userId)) {
|
||||||
|
res.status(403).json({ error: "Insufficient permissions" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../plugins/index.js", () => ({
|
||||||
|
getPluginRuntime: () => ({ loader: { list: () => [] } }),
|
||||||
|
activatePlugin: vi.fn(),
|
||||||
|
deactivatePlugin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../plugins/permissions.js", () => ({
|
||||||
|
invalidatePluginPermissionCache: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginRepository: () => ({
|
||||||
|
listAll: async () => [...state.plugins.values()],
|
||||||
|
findById: async (id: string) => state.plugins.get(id) ?? null,
|
||||||
|
update: async (id: string, changes: Partial<PluginRow>) => {
|
||||||
|
const existing = state.plugins.get(id);
|
||||||
|
if (existing) state.plugins.set(id, { ...existing, ...changes });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentPluginPermissionGrantRepository: () => ({
|
||||||
|
listByPlugin: async (pluginId: string) =>
|
||||||
|
state.grants
|
||||||
|
.filter((g) => g.pluginId === pluginId)
|
||||||
|
.map((g) => ({ capability: g.capability })),
|
||||||
|
findGrant: async (pluginId: string, capability: string) =>
|
||||||
|
state.grants.find(
|
||||||
|
(g) => g.pluginId === pluginId && g.capability === capability,
|
||||||
|
) ?? null,
|
||||||
|
grant: async (input: {
|
||||||
|
pluginId: string;
|
||||||
|
capability: string;
|
||||||
|
grantedBy: string;
|
||||||
|
}) => {
|
||||||
|
state.grants.push(input);
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
revoke: async (pluginId: string, capability: string) => {
|
||||||
|
const before = state.grants.length;
|
||||||
|
state.grants = state.grants.filter(
|
||||||
|
(g) => !(g.pluginId === pluginId && g.capability === capability),
|
||||||
|
);
|
||||||
|
return state.grants.length < before;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||||
|
.default;
|
||||||
|
|
||||||
|
function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
|
||||||
|
return {
|
||||||
|
id: "sample-plugin",
|
||||||
|
name: "Sample Plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
tier: "community",
|
||||||
|
source: "community",
|
||||||
|
state: "enabled",
|
||||||
|
manifestJson: JSON.stringify({
|
||||||
|
permissions: ["hosts.read", "storage.own"],
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugins route", () => {
|
||||||
|
let server: Server | null = null;
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
state.plugins = new Map();
|
||||||
|
state.grants = [];
|
||||||
|
state.managers = new Set(["admin-1"]);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/plugins", pluginRoutes);
|
||||||
|
|
||||||
|
server = await new Promise<Server>((resolve) => {
|
||||||
|
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||||
|
});
|
||||||
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||||
|
server = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a plugin's declared permissions and granted capabilities", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
state.grants.push({
|
||||||
|
pluginId: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
grantedBy: "admin-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins`);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "sample-plugin",
|
||||||
|
permissions: ["hosts.read", "storage.own"],
|
||||||
|
grantedCapabilities: ["hosts.read"],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants a declared capability", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capability: "hosts.read" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
id: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
granted: true,
|
||||||
|
});
|
||||||
|
expect(state.grants).toEqual([
|
||||||
|
{
|
||||||
|
pluginId: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
grantedBy: "admin-1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects granting a capability the manifest does not declare", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capability: "ssh.exec" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(state.grants).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s granting a capability for a plugin that does not exist", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capability: "hosts.read" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an already-granted capability", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capability: "hosts.read" }),
|
||||||
|
});
|
||||||
|
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ capability: "hosts.read" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.grants).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes a granted capability", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
state.grants.push({
|
||||||
|
pluginId: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
grantedBy: "admin-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
id: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
granted: false,
|
||||||
|
});
|
||||||
|
expect(state.grants).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s revoking a capability that was not granted", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin.plugins.manage gate", () => {
|
||||||
|
it("a non-admin authenticated user cannot list plugins", async () => {
|
||||||
|
// GET stays open to any authenticated user on purpose: the app shell
|
||||||
|
// calls it for every session to know which plugin tabs to register.
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins`, {
|
||||||
|
headers: { "x-test-user-id": "regular-user" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403s a non-admin enabling or disabling a plugin", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/sample-plugin/state`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-test-user-id": "regular-user",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(state.plugins.get("sample-plugin")?.state).toBe("enabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403s a non-admin granting a capability", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-test-user-id": "regular-user",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ capability: "hosts.read" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(state.grants).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403s a non-admin revoking a capability", async () => {
|
||||||
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
state.grants.push({
|
||||||
|
pluginId: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
grantedBy: "admin-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "x-test-user-id": "regular-user" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(state.grants).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ const { PluginBroker } = await import("../../plugins/broker.js");
|
|||||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||||
const { buildPluginRouter } = await import("../../plugins/http-bridge.js");
|
const { buildPluginRouter } = await import("../../plugins/http-bridge.js");
|
||||||
const pluginApi = await import("../../database/routes/plugin-api-routes.js");
|
const pluginApi = await import("../../database/routes/plugin-api-routes.js");
|
||||||
|
const { invalidatePluginPermissionCache } =
|
||||||
|
await import("../../plugins/permissions.js");
|
||||||
|
|
||||||
const WORKER_TIMEOUT = 30_000;
|
const WORKER_TIMEOUT = 30_000;
|
||||||
|
|
||||||
@@ -314,4 +316,165 @@ describe("plugin HTTP bridge", () => {
|
|||||||
},
|
},
|
||||||
WORKER_TIMEOUT,
|
WORKER_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
describe("per-request actor identity", () => {
|
||||||
|
/**
|
||||||
|
* Exercises the real worker end to end: the fixture's activate() calls
|
||||||
|
* ctx.hosts.list() from inside a genuine ctx.http.route handler, so the
|
||||||
|
* AsyncLocalStorage context worker-bootstrap.ts enters around that
|
||||||
|
* invocation is the thing under test, not a mock of it. Two concurrent
|
||||||
|
* requests as two different users must each see only their own hosts,
|
||||||
|
* even though both are in flight on the very same worker at once.
|
||||||
|
*/
|
||||||
|
async function activateWithHostsRoute() {
|
||||||
|
const fixture = createFixturePlugin({
|
||||||
|
backendSource: `
|
||||||
|
export async function activate(ctx) {
|
||||||
|
await ctx.http.route("GET", "/my-hosts", async () => {
|
||||||
|
const hosts = await ctx.hosts.list();
|
||||||
|
return { hosts };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
fixtures.push(fixture);
|
||||||
|
|
||||||
|
const hostsByUser: Record<string, { id: number; name: string }[]> = {
|
||||||
|
alice: [{ id: 1, name: "alice-host" }],
|
||||||
|
bob: [{ id: 2, name: "bob-host" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
broker = new PluginBroker({
|
||||||
|
listHosts: async (userId) => hostsByUser[userId] ?? [],
|
||||||
|
resolveHost: async () => null,
|
||||||
|
onRoutesChanged: (runtime) => {
|
||||||
|
pluginApi.registerPluginRouter(
|
||||||
|
runtime.plugin.id,
|
||||||
|
buildPluginRouter(broker!, runtime),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loader = new PluginLoader({
|
||||||
|
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
|
||||||
|
onWorkerGone: (plugin) => {
|
||||||
|
broker!.detach(plugin.id);
|
||||||
|
pluginApi.unregisterPluginRouter(plugin.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
state.grants.push({
|
||||||
|
pluginId: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
});
|
||||||
|
invalidatePluginPermissionCache("sample-plugin");
|
||||||
|
|
||||||
|
await loader.load(fixture.dir);
|
||||||
|
// Activated under "install-owner", a third identity distinct from
|
||||||
|
// alice and bob, so a test that accidentally fell back to ownerUserId
|
||||||
|
// instead of the per-request actor would show up as a clear mismatch
|
||||||
|
// rather than an accidental pass.
|
||||||
|
await loader.activate("sample-plugin", "install-owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mimics authenticateJWT setting req.userId ahead of the dispatcher, the
|
||||||
|
* way database.ts really wires it (see plugin-api-routes-auth.test.ts for
|
||||||
|
* that wiring itself). This file's own app has no auth in front of
|
||||||
|
* pluginApi.default on purpose, to test the dispatcher in isolation, so
|
||||||
|
* the per-user identity is injected the same minimal way here.
|
||||||
|
*/
|
||||||
|
function fetchAsUser(path: string, userId: string) {
|
||||||
|
return fetch(`${baseUrl}${path}`, {
|
||||||
|
headers: { "x-test-user-id": userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it(
|
||||||
|
"two concurrent requests as different users each see only their own hosts",
|
||||||
|
async () => {
|
||||||
|
await activateWithHostsRoute();
|
||||||
|
|
||||||
|
// Reopen the server with a stand-in auth middleware that reads the
|
||||||
|
// test header, since the beforeEach server has none.
|
||||||
|
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
const userId = req.headers["x-test-user-id"];
|
||||||
|
if (typeof userId === "string") {
|
||||||
|
(req as typeof req & { userId?: string }).userId = userId;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/plugin-api", pluginApi.default);
|
||||||
|
server = await new Promise((resolve) => {
|
||||||
|
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||||
|
});
|
||||||
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const [aliceRes, bobRes] = await Promise.all([
|
||||||
|
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"),
|
||||||
|
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await aliceRes.json()).toMatchObject({
|
||||||
|
hosts: [{ id: 1, name: "alice-host" }],
|
||||||
|
});
|
||||||
|
expect(await bobRes.json()).toMatchObject({
|
||||||
|
hosts: [{ id: 2, name: "bob-host" }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
WORKER_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"falls back to the install owner when a call has no inbound request",
|
||||||
|
async () => {
|
||||||
|
const fixture = createFixturePlugin({
|
||||||
|
backendSource: `
|
||||||
|
export async function activate(ctx) {
|
||||||
|
const hosts = await ctx.hosts.list();
|
||||||
|
await ctx.log.info(JSON.stringify({ hosts }));
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
fixtures.push(fixture);
|
||||||
|
|
||||||
|
const hostsByUser: Record<string, { id: number; name: string }[]> = {
|
||||||
|
"install-owner": [{ id: 9, name: "owner-host" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
broker = new PluginBroker({
|
||||||
|
listHosts: async (userId) => hostsByUser[userId] ?? [],
|
||||||
|
resolveHost: async () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logged: string[] = [];
|
||||||
|
const { pluginLogger } = await import("../../utils/logger.js");
|
||||||
|
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
|
||||||
|
logged.push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
loader = new PluginLoader({
|
||||||
|
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
|
||||||
|
onWorkerGone: (plugin) => broker!.detach(plugin.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.grants.push({
|
||||||
|
pluginId: "sample-plugin",
|
||||||
|
capability: "hosts.read",
|
||||||
|
});
|
||||||
|
invalidatePluginPermissionCache("sample-plugin");
|
||||||
|
|
||||||
|
await loader.load(fixture.dir);
|
||||||
|
await loader.activate("sample-plugin", "install-owner");
|
||||||
|
|
||||||
|
const line = logged.find((entry) => entry.startsWith("{"));
|
||||||
|
expect(line && JSON.parse(line)).toMatchObject({
|
||||||
|
hosts: [{ id: 9, name: "owner-host" }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
WORKER_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -59,6 +59,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
|||||||
"admin.roles.manage",
|
"admin.roles.manage",
|
||||||
"admin.settings.manage",
|
"admin.settings.manage",
|
||||||
"admin.sessions.manage",
|
"admin.sessions.manage",
|
||||||
|
"admin.plugins.manage",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface PluginSummary {
|
|||||||
runtimeState: string;
|
runtimeState: string;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
contributes: PluginContributions | null;
|
contributes: PluginContributions | null;
|
||||||
|
/** Capabilities this plugin's manifest declares it may ask for. */
|
||||||
|
permissions: string[];
|
||||||
|
/** The subset of `permissions` an admin has actually granted. */
|
||||||
|
grantedCapabilities: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlugins(): Promise<PluginSummary[]> {
|
export async function getPlugins(): Promise<PluginSummary[]> {
|
||||||
@@ -36,3 +40,21 @@ export async function setPluginEnabled(
|
|||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function grantPluginCapability(
|
||||||
|
pluginId: string,
|
||||||
|
capability: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, {
|
||||||
|
capability,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokePluginCapability(
|
||||||
|
pluginId: string,
|
||||||
|
capability: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await rbacApi.delete(
|
||||||
|
`/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-1
@@ -3984,7 +3984,37 @@
|
|||||||
"pluginToggleFailed": "Failed to change the plugin",
|
"pluginToggleFailed": "Failed to change the plugin",
|
||||||
"pluginBuiltIn": "BUILT IN",
|
"pluginBuiltIn": "BUILT IN",
|
||||||
"pluginEnabled": "{{name}} enabled",
|
"pluginEnabled": "{{name}} enabled",
|
||||||
"pluginDisabled": "{{name}} disabled"
|
"pluginDisabled": "{{name}} disabled",
|
||||||
|
"pluginPermissions": "Permissions",
|
||||||
|
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
|
||||||
|
"pluginPermissionGranted": "Granted",
|
||||||
|
"pluginPermissionNotGranted": "Not granted",
|
||||||
|
"pluginGrantFailed": "Failed to grant the capability",
|
||||||
|
"pluginRevokeFailed": "Failed to revoke the capability",
|
||||||
|
"pluginPermissionHostsRead": "Read hosts",
|
||||||
|
"pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.",
|
||||||
|
"pluginPermissionHostsWrite": "Modify hosts",
|
||||||
|
"pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.",
|
||||||
|
"pluginPermissionCredentialsUse": "Use stored credentials",
|
||||||
|
"pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.",
|
||||||
|
"pluginPermissionSshExec": "Run commands over SSH",
|
||||||
|
"pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.",
|
||||||
|
"pluginPermissionSshSftp": "Transfer files over SFTP",
|
||||||
|
"pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.",
|
||||||
|
"pluginPermissionStorageOwn": "Store its own data",
|
||||||
|
"pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.",
|
||||||
|
"pluginPermissionStorageSecrets": "Store secrets",
|
||||||
|
"pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.",
|
||||||
|
"pluginPermissionNetworkOutbound": "Make outbound network requests",
|
||||||
|
"pluginPermissionNetworkOutboundDesc": "Contact services outside this server.",
|
||||||
|
"pluginPermissionEventsRead": "Subscribe to server events",
|
||||||
|
"pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.",
|
||||||
|
"pluginPermissionNotifySend": "Send notifications",
|
||||||
|
"pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.",
|
||||||
|
"pluginPermissionUsersRead": "Read user accounts",
|
||||||
|
"pluginPermissionUsersReadDesc": "See basic details about accounts on this server.",
|
||||||
|
"pluginPermissionProcessSidecar": "Run a background process",
|
||||||
|
"pluginPermissionProcessSidecarDesc": "Start and manage its own helper process."
|
||||||
},
|
},
|
||||||
"newUi": {
|
"newUi": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Puzzle } from "lucide-react";
|
import { Puzzle, ShieldCheck } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SettingRow } from "@/components/section-card";
|
import { SettingRow } from "@/components/section-card";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
import {
|
import {
|
||||||
getPlugins,
|
getPlugins,
|
||||||
setPluginEnabled,
|
setPluginEnabled,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
} from "@/api/plugins-api";
|
} from "@/api/plugins-api";
|
||||||
import { refreshPluginState } from "@/shell/pluginLoader";
|
import { refreshPluginState } from "@/shell/pluginLoader";
|
||||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||||
|
import { PluginPermissionsDialog } from "./PluginPermissionsDialog";
|
||||||
|
|
||||||
export function AdminPluginsSection({
|
export function AdminPluginsSection({
|
||||||
open,
|
open,
|
||||||
@@ -22,6 +24,11 @@ export function AdminPluginsSection({
|
|||||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [permissionsTargetId, setPermissionsTargetId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const permissionsTarget =
|
||||||
|
plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null;
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -57,40 +64,64 @@ export function AdminPluginsSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionSection
|
<>
|
||||||
label={t("admin.plugins")}
|
<AccordionSection
|
||||||
icon={<Puzzle className="size-3.5" />}
|
label={t("admin.plugins")}
|
||||||
open={open}
|
icon={<Puzzle className="size-3.5" />}
|
||||||
onToggle={onToggle}
|
open={open}
|
||||||
>
|
onToggle={onToggle}
|
||||||
{plugins.length === 0 ? (
|
>
|
||||||
<p className="text-xs text-muted-foreground py-3">
|
{plugins.length === 0 ? (
|
||||||
{loaded ? t("admin.pluginsNone") : t("common.loading")}
|
<p className="text-xs text-muted-foreground py-3">
|
||||||
</p>
|
{loaded ? t("admin.pluginsNone") : t("common.loading")}
|
||||||
) : (
|
</p>
|
||||||
plugins.map((plugin) => (
|
) : (
|
||||||
<SettingRow
|
plugins.map((plugin) => (
|
||||||
key={plugin.id}
|
<SettingRow
|
||||||
label={plugin.name}
|
key={plugin.id}
|
||||||
badge={
|
label={plugin.name}
|
||||||
plugin.tier === "first-party"
|
badge={
|
||||||
? t("admin.pluginBuiltIn")
|
plugin.tier === "first-party"
|
||||||
: undefined
|
? t("admin.pluginBuiltIn")
|
||||||
}
|
: undefined
|
||||||
description={
|
}
|
||||||
plugin.lastError
|
description={
|
||||||
? plugin.lastError
|
plugin.lastError
|
||||||
: `v${plugin.version} · ${plugin.runtimeState}`
|
? plugin.lastError
|
||||||
}
|
: `v${plugin.version} · ${plugin.runtimeState}`
|
||||||
>
|
}
|
||||||
<AdminToggle
|
>
|
||||||
on={plugin.enabled}
|
<div className="flex items-center gap-2">
|
||||||
onToggle={() => void toggle(plugin)}
|
{plugin.permissions.length > 0 && (
|
||||||
disabled={busy === plugin.id}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
</SettingRow>
|
size="sm"
|
||||||
))
|
className="rounded-none h-7 px-2 text-[10px] font-bold uppercase tracking-widest"
|
||||||
)}
|
onClick={() => setPermissionsTargetId(plugin.id)}
|
||||||
</AccordionSection>
|
>
|
||||||
|
<ShieldCheck className="size-3" />
|
||||||
|
{t("admin.pluginPermissions")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AdminToggle
|
||||||
|
on={plugin.enabled}
|
||||||
|
onToggle={() => void toggle(plugin)}
|
||||||
|
disabled={busy === plugin.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
|
<PluginPermissionsDialog
|
||||||
|
plugin={permissionsTarget}
|
||||||
|
open={permissionsTargetId !== null}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setPermissionsTargetId(null);
|
||||||
|
}}
|
||||||
|
onChanged={() => void load()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/dialog";
|
||||||
|
import {
|
||||||
|
grantPluginCapability,
|
||||||
|
revokePluginCapability,
|
||||||
|
type PluginSummary,
|
||||||
|
} from "@/api/plugins-api";
|
||||||
|
import { AdminToggle } from "./AdminSettingsShared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every permission a plugin's manifest can declare, with the label and
|
||||||
|
* one-line description shown here. process:transport-owner is left out on
|
||||||
|
* purpose: it is reserved for first-party plugins and enforced by a hardcoded
|
||||||
|
* allowlist, not something an admin grants, so showing a toggle for it would
|
||||||
|
* be misleading. ui.* permissions describe what a plugin contributes to the
|
||||||
|
* shell rather than a capability the broker gates, but they are still shown
|
||||||
|
* here since the manifest can declare them and a plugin's grant list should
|
||||||
|
* not silently hide part of what it asked for.
|
||||||
|
*/
|
||||||
|
const LABELED_PERMISSIONS: Array<{
|
||||||
|
capability: string;
|
||||||
|
labelKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
capability: "hosts.read",
|
||||||
|
labelKey: "admin.pluginPermissionHostsRead",
|
||||||
|
descriptionKey: "admin.pluginPermissionHostsReadDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "hosts.write",
|
||||||
|
labelKey: "admin.pluginPermissionHostsWrite",
|
||||||
|
descriptionKey: "admin.pluginPermissionHostsWriteDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "credentials.use",
|
||||||
|
labelKey: "admin.pluginPermissionCredentialsUse",
|
||||||
|
descriptionKey: "admin.pluginPermissionCredentialsUseDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "ssh.exec",
|
||||||
|
labelKey: "admin.pluginPermissionSshExec",
|
||||||
|
descriptionKey: "admin.pluginPermissionSshExecDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "ssh.sftp",
|
||||||
|
labelKey: "admin.pluginPermissionSshSftp",
|
||||||
|
descriptionKey: "admin.pluginPermissionSshSftpDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "storage.own",
|
||||||
|
labelKey: "admin.pluginPermissionStorageOwn",
|
||||||
|
descriptionKey: "admin.pluginPermissionStorageOwnDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "storage.secrets",
|
||||||
|
labelKey: "admin.pluginPermissionStorageSecrets",
|
||||||
|
descriptionKey: "admin.pluginPermissionStorageSecretsDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "network.outbound",
|
||||||
|
labelKey: "admin.pluginPermissionNetworkOutbound",
|
||||||
|
descriptionKey: "admin.pluginPermissionNetworkOutboundDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "events.read",
|
||||||
|
labelKey: "admin.pluginPermissionEventsRead",
|
||||||
|
descriptionKey: "admin.pluginPermissionEventsReadDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "notify.send",
|
||||||
|
labelKey: "admin.pluginPermissionNotifySend",
|
||||||
|
descriptionKey: "admin.pluginPermissionNotifySendDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "users.read",
|
||||||
|
labelKey: "admin.pluginPermissionUsersRead",
|
||||||
|
descriptionKey: "admin.pluginPermissionUsersReadDesc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capability: "process.sidecar",
|
||||||
|
labelKey: "admin.pluginPermissionProcessSidecar",
|
||||||
|
descriptionKey: "admin.pluginPermissionProcessSidecarDesc",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PluginPermissionsDialog({
|
||||||
|
plugin,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
plugin: PluginSummary | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!plugin) return null;
|
||||||
|
|
||||||
|
const rows = LABELED_PERMISSIONS.filter((row) =>
|
||||||
|
plugin.permissions.includes(row.capability),
|
||||||
|
);
|
||||||
|
const granted = new Set(plugin.grantedCapabilities);
|
||||||
|
|
||||||
|
const toggle = async (capability: string, isGranted: boolean) => {
|
||||||
|
setBusy(capability);
|
||||||
|
try {
|
||||||
|
if (isGranted) {
|
||||||
|
await revokePluginCapability(plugin.id, capability);
|
||||||
|
} else {
|
||||||
|
await grantPluginCapability(plugin.id, capability);
|
||||||
|
}
|
||||||
|
onChanged();
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
isGranted
|
||||||
|
? t("admin.pluginRevokeFailed")
|
||||||
|
: t("admin.pluginGrantFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-4 text-accent-brand" />
|
||||||
|
{t("admin.pluginPermissions")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground">
|
||||||
|
{plugin.name}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-3">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground py-3">
|
||||||
|
{t("admin.pluginPermissionsNone")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border overflow-hidden">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const isGranted = granted.has(row.capability);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.capability}
|
||||||
|
className={`flex items-center justify-between gap-3 px-3 py-3 ${
|
||||||
|
i < rows.length - 1 ? "border-b border-border" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold">
|
||||||
|
{t(row.labelKey)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t(row.descriptionKey)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-0.5">
|
||||||
|
{isGranted
|
||||||
|
? t("admin.pluginPermissionGranted")
|
||||||
|
: t("admin.pluginPermissionNotGranted")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AdminToggle
|
||||||
|
on={isGranted}
|
||||||
|
onToggle={() => void toggle(row.capability, isGranted)}
|
||||||
|
disabled={busy === row.capability}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
permissions: [],
|
||||||
|
grantedCapabilities: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user