feat: enforce RBAC and harden collaboration features (#1327)

* feat: enforce RBAC and harden collaboration features

- Mount requirePermission on hosts/snippets/credentials/automations/AI routes
- Seed and backfill system role permissions on every dialect at startup
- Support personal credential overrides for RDP/VNC/Telnet shared hosts
- Broadcast participant presence in shared terminal sessions
- Make audit log forwarding configurable from the admin panel
- Add role members endpoint and snippet folder sharing

* fix: enforce RBAC across split routes
This commit is contained in:
ZacharyZcR
2026-08-24 19:47:29 +08:00
committed by GitHub
parent 69002e6416
commit f3a1087f51
45 changed files with 1262 additions and 115 deletions
@@ -7,6 +7,7 @@ import type {
Trigger,
} from "../../../types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import {
@@ -26,6 +27,7 @@ import {
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
@@ -244,6 +246,7 @@ async function syncSchedule(
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -282,6 +285,7 @@ router.get(
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -333,6 +337,7 @@ router.get(
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -423,6 +428,7 @@ router.post(
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -531,6 +537,7 @@ router.put(
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -600,6 +607,7 @@ router.delete(
router.post(
"/:id/run",
authenticateJWT,
permissionManager.requirePermission("automations.run"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -673,6 +681,7 @@ router.post(
router.get(
"/runs/history",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -715,6 +724,7 @@ router.get(
router.get(
"/runs/:runId/steps",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -6,6 +6,8 @@ import { createCurrentCredentialRepository } from "../repositories/factory.js";
export function registerCredentialBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -42,6 +44,8 @@ export function registerCredentialBulkRoutes(
router.put(
"/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
@@ -359,6 +359,9 @@ async function deploySSHKeyToHost(
export function registerCredentialDeployRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireHostEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -398,6 +401,9 @@ export function registerCredentialDeployRoutes(
router.post(
"/:id/deploy-to-host",
authenticateJWT,
requireCredentialViewPermission,
requireHostEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const id = Array.isArray(req.params.id)
? req.params.id[0]
@@ -63,6 +63,8 @@ function generateSSHKeyPair(
export function registerCredentialKeyRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -94,6 +96,8 @@ export function registerCredentialKeyRoutes(
router.post(
"/detect-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
@@ -150,6 +154,8 @@ export function registerCredentialKeyRoutes(
router.post(
"/detect-public-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { publicKey } = req.body;
@@ -209,6 +215,8 @@ export function registerCredentialKeyRoutes(
router.post(
"/validate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
@@ -274,6 +282,8 @@ export function registerCredentialKeyRoutes(
router.post(
"/generate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
@@ -339,6 +349,8 @@ export function registerCredentialKeyRoutes(
router.post(
"/generate-public-key",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
+31 -3
View File
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
@@ -26,6 +27,7 @@ function isNonEmptyString(val: unknown): val is string {
}
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
@@ -78,6 +80,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -250,6 +253,7 @@ router.post(
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -290,6 +294,7 @@ router.get(
router.get(
"/folders",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -311,7 +316,12 @@ router.get(
// Registered here (before the PUT /:id route below) so the literal
// "/reorder" path segment is matched before Express falls through to the
// PUT /:id param route and treats "reorder" as an id.
registerCredentialBulkRoutes(router, authenticateJWT);
registerCredentialBulkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
);
/**
* @openapi
@@ -340,6 +350,7 @@ registerCredentialBulkRoutes(router, authenticateJWT);
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -432,6 +443,7 @@ router.get(
router.post(
"/:id/duplicate",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -585,6 +597,7 @@ router.post(
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -747,6 +760,7 @@ router.put(
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -878,6 +892,7 @@ router.delete(
router.post(
"/:id/apply-to-host/:hostId",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const credentialId = Array.isArray(req.params.id)
@@ -958,6 +973,7 @@ router.post(
router.get(
"/:id/hosts",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const credentialId = Array.isArray(req.params.id)
@@ -1076,6 +1092,7 @@ function formatSSHHostOutput(
router.put(
"/folders/rename",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
@@ -1107,8 +1124,19 @@ router.put(
},
);
registerCredentialKeyRoutes(router, authenticateJWT);
registerCredentialKeyRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
);
registerCredentialDeployRoutes(router, authenticateJWT);
registerCredentialDeployRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
export default router;
@@ -7,12 +7,19 @@ import { createCurrentHostRepository } from "../repositories/factory.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostAutostartRoutes(
router: Router,
{ authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDataAccess,
}: HostAutostartRoutesDeps,
): void {
/**
* @openapi
@@ -44,6 +51,7 @@ export function registerHostAutostartRoutes(
router.post(
"/autostart/enable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -204,6 +212,8 @@ export function registerHostAutostartRoutes(
router.delete(
"/autostart/disable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
@@ -259,6 +269,8 @@ export function registerHostAutostartRoutes(
router.get(
"/autostart/status",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -118,6 +118,9 @@ export function importedHostUsername(
export function registerHostBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCreatePermission: RequestHandler,
requireEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -182,6 +185,8 @@ export function registerHostBulkRoutes(
router.patch(
"/bulk-update",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostIds, updates } = req.body;
@@ -386,6 +391,8 @@ export function registerHostBulkRoutes(
router.put(
"/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
@@ -432,6 +439,9 @@ export function registerHostBulkRoutes(
router.post(
"/bulk-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
@@ -844,6 +854,9 @@ export function registerHostBulkRoutes(
router.post(
"/ssh-config-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
@@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -33,6 +35,8 @@ export function registerHostCommandHistoryRoutes(
router.get(
"/command-history/:hostId",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdParam = Array.isArray(req.params.hostId)
@@ -98,6 +102,8 @@ export function registerHostCommandHistoryRoutes(
router.delete(
"/command-history",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
@@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -33,6 +35,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.get(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
@@ -98,6 +102,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.post(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
@@ -155,6 +161,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.delete(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
@@ -203,6 +211,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.get(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
@@ -269,6 +279,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.post(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
@@ -327,6 +339,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.delete(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
@@ -375,6 +389,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.get(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
@@ -441,6 +457,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.post(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
@@ -499,6 +517,8 @@ export function registerHostFileManagerBookmarkRoutes(
router.delete(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
@@ -17,12 +17,25 @@ import { isNonEmptyString } from "./host-normalizers.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDeletePermission: RequestHandler;
requireCredentialEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
statsServerUrl: string;
};
export function registerHostFolderRoutes(
router: Router,
{ authenticateJWT, statsServerUrl }: HostFolderRoutesDeps,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDeletePermission,
requireCredentialEditPermission,
requireDataAccess,
statsServerUrl,
}: HostFolderRoutesDeps,
): void {
/**
* @openapi
@@ -54,6 +67,9 @@ export function registerHostFolderRoutes(
router.put(
"/folders/rename",
authenticateJWT,
requireEditPermission,
requireCredentialEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
@@ -113,6 +129,8 @@ export function registerHostFolderRoutes(
router.get(
"/folders",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -170,6 +188,8 @@ export function registerHostFolderRoutes(
router.put(
"/folders/metadata",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId } = req.body;
@@ -275,6 +295,8 @@ export function registerHostFolderRoutes(
router.put(
"/folders/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
@@ -346,6 +368,8 @@ export function registerHostFolderRoutes(
router.delete(
"/folders/:name/hosts",
authenticateJWT,
requireDeletePermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = Array.isArray(req.params.name)
@@ -7,12 +7,17 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
interface HostNetworkRoutesDeps {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireDataAccess: RequestHandler;
}
export function registerHostNetworkRoutes(
router: Router,
{ authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps,
{
authenticateJWT,
requireViewPermission,
requireDataAccess,
}: HostNetworkRoutesDeps,
): void {
/**
* @openapi
@@ -62,6 +67,7 @@ export function registerHostNetworkRoutes(
router.post(
"/db/proxy/test",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
try {
@@ -93,6 +99,7 @@ export function registerHostNetworkRoutes(
router.post(
"/db/host/:id/wake",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
+66 -6
View File
@@ -28,6 +28,7 @@ import {
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../repositories/factory.js";
import {
containsOwnerPrivateAuthUpdate,
@@ -62,6 +63,7 @@ import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
@@ -125,6 +127,7 @@ registerHostInternalRoutes(router);
router.post(
["/db/host", "/enroll"],
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
requireDataAccess,
requireHostEnrollmentAccessForPath,
upload.single("key"),
@@ -817,6 +820,7 @@ router.post(
router.put(
"/db/host/:id",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
upload.single("key"),
async (req: Request, res: Response) => {
@@ -1458,6 +1462,7 @@ router.put(
router.get(
"/db/host",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1601,6 +1606,7 @@ router.get(
router.get(
"/db/host/:id",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Array.isArray(req.params.id)
@@ -2223,6 +2229,7 @@ router.get(
router.delete(
"/db/host/:id",
authenticateJWT,
permissionManager.requirePermission("hosts.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -2354,11 +2361,18 @@ router.delete(
},
);
registerHostFileManagerBookmarkRoutes(router, authenticateJWT);
registerHostFileManagerBookmarkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
router.get(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
@@ -2395,6 +2409,8 @@ router.get(
router.post(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
@@ -2426,7 +2442,12 @@ router.post(
}
},
);
registerHostCommandHistoryRoutes(router, authenticateJWT);
registerHostCommandHistoryRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
async function resolveHostCredentials(
host: Record<string, unknown>,
@@ -2455,6 +2476,24 @@ async function resolveHostCredentials(
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
// Owner auth for the remote desktop protocols is always snapshotted
// for recipients; only their own override credential varies per user.
const authOverrides: Record<string, unknown> = {
ssh: baseSshOverrideState,
};
const overrideCredentialIds =
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
host.id,
requestingUserId,
);
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
if (!host[AUTH_PROTOCOL_METADATA[protocol].enableField]) continue;
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
ownerAuthShared: true,
};
}
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
@@ -2462,9 +2501,7 @@ async function resolveHostCredentials(
key: null,
keyPassword: null,
keyType: null,
authOverrides: {
ssh: baseSshOverrideState,
},
authOverrides,
};
try {
@@ -2480,6 +2517,7 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
@@ -2505,6 +2543,7 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
@@ -2524,6 +2563,7 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
@@ -2547,6 +2587,7 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
@@ -2605,13 +2646,27 @@ async function resolveHostCredentials(
registerHostFolderRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDeletePermission: permissionManager.requirePermission("hosts.delete"),
requireCredentialEditPermission:
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
statsServerUrl: STATS_SERVER_URL,
});
registerHostBulkRoutes(router, authenticateJWT);
registerHostBulkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
registerHostAutostartRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
});
@@ -2656,6 +2711,8 @@ registerHostAutostartRoutes(router, {
router.get(
"/ssh/opkssh/token/:hostId",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId;
@@ -2727,6 +2784,8 @@ router.get(
router.delete(
"/ssh/opkssh/token/:hostId",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId;
@@ -2759,6 +2818,7 @@ registerHostOpksshRoutes(router);
registerHostNetworkRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireDataAccess,
});
+180 -5
View File
@@ -154,6 +154,7 @@ export function parseShareTargets(
router.post(
"/host/:id/share",
authenticateJWT,
permissionManager.requirePermission("hosts.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const hostId = parseInt(id, 10);
@@ -365,6 +366,7 @@ router.post(
router.post(
"/folder/share",
authenticateJWT,
permissionManager.requirePermission("hosts.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder } = req.body ?? {};
@@ -939,7 +941,7 @@ router.get(
router.post(
"/roles",
authenticateJWT,
permissionManager.requireAdmin(),
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const { name, displayName, description } = req.body;
@@ -1031,7 +1033,7 @@ router.post(
router.put(
"/roles/:id",
authenticateJWT,
permissionManager.requireAdmin(),
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const roleId = parseInt(id, 10);
@@ -1174,7 +1176,7 @@ router.get(
router.delete(
"/roles/:id",
authenticateJWT,
permissionManager.requireAdmin(),
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const roleId = parseInt(id, 10);
@@ -1217,6 +1219,53 @@ router.delete(
},
);
/**
* @openapi
* /rbac/roles/{id}/members:
* get:
* summary: List the users holding a role
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Role members.
* 404:
* description: Role not found.
*/
router.get(
"/roles/:id/members",
authenticateJWT,
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const roleId = parseInt(id, 10);
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
try {
const roleRepository = createCurrentRoleRepository();
if (!(await roleRepository.findRoleById(roleId))) {
return res.status(404).json({ error: "Role not found" });
}
res.json({ members: await roleRepository.listRoleMembers(roleId) });
} catch (error) {
databaseLogger.error("Failed to list role members", error, {
operation: "list_role_members",
roleId,
});
res.status(500).json({ error: "Failed to list role members" });
}
},
);
/**
* @openapi
* /rbac/users/{userId}/roles:
@@ -1257,7 +1306,7 @@ router.delete(
router.post(
"/users/:userId/roles",
authenticateJWT,
permissionManager.requireAdmin(),
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
@@ -1382,7 +1431,7 @@ router.post(
router.delete(
"/users/:userId/roles/:roleId",
authenticateJWT,
permissionManager.requireAdmin(),
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
@@ -1524,6 +1573,7 @@ router.get(
router.post(
"/snippet/:id/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
@@ -1639,6 +1689,131 @@ router.post(
* tags:
* - RBAC
*/
/**
* @openapi
* /rbac/snippet-folder/share:
* post:
* summary: Share every snippet in a folder
* description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* folder:
* type: string
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* durationHours:
* type: number
* responses:
* 200:
* description: Folder shared.
* 404:
* description: A target was not found.
*/
router.post(
"/snippet-folder/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder, durationHours } = req.body ?? {};
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "Folder name is required" });
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
try {
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
const found =
target.type === "user"
? await userRepository.findById(target.id as string)
: await roleRepository.findRoleById(target.id as number);
if (!found) {
return res.status(404).json({
error: `Target ${target.type} not found`,
targetId: target.id,
});
}
}
const snippetsInFolder =
await createCurrentSnippetRepository().listOwnedSnippetsInFolder(
userId,
folder,
);
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
for (const snippet of snippetsInFolder) {
for (const target of targets) {
if (target.type === "user" && target.id === userId) continue;
await rbacAccessRepository.upsertSnippetAccess({
snippetId: snippet.id,
grantedBy: userId,
expiresAt,
...(target.type === "user"
? {
targetType: "user" as const,
targetUserId: target.id as string,
}
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
}
}
databaseLogger.success("Snippet folder shared successfully", {
operation: "rbac_snippet_folder_share",
userId,
folder,
snippetsShared: snippetsInFolder.length,
targets: targets.length,
});
res.json({
success: true,
expiresAt,
snippetsShared: snippetsInFolder.length,
});
} catch (error) {
databaseLogger.error("Failed to share snippet folder", error, {
operation: "share_snippet_folder",
folder,
userId,
});
res.status(500).json({ error: "Failed to share snippet folder" });
}
},
);
router.delete(
"/snippet/:id/access/:accessId",
authenticateJWT,
+15
View File
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
@@ -68,6 +69,7 @@ async function getAccessibleSnippet(snippetId: number, userId: string) {
}
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
@@ -90,6 +92,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
router.get(
"/folders",
authenticateJWT,
permissionManager.requirePermission("snippets.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -144,6 +147,7 @@ router.get(
router.post(
"/folders",
authenticateJWT,
permissionManager.requirePermission("snippets.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -226,6 +230,7 @@ router.post(
router.put(
"/folders/:name/metadata",
authenticateJWT,
permissionManager.requirePermission("snippets.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -306,6 +311,7 @@ router.put(
router.put(
"/folders/rename",
authenticateJWT,
permissionManager.requirePermission("snippets.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -382,6 +388,7 @@ router.put(
router.delete(
"/folders/:name",
authenticateJWT,
permissionManager.requirePermission("snippets.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -466,6 +473,7 @@ router.delete(
router.put(
"/reorder",
authenticateJWT,
permissionManager.requirePermission("snippets.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -796,6 +804,7 @@ router.post(
router.get(
"/export",
authenticateJWT,
permissionManager.requirePermission("snippets.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -872,6 +881,7 @@ router.get(
router.post(
"/bulk-import",
authenticateJWT,
permissionManager.requirePermission("snippets.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -932,6 +942,7 @@ router.post(
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("snippets.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1003,6 +1014,7 @@ router.get(
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("snippets.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1073,6 +1085,7 @@ router.get(
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("snippets.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1183,6 +1196,7 @@ router.post(
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("snippets.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1261,6 +1275,7 @@ router.put(
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("snippets.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -7,6 +7,11 @@ import {
setGlobalLogLevel,
} from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
AUDIT_FORWARD_TOKEN_SETTING,
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_URL_SETTING,
} from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
import {
@@ -669,6 +674,125 @@ export function registerUserSettingsRoutes(
* enabled:
* type: boolean
*/
/**
* @openapi
* /users/audit-forwarding:
* get:
* summary: Get audit log forwarding settings (admin only)
* tags:
* - Users
* responses:
* 200:
* description: Forwarding target. The token itself is never returned.
*/
router.get("/audit-forwarding", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
if (!(await getAdminActor(userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository();
const url =
(await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? "";
const hasToken = !!(await settingsRepository.get(
AUDIT_FORWARD_TOKEN_SETTING,
));
res.json({
url,
hasToken,
envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(),
});
} catch (err) {
authLogger.error("Failed to get audit forwarding settings", err);
res
.status(500)
.json({ error: "Failed to get audit forwarding settings" });
}
});
/**
* @openapi
* /users/audit-forwarding:
* patch:
* summary: Update audit log forwarding settings (admin only)
* description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* token:
* type: string
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
*/
router.patch("/audit-forwarding", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { url, token } = req.body ?? {};
if (
typeof url !== "string" ||
(token !== undefined && typeof token !== "string")
) {
return res.status(400).json({ error: "url must be a string" });
}
const trimmedUrl = url.trim();
if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
return res.status(400).json({ error: "url must be http(s)" });
}
const settingsRepository = createCurrentSettingsRepository();
if (!trimmedUrl) {
await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING);
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
} else {
await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl);
if (token !== undefined) {
if (token.trim()) {
await settingsRepository.set(
AUDIT_FORWARD_TOKEN_SETTING,
token.trim(),
);
} else {
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
}
}
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_audit_forwarding",
resourceType: "setting",
details: JSON.stringify({ enabled: !!trimmedUrl }),
ipAddress,
userAgent,
success: true,
});
res.json({ url: trimmedUrl, hasToken: !!token?.trim() });
} catch (err) {
authLogger.error("Failed to update audit forwarding settings", err);
res
.status(500)
.json({ error: "Failed to update audit forwarding settings" });
}
});
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
try {
res.json({