diff --git a/CHANGELOG.md b/CHANGELOG.md index a897fa7ba..2cd43a1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index e622d9ffe..6b64200e2 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -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. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 7ceecc279..67877a860 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -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. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index dfed5bfd5..330cbfea6 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -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": { diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 344c67b5c..954533256 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -356,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise void cn (value): (...inputs: ClassValue[]) => string copyToClipboard (value): (text: string) => Promise -createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise +createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise createCredential (value): (credentialData: Record) => Promise> createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createQuickConnectHost (value): (input: QuickConnectInput) => Host diff --git a/src/backend/database/repositories/api-key-repository.ts b/src/backend/database/repositories/api-key-repository.ts index c037f7800..fbc456df9 100644 --- a/src/backend/database/repositories/api-key-repository.ts +++ b/src/backend/database/repositories/api-key-repository.ts @@ -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)) diff --git a/src/backend/database/routes/user-api-key-routes.ts b/src/backend/database/routes/user-api-key-routes.ts index f09b3a2a8..7134b5d76 100644 --- a/src/backend/database/routes/user-api-key-routes.ts +++ b/src/backend/database/routes/user-api-key-routes.ts @@ -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) { diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index b0f97cadb..81f9a1142 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -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 { try { const response = await authApi.post("/users/api-keys", { name, userId, expiresAt: expiresAt ?? null, + scopes: scopes?.length ? scopes : null, }); return response.data; } catch (error) {