feat: limit api keys to actions when they are made

This commit is contained in:
LukeGus committed 2026-11-04 06:29:49 -06:00
1 parent 03029cd372
commit 2e79bf9d0d
8 files changed
+47 -5

No files matched your search

+2 -1
View File
@@ -5,7 +5,8 @@
### Added ### 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 - 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 ### Changed
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 1.0.24
Plugin API 1.14.0. Plugin API 1.14.0.
+1 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # 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 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. `@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", "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.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+1 -1
View File
@@ -356,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean> 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>> createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
} }
export class ApiKeyRepository { export class ApiKeyRepository {
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt, expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt, lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive, isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
}) })
.from(apiKeys) .from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id)) .leftJoin(users, eq(apiKeys.userId, users.id))
@@ -1,5 +1,6 @@
import type { RequestHandler, Router } from "express"; import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto"; import crypto from "crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
* type: string * type: string
* format: date-time * format: date-time
* description: Optional expiration date. Null means the key never expires. * 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: * responses:
* 201: * 201:
* description: API key created. Contains the full token (shown only once). * 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) => { router.post("/api-keys", requireAdmin, async (req, res) => {
try { try {
const { name, userId: targetUserId, expiresAt } = req.body; const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository(); const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository(); const userRepository = createCurrentUserRepository();
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
expiresAtValue = parsed.toISOString(); 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 rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12); const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10); const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
lastUsedAt: null, lastUsedAt: null,
isActive: true, isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
}); });
const actorId = (req as AuthenticatedRequest).userId; const actorId = (req as AuthenticatedRequest).userId;
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({ details: JSON.stringify({
targetUserId, targetUserId,
targetUsername: targetUser.username, targetUsername: targetUser.username,
scopes: scopesValue,
}), }),
ipAddress, ipAddress,
userAgent, userAgent,
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
tokenPrefix, tokenPrefix,
createdAt: now, createdAt: now,
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken, token: rawToken,
}); });
} catch (err) { } catch (err) {
+4
View File
@@ -87,6 +87,8 @@ export interface ApiKey {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** Null for full access. */
scopes?: string[] | null;
} }
export interface CreatedApiKey extends ApiKey { export interface CreatedApiKey extends ApiKey {
@@ -97,12 +99,14 @@ export async function createApiKey(
name: string, name: string,
userId: string, userId: string,
expiresAt?: string, expiresAt?: string,
scopes?: string[] | null,
): Promise<CreatedApiKey> { ): Promise<CreatedApiKey> {
try { try {
const response = await authApi.post("/users/api-keys", { const response = await authApi.post("/users/api-keys", {
name, name,
userId, userId,
expiresAt: expiresAt ?? null, expiresAt: expiresAt ?? null,
scopes: scopes?.length ? scopes : null,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {