mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45:24 +00:00
feat: limit api keys to actions when they are made
This commit is contained in:
1 parent
03029cd372
commit
2e79bf9d0d
8 files changed
+47
-5
No files matched your search
+2
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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": {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user