Compare commits

...
13 changed files with 62 additions and 7 deletions

No files matched your search

+2 -1
View File
@@ -5,7 +5,8 @@
### Added
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
- API keys can be limited to reading or running actions
- API keys can be limited to reading or running actions, when an admin makes them
- A click on a host can open its host page, when a plugin offers one
### Changed
+1
View File
@@ -82,6 +82,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 1.0.25
Plugin API 1.14.0.
### Added
- Host click behavior `hostPage` opens a host page when a plugin offers one
- `createApiKey` takes scopes, to limit a key to actions
## 1.0.24
Plugin API 1.14.0.
+1 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.24 | Plugin API 1.14.0
SDK 1.0.25 | Plugin API 1.14.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.24",
"version": "1.0.25",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -356,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
}
export class ApiKeyRepository {
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
@@ -1,5 +1,6 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
* type: string
* format: date-time
* description: Optional expiration date. Null means the key never expires.
* scopes:
* type: array
* items:
* type: string
* enum: [operations:read, operations:run]
* description: Limits the key to actions only. Leave out for full access.
* responses:
* 201:
* description: API key created. Contains the full token (shown only once).
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
*/
router.post("/api-keys", requireAdmin, async (req, res) => {
try {
const { name, userId: targetUserId, expiresAt } = req.body;
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
expiresAtValue = parsed.toISOString();
}
let scopesValue: string[] | null = null;
if (scopes !== undefined && scopes !== null) {
if (
!Array.isArray(scopes) ||
scopes.some(
(scope) =>
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
)
) {
return res.status(400).json({ error: "Unknown API key scope" });
}
scopesValue =
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue,
lastUsedAt: null,
isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
});
const actorId = (req as AuthenticatedRequest).userId;
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({
targetUserId,
targetUsername: targetUser.username,
scopes: scopesValue,
}),
ipAddress,
userAgent,
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken,
});
} catch (err) {
+2 -1
View File
@@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void {
}
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
// approved: a person approved this exact request in the plugin's own UI.
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]);
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
/** A person is the check when the request came from something else. */
@@ -451,6 +451,15 @@ describe("policy", () => {
expect(outcome.status).toBe("success");
});
it("lets a request a person approved in a plugin go by RBAC", async () => {
register();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "approved" },
});
expect(outcome.status).toBe("success");
});
it("treats a community read as a change", async () => {
state.plugin.source = "user";
state.plugin.signedTier = "community";
+2 -1
View File
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
/** What clicking a host does when it already has an open tab. */
export type HostClickBehavior =
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[];
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
"newTab",
"focusExisting",
"focusExistingDoubleClickNew",
"hostPage",
];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [
+4
View File
@@ -87,6 +87,8 @@ export interface ApiKey {
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
/** Null for full access. */
scopes?: string[] | null;
}
export interface CreatedApiKey extends ApiKey {
@@ -97,12 +99,14 @@ export async function createApiKey(
name: string,
userId: string,
expiresAt?: string,
scopes?: string[] | null,
): Promise<CreatedApiKey> {
try {
const response = await authApi.post("/users/api-keys", {
name,
userId,
expiresAt: expiresAt ?? null,
scopes: scopes?.length ? scopes : null,
});
return response.data;
} catch (error) {
+1
View File
@@ -609,6 +609,7 @@
"hostClickBehavior": "Clicking a host",
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
"hostClickNewTab": "Always open a new tab",
"hostClickHostPage": "Open the host page, when a plugin offers one",
"hostClickFocusExisting": "Switch to the open tab",
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
"openInNewTab": "Open in new tab",