Files
Termix/src/backend/database/routes/user-api-key-routes.ts
T

258 lines
8.2 KiB
TypeScript

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";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentApiKeyRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
export function registerUserApiKeyRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
/**
* @openapi
* /users/api-keys:
* post:
* summary: Create an API key (admin only)
* description: Creates a new API key scoped to a specific user. The full token is returned only once.
* tags:
* - API Keys
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - userId
* properties:
* name:
* type: string
* description: Human-readable name for the key.
* userId:
* type: string
* description: ID of the user this key is scoped to.
* expiresAt:
* 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).
* 400:
* description: Invalid input.
* 403:
* description: Admin access required.
* 404:
* description: Target user not found.
* 500:
* description: Failed to create API key.
*/
router.post("/api-keys", requireAdmin, async (req, res) => {
try {
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
if (typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (typeof targetUserId !== "string" || !targetUserId.trim()) {
return res.status(400).json({ error: "userId is required" });
}
const targetUser = await userRepository.findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
}
let expiresAtValue: string | null = null;
if (expiresAt) {
const parsed = new Date(expiresAt);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: "Invalid expiresAt date" });
}
if (parsed <= new Date()) {
return res
.status(400)
.json({ error: "expiresAt must be in the future" });
}
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);
const keyId = nanoid();
const now = new Date().toISOString();
await apiKeyRepository.create({
id: keyId,
userId: targetUserId,
name: name.trim(),
tokenHash,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
lastUsedAt: null,
isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
});
const actorId = (req as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = actorId
? await userRepository.findById(actorId)
: null;
await logAudit({
userId: actorId,
username: actorRecord?.username ?? actorId,
action: "create_api_key",
resourceType: "api_key",
resourceId: keyId,
resourceName: name.trim(),
details: JSON.stringify({
targetUserId,
targetUsername: targetUser.username,
scopes: scopesValue,
}),
ipAddress,
userAgent,
success: true,
});
return res.status(201).json({
id: keyId,
name: name.trim(),
userId: targetUserId,
username: targetUser.username,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken,
});
} catch (err) {
authLogger.error("Failed to create API key", err);
return res.status(500).json({ error: "Failed to create API key" });
}
});
/**
* @openapi
* /users/api-keys:
* get:
* summary: List all API keys (admin only)
* description: Returns all API keys with associated usernames. Token hashes are never returned.
* tags:
* - API Keys
* responses:
* 200:
* description: List of API keys.
* 403:
* description: Admin access required.
* 500:
* description: Failed to fetch API keys.
*/
router.get("/api-keys", requireAdmin, async (_req, res) => {
try {
const keys = await createCurrentApiKeyRepository().listAllWithUsers();
return res.json({ apiKeys: keys });
} catch (err) {
authLogger.error("Failed to list API keys", err);
return res.status(500).json({ error: "Failed to fetch API keys" });
}
});
/**
* @openapi
* /users/api-keys/{keyId}:
* delete:
* summary: Delete an API key (admin only)
* description: Permanently deletes an API key. It can no longer be used to authenticate.
* tags:
* - API Keys
* parameters:
* - in: path
* name: keyId
* required: true
* schema:
* type: string
* description: The ID of the API key to delete.
* responses:
* 200:
* description: API key deleted.
* 403:
* description: Admin access required.
* 404:
* description: API key not found.
* 500:
* description: Failed to delete API key.
*/
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
try {
const keyId = String(req.params.keyId);
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
const deleted = await apiKeyRepository.delete(keyId);
if (!deleted) {
return res.status(404).json({ error: "API key not found" });
}
const actorId = (req as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = actorId
? await userRepository.findById(actorId)
: null;
await logAudit({
userId: actorId,
username: actorRecord?.username ?? actorId,
action: "delete_api_key",
resourceType: "api_key",
resourceId: keyId,
resourceName: deleted.name,
ipAddress,
userAgent,
success: true,
});
return res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete API key", err, {
keyId: String(req.params.keyId),
});
return res.status(500).json({ error: "Failed to delete API key" });
}
});
}