mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
@@ -1,6 +1,7 @@
|
|||||||
import { getErrorMessage } from "../utils/error-message.js";
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
|
import { PermissionManager } from "../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { databaseLogger } from "../utils/logger.js";
|
import { databaseLogger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import { applyProposal } from "./tools/executor.js";
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
const aiGate = createAiGate();
|
const aiGate = createAiGate();
|
||||||
@@ -97,6 +99,7 @@ router.get("/status", authenticateJWT, async (req, res) => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/providers",
|
"/providers",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -147,6 +150,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/providers",
|
"/providers",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -224,6 +228,7 @@ router.post(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/providers/:id",
|
"/providers/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -286,6 +291,7 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/providers/:id",
|
"/providers/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -358,6 +364,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/probe-models",
|
"/probe-models",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -423,6 +430,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/providers/:id/models",
|
"/providers/:id/models",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -470,6 +478,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/conversations",
|
"/conversations",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -510,6 +519,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/conversations/:id",
|
"/conversations/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -560,6 +570,7 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/conversations/:id",
|
"/conversations/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -622,6 +633,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/chat/stream",
|
"/chat/stream",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -840,6 +852,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/proposals/:id/apply",
|
"/proposals/:id/apply",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.apply_proposals"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -931,6 +944,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/proposals/:id/reject",
|
"/proposals/:id/reject",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import type { AiTool, ToolDefinitionShape } from "./types.js";
|
|||||||
/**
|
/**
|
||||||
* The allowlist, and the security boundary for the whole feature.
|
* The allowlist, and the security boundary for the whole feature.
|
||||||
*
|
*
|
||||||
* A model can only ever invoke what appears here. This matters more than usual
|
* A model can only ever invoke what appears here. Route-level RBAC gates the
|
||||||
* in this codebase: PermissionManager.requirePermission exists but is currently
|
* HTTP API, but tools run in-process with the calling user's identity and never
|
||||||
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
|
* pass through a router, so "the assistant cannot reach credentials or user
|
||||||
* editor rather than route enforcement. "The assistant cannot reach credentials
|
* administration" is a property of this list, not of the permission system.
|
||||||
* or user administration" is therefore a property of this list, not of the
|
|
||||||
* permission system.
|
|
||||||
*
|
*
|
||||||
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
||||||
* SSO or instance settings is deliberately absent and must stay absent.
|
* SSO or instance settings is deliberately absent and must stay absent.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "../../utils/data-dir-guard.js";
|
} from "../../utils/data-dir-guard.js";
|
||||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||||
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
||||||
|
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
||||||
import { connectRemoteDatabase } from "./connect.js";
|
import { connectRemoteDatabase } from "./connect.js";
|
||||||
import { runRemoteMigrations } from "./migrate.js";
|
import { runRemoteMigrations } from "./migrate.js";
|
||||||
import type { PortableDatabase } from "../repositories/database-context.js";
|
import type { PortableDatabase } from "../repositories/database-context.js";
|
||||||
@@ -1953,20 +1954,24 @@ const migrateSchema = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemRoles = [
|
const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map(
|
||||||
{
|
([name, defaults]) => ({
|
||||||
name: "admin",
|
name,
|
||||||
displayName: "rbac.roles.admin",
|
displayName: `rbac.roles.${name}`,
|
||||||
description: "Administrator with full access",
|
description: defaults.description,
|
||||||
permissions: null,
|
permissions: JSON.stringify(defaults.permissions),
|
||||||
},
|
}),
|
||||||
{
|
);
|
||||||
name: "user",
|
|
||||||
displayName: "rbac.roles.user",
|
// Route-level RBAC needs the permission lists to exist; roles seeded by
|
||||||
description: "Regular user",
|
// earlier versions carried NULL there. Backfill only NULL so an admin's
|
||||||
permissions: null,
|
// edits to these roles are never overwritten.
|
||||||
},
|
const backfillPermissions = sqlite.prepare(
|
||||||
];
|
"UPDATE roles SET permissions = ? WHERE name = ? AND is_system = 1 AND permissions IS NULL",
|
||||||
|
);
|
||||||
|
for (const role of systemRoles) {
|
||||||
|
backfillPermissions.run(role.permissions, role.name);
|
||||||
|
}
|
||||||
|
|
||||||
for (const role of systemRoles) {
|
for (const role of systemRoles) {
|
||||||
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
||||||
@@ -2986,6 +2991,12 @@ async function initializeRemoteDatabase(
|
|||||||
await primeCurrentSettingsCache();
|
await primeCurrentSettingsCache();
|
||||||
startSettingsCacheRefresh();
|
startSettingsCacheRefresh();
|
||||||
|
|
||||||
|
// The SQLite bootstrap seeds system roles inline below; migrations for the
|
||||||
|
// remote dialects never did, and route-level RBAC denies a user with no
|
||||||
|
// usable role, so they are seeded (and backfilled) here.
|
||||||
|
const { ensureSystemRoles } = await import("../../utils/system-roles.js");
|
||||||
|
await ensureSystemRoles();
|
||||||
|
|
||||||
databaseLogger.info(`${dialect} database ready`, {
|
databaseLogger.info(`${dialect} database ready`, {
|
||||||
operation: "db_init_complete",
|
operation: "db_init_complete",
|
||||||
dialect,
|
dialect,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { hostAccess, roles, userRoles } from "../db/schema.js";
|
import { hostAccess, roles, userRoles, users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { rowsAffected } from "./mutation-result.js";
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
import { deleteReturning, insertReturning } from "./returning.js";
|
import { deleteReturning, insertReturning } from "./returning.js";
|
||||||
@@ -8,7 +8,7 @@ export type RoleRecord = typeof roles.$inferSelect;
|
|||||||
export type NewRoleRecord = typeof roles.$inferInsert;
|
export type NewRoleRecord = typeof roles.$inferInsert;
|
||||||
export type RoleUpdate = Pick<
|
export type RoleUpdate = Pick<
|
||||||
Partial<NewRoleRecord>,
|
Partial<NewRoleRecord>,
|
||||||
"displayName" | "description" | "updatedAt"
|
"displayName" | "description" | "permissions" | "updatedAt"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type UserRoleWithRole = {
|
export type UserRoleWithRole = {
|
||||||
@@ -21,6 +21,13 @@ export type UserRoleWithRole = {
|
|||||||
grantedAt: string;
|
grantedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RoleMemberRecord = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
grantedAt: string;
|
||||||
|
grantedBy: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserRolePermissionRecord = {
|
export type UserRolePermissionRecord = {
|
||||||
permissions: string | null;
|
permissions: string | null;
|
||||||
};
|
};
|
||||||
@@ -226,6 +233,20 @@ export class RoleRepository {
|
|||||||
return rows.map((row) => row.userId);
|
return rows.map((row) => row.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRoleMembers(roleId: number): Promise<RoleMemberRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select({
|
||||||
|
userId: userRoles.userId,
|
||||||
|
username: users.username,
|
||||||
|
grantedAt: userRoles.grantedAt,
|
||||||
|
grantedBy: userRoles.grantedBy,
|
||||||
|
})
|
||||||
|
.from(userRoles)
|
||||||
|
.innerJoin(users, eq(userRoles.userId, users.id))
|
||||||
|
.where(eq(userRoles.roleId, roleId))
|
||||||
|
.orderBy(users.username);
|
||||||
|
}
|
||||||
|
|
||||||
async listUserRolePermissions(
|
async listUserRolePermissions(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<UserRolePermissionRecord[]> {
|
): Promise<UserRolePermissionRecord[]> {
|
||||||
|
|||||||
@@ -33,6 +33,33 @@ export class SharedHostAuthOverrideRepository {
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recipient overrides for a host keyed by protocol, in one query. */
|
||||||
|
async listCredentialIds(
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Partial<Record<AuthOverrideProtocol, number>>> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
protocol: sharedHostAuthOverrides.protocol,
|
||||||
|
credentialId: sharedHostAuthOverrides.credentialId,
|
||||||
|
})
|
||||||
|
.from(sharedHostAuthOverrides)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedHostAuthOverrides.hostId, hostId),
|
||||||
|
eq(sharedHostAuthOverrides.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Partial<Record<AuthOverrideProtocol, number>> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.credentialId) {
|
||||||
|
result[row.protocol as AuthOverrideProtocol] = row.credentialId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async findCredentialId(
|
async findCredentialId(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, eq, sql } from "drizzle-orm";
|
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { snippetFolders, snippets } from "../db/schema.js";
|
import { snippetFolders, snippets } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
@@ -108,6 +108,25 @@ export class SnippetRepository {
|
|||||||
return this.listFolders(userId);
|
return this.listFolders(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
|
||||||
|
async listOwnedSnippetsInFolder(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
): Promise<SnippetRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(snippets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippets.userId, userId),
|
||||||
|
or(
|
||||||
|
eq(snippets.folder, folder),
|
||||||
|
like(snippets.folder, `${folder} / %`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
||||||
return this.context.drizzle
|
return this.context.drizzle
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
Trigger,
|
Trigger,
|
||||||
} from "../../../types/automations.js";
|
} from "../../../types/automations.js";
|
||||||
import { AUTOMATION_DEFINITION_VERSION } 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 { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
@@ -244,6 +246,7 @@ async function syncSchedule(
|
|||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -282,6 +285,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -333,6 +337,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -423,6 +428,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -531,6 +537,7 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -600,6 +607,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/run",
|
"/:id/run",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.run"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -673,6 +681,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/runs/history",
|
"/runs/history",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -715,6 +724,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/runs/:runId/steps",
|
"/runs/:runId/steps",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { createCurrentCredentialRepository } from "../repositories/factory.js";
|
|||||||
export function registerCredentialBulkRoutes(
|
export function registerCredentialBulkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireEditPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -42,6 +44,8 @@ export function registerCredentialBulkRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { positions } = req.body as {
|
const { positions } = req.body as {
|
||||||
|
|||||||
@@ -359,6 +359,9 @@ async function deploySSHKeyToHost(
|
|||||||
export function registerCredentialDeployRoutes(
|
export function registerCredentialDeployRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireCredentialViewPermission: RequestHandler,
|
||||||
|
requireHostEditPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -398,6 +401,9 @@ export function registerCredentialDeployRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/deploy-to-host",
|
"/:id/deploy-to-host",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireHostEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id)
|
const id = Array.isArray(req.params.id)
|
||||||
? req.params.id[0]
|
? req.params.id[0]
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ function generateSSHKeyPair(
|
|||||||
export function registerCredentialKeyRoutes(
|
export function registerCredentialKeyRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireCredentialViewPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -94,6 +96,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/detect-key-type",
|
"/detect-key-type",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, keyPassword } = req.body;
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
@@ -150,6 +154,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/detect-public-key-type",
|
"/detect-public-key-type",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { publicKey } = req.body;
|
const { publicKey } = req.body;
|
||||||
|
|
||||||
@@ -209,6 +215,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/validate-key-pair",
|
"/validate-key-pair",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, publicKey, keyPassword } = req.body;
|
const { privateKey, publicKey, keyPassword } = req.body;
|
||||||
|
|
||||||
@@ -274,6 +282,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/generate-key-pair",
|
"/generate-key-pair",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
||||||
|
|
||||||
@@ -339,6 +349,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/generate-public-key",
|
"/generate-public-key",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, keyPassword } = req.body;
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||||
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
||||||
@@ -26,6 +27,7 @@ function isNonEmptyString(val: unknown): val is string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -250,6 +253,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -290,6 +294,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -311,7 +316,12 @@ router.get(
|
|||||||
// Registered here (before the PUT /:id route below) so the literal
|
// Registered here (before the PUT /:id route below) so the literal
|
||||||
// "/reorder" path segment is matched before Express falls through to the
|
// "/reorder" path segment is matched before Express falls through to the
|
||||||
// PUT /:id param route and treats "reorder" as an id.
|
// PUT /:id param route and treats "reorder" as an id.
|
||||||
registerCredentialBulkRoutes(router, authenticateJWT);
|
registerCredentialBulkRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -340,6 +350,7 @@ registerCredentialBulkRoutes(router, authenticateJWT);
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -432,6 +443,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/duplicate",
|
"/:id/duplicate",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -585,6 +597,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -747,6 +760,7 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -878,6 +892,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/apply-to-host/:hostId",
|
"/:id/apply-to-host/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const credentialId = Array.isArray(req.params.id)
|
const credentialId = Array.isArray(req.params.id)
|
||||||
@@ -958,6 +973,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id/hosts",
|
"/:id/hosts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const credentialId = Array.isArray(req.params.id)
|
const credentialId = Array.isArray(req.params.id)
|
||||||
@@ -1076,6 +1092,7 @@ function formatSSHHostOutput(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { oldName, newName } = req.body;
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ import { createCurrentHostRepository } from "../repositories/factory.js";
|
|||||||
|
|
||||||
type HostAutostartRoutesDeps = {
|
type HostAutostartRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
|
requireViewPermission: RequestHandler;
|
||||||
|
requireEditPermission: RequestHandler;
|
||||||
requireDataAccess: RequestHandler;
|
requireDataAccess: RequestHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerHostAutostartRoutes(
|
export function registerHostAutostartRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
{ authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps,
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
}: HostAutostartRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -44,6 +51,7 @@ export function registerHostAutostartRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/autostart/enable",
|
"/autostart/enable",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -204,6 +212,8 @@ export function registerHostAutostartRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/autostart/disable",
|
"/autostart/disable",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { sshConfigId } = req.body;
|
const { sshConfigId } = req.body;
|
||||||
@@ -259,6 +269,8 @@ export function registerHostAutostartRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/autostart/status",
|
"/autostart/status",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export function importedHostUsername(
|
|||||||
export function registerHostBulkRoutes(
|
export function registerHostBulkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireCreatePermission: RequestHandler,
|
||||||
|
requireEditPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -182,6 +185,8 @@ export function registerHostBulkRoutes(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/bulk-update",
|
"/bulk-update",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostIds, updates } = req.body;
|
const { hostIds, updates } = req.body;
|
||||||
@@ -386,6 +391,8 @@ export function registerHostBulkRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { positions } = req.body as {
|
const { positions } = req.body as {
|
||||||
@@ -432,6 +439,9 @@ export function registerHostBulkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/bulk-import",
|
"/bulk-import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCreatePermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const {
|
const {
|
||||||
@@ -844,6 +854,9 @@ export function registerHostBulkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/ssh-config-import",
|
"/ssh-config-import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCreatePermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { content, overwrite } = req.body;
|
const { content, overwrite } = req.body;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js";
|
|||||||
export function registerHostCommandHistoryRoutes(
|
export function registerHostCommandHistoryRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireViewPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -33,6 +35,8 @@ export function registerHostCommandHistoryRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/command-history/:hostId",
|
"/command-history/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdParam = Array.isArray(req.params.hostId)
|
const hostIdParam = Array.isArray(req.params.hostId)
|
||||||
@@ -98,6 +102,8 @@ export function registerHostCommandHistoryRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/command-history",
|
"/command-history",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, command } = req.body;
|
const { hostId, command } = req.body;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js";
|
|||||||
export function registerHostFileManagerBookmarkRoutes(
|
export function registerHostFileManagerBookmarkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireViewPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -33,6 +35,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/file_manager/recent",
|
"/file_manager/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||||
@@ -98,6 +102,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/file_manager/recent",
|
"/file_manager/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path, name } = req.body;
|
const { hostId, path, name } = req.body;
|
||||||
@@ -155,6 +161,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/file_manager/recent",
|
"/file_manager/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path } = req.body;
|
const { hostId, path } = req.body;
|
||||||
@@ -203,6 +211,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/file_manager/pinned",
|
"/file_manager/pinned",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||||
@@ -269,6 +279,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/file_manager/pinned",
|
"/file_manager/pinned",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path, name } = req.body;
|
const { hostId, path, name } = req.body;
|
||||||
@@ -327,6 +339,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/file_manager/pinned",
|
"/file_manager/pinned",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path } = req.body;
|
const { hostId, path } = req.body;
|
||||||
@@ -375,6 +389,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/file_manager/shortcuts",
|
"/file_manager/shortcuts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||||
@@ -441,6 +457,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/file_manager/shortcuts",
|
"/file_manager/shortcuts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path, name } = req.body;
|
const { hostId, path, name } = req.body;
|
||||||
@@ -499,6 +517,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/file_manager/shortcuts",
|
"/file_manager/shortcuts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path } = req.body;
|
const { hostId, path } = req.body;
|
||||||
|
|||||||
@@ -17,12 +17,25 @@ import { isNonEmptyString } from "./host-normalizers.js";
|
|||||||
|
|
||||||
type HostFolderRoutesDeps = {
|
type HostFolderRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
|
requireViewPermission: RequestHandler;
|
||||||
|
requireEditPermission: RequestHandler;
|
||||||
|
requireDeletePermission: RequestHandler;
|
||||||
|
requireCredentialEditPermission: RequestHandler;
|
||||||
|
requireDataAccess: RequestHandler;
|
||||||
statsServerUrl: string;
|
statsServerUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerHostFolderRoutes(
|
export function registerHostFolderRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
{ authenticateJWT, statsServerUrl }: HostFolderRoutesDeps,
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDeletePermission,
|
||||||
|
requireCredentialEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
statsServerUrl,
|
||||||
|
}: HostFolderRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -54,6 +67,9 @@ export function registerHostFolderRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireCredentialEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { oldName, newName } = req.body;
|
const { oldName, newName } = req.body;
|
||||||
@@ -113,6 +129,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
@@ -170,6 +188,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/metadata",
|
"/folders/metadata",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { name, color, icon, credentialId } = req.body;
|
const { name, color, icon, credentialId } = req.body;
|
||||||
@@ -275,6 +295,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/reorder",
|
"/folders/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { positions } = req.body as {
|
const { positions } = req.body as {
|
||||||
@@ -346,6 +368,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/folders/:name/hosts",
|
"/folders/:name/hosts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireDeletePermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const folderName = Array.isArray(req.params.name)
|
const folderName = Array.isArray(req.params.name)
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
|
|||||||
|
|
||||||
interface HostNetworkRoutesDeps {
|
interface HostNetworkRoutesDeps {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
|
requireViewPermission: RequestHandler;
|
||||||
requireDataAccess: RequestHandler;
|
requireDataAccess: RequestHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerHostNetworkRoutes(
|
export function registerHostNetworkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
{ authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps,
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
}: HostNetworkRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -62,6 +67,7 @@ export function registerHostNetworkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/db/proxy/test",
|
"/db/proxy/test",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +99,7 @@ export function registerHostNetworkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/db/host/:id/wake",
|
"/db/host/:id/wake",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = Number.parseInt(String(req.params.id), 10);
|
const hostId = Number.parseInt(String(req.params.id), 10);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
createCurrentSyncTombstoneRepository,
|
createCurrentSyncTombstoneRepository,
|
||||||
|
createCurrentSharedHostAuthOverrideRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
containsOwnerPrivateAuthUpdate,
|
containsOwnerPrivateAuthUpdate,
|
||||||
@@ -62,6 +63,7 @@ import type {
|
|||||||
HostResolutionCredentialRecord,
|
HostResolutionCredentialRecord,
|
||||||
HostResolutionHostRecord,
|
HostResolutionHostRecord,
|
||||||
} from "../repositories/host-resolution-repository.js";
|
} from "../repositories/host-resolution-repository.js";
|
||||||
|
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
|
||||||
import {
|
import {
|
||||||
requiresPersonalHostAuthentication,
|
requiresPersonalHostAuthentication,
|
||||||
resolveRecipientSharedHostAuthentication,
|
resolveRecipientSharedHostAuthentication,
|
||||||
@@ -125,6 +127,7 @@ registerHostInternalRoutes(router);
|
|||||||
router.post(
|
router.post(
|
||||||
["/db/host", "/enroll"],
|
["/db/host", "/enroll"],
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
requireHostEnrollmentAccessForPath,
|
requireHostEnrollmentAccessForPath,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
@@ -817,6 +820,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
@@ -1458,6 +1462,7 @@ router.put(
|
|||||||
router.get(
|
router.get(
|
||||||
"/db/host",
|
"/db/host",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1601,6 +1606,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = Array.isArray(req.params.id)
|
const hostId = Array.isArray(req.params.id)
|
||||||
@@ -2223,6 +2229,7 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
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(
|
router.get(
|
||||||
"/transfer/recent",
|
"/transfer/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
|
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
|
||||||
@@ -2395,6 +2409,8 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/transfer/recent",
|
"/transfer/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
|
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(
|
async function resolveHostCredentials(
|
||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
@@ -2455,6 +2476,24 @@ async function resolveHostCredentials(
|
|||||||
required: needsPersonalCredential,
|
required: needsPersonalCredential,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
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> = {
|
const recipientHost: Record<string, unknown> = {
|
||||||
...host,
|
...host,
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
@@ -2462,9 +2501,7 @@ async function resolveHostCredentials(
|
|||||||
key: null,
|
key: null,
|
||||||
keyPassword: null,
|
keyPassword: null,
|
||||||
keyType: null,
|
keyType: null,
|
||||||
authOverrides: {
|
authOverrides,
|
||||||
ssh: baseSshOverrideState,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2480,6 +2517,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
credentialId: resolution.credentialId,
|
credentialId: resolution.credentialId,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -2505,6 +2543,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: true,
|
ownerAuthShared: true,
|
||||||
@@ -2524,6 +2563,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: true,
|
ownerAuthShared: true,
|
||||||
@@ -2547,6 +2587,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
@@ -2605,13 +2646,27 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
registerHostFolderRoutes(router, {
|
registerHostFolderRoutes(router, {
|
||||||
authenticateJWT,
|
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,
|
statsServerUrl: STATS_SERVER_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerHostBulkRoutes(router, authenticateJWT);
|
registerHostBulkRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.create"),
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
registerHostAutostartRoutes(router, {
|
registerHostAutostartRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2656,6 +2711,8 @@ registerHostAutostartRoutes(router, {
|
|||||||
router.get(
|
router.get(
|
||||||
"/ssh/opkssh/token/:hostId",
|
"/ssh/opkssh/token/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
@@ -2727,6 +2784,8 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/ssh/opkssh/token/:hostId",
|
"/ssh/opkssh/token/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
@@ -2759,6 +2818,7 @@ registerHostOpksshRoutes(router);
|
|||||||
|
|
||||||
registerHostNetworkRoutes(router, {
|
registerHostNetworkRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export function parseShareTargets(
|
|||||||
router.post(
|
router.post(
|
||||||
"/host/:id/share",
|
"/host/:id/share",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.share"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const hostId = parseInt(id, 10);
|
const hostId = parseInt(id, 10);
|
||||||
@@ -365,6 +366,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/folder/share",
|
"/folder/share",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.share"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userId = req.userId!;
|
const userId = req.userId!;
|
||||||
const { folder } = req.body ?? {};
|
const { folder } = req.body ?? {};
|
||||||
@@ -939,7 +941,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/roles",
|
"/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requireAdmin(),
|
permissionManager.requirePermission("admin.roles.manage"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const { name, displayName, description } = req.body;
|
const { name, displayName, description } = req.body;
|
||||||
|
|
||||||
@@ -1031,7 +1033,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/roles/:id",
|
"/roles/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requireAdmin(),
|
permissionManager.requirePermission("admin.roles.manage"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const roleId = parseInt(id, 10);
|
const roleId = parseInt(id, 10);
|
||||||
@@ -1174,7 +1176,7 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/roles/:id",
|
"/roles/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requireAdmin(),
|
permissionManager.requirePermission("admin.roles.manage"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const roleId = parseInt(id, 10);
|
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
|
* @openapi
|
||||||
* /rbac/users/{userId}/roles:
|
* /rbac/users/{userId}/roles:
|
||||||
@@ -1257,7 +1306,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/users/:userId/roles",
|
"/users/:userId/roles",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requireAdmin(),
|
permissionManager.requirePermission("admin.roles.manage"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const targetUserId = Array.isArray(req.params.userId)
|
const targetUserId = Array.isArray(req.params.userId)
|
||||||
? req.params.userId[0]
|
? req.params.userId[0]
|
||||||
@@ -1382,7 +1431,7 @@ router.post(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/users/:userId/roles/:roleId",
|
"/users/:userId/roles/:roleId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requireAdmin(),
|
permissionManager.requirePermission("admin.roles.manage"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const targetUserId = Array.isArray(req.params.userId)
|
const targetUserId = Array.isArray(req.params.userId)
|
||||||
? req.params.userId[0]
|
? req.params.userId[0]
|
||||||
@@ -1524,6 +1573,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/snippet/:id/share",
|
"/snippet/:id/share",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.share"),
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const snippetId = parseInt(id, 10);
|
const snippetId = parseInt(id, 10);
|
||||||
@@ -1639,6 +1689,131 @@ router.post(
|
|||||||
* tags:
|
* tags:
|
||||||
* - RBAC
|
* - 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(
|
router.delete(
|
||||||
"/snippet/:id/access/:accessId",
|
"/snippet/:id/access/:accessId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||||
@@ -68,6 +69,7 @@ async function getAccessibleSnippet(snippetId: number, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
@@ -90,6 +92,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
|
|||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -144,6 +147,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -226,6 +230,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/:name/metadata",
|
"/folders/:name/metadata",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -306,6 +311,7 @@ router.put(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -382,6 +388,7 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/folders/:name",
|
"/folders/:name",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -466,6 +473,7 @@ router.delete(
|
|||||||
router.put(
|
router.put(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -796,6 +804,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/export",
|
"/export",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -872,6 +881,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/bulk-import",
|
"/bulk-import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -932,6 +942,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1003,6 +1014,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1073,6 +1085,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1183,6 +1196,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1261,6 +1275,7 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("snippets.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
setGlobalLogLevel,
|
setGlobalLogLevel,
|
||||||
} from "../../utils/logger.js";
|
} from "../../utils/logger.js";
|
||||||
import { logAudit, getRequestMeta } from "../../utils/audit-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 { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
||||||
import {
|
import {
|
||||||
@@ -669,6 +674,125 @@ export function registerUserSettingsRoutes(
|
|||||||
* enabled:
|
* enabled:
|
||||||
* type: boolean
|
* 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) => {
|
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { withRecordingSettings } from "./recording-settings.js";
|
|||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
import { resolveRecipientSharedHostAuthentication } from "../../utils/shared-host-auth-resolver.js";
|
||||||
|
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -332,30 +334,34 @@ router.post(
|
|||||||
host.telnetPassword = null;
|
host.telnetPassword = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { SharedHostSecretsManager } =
|
const resolution = await resolveRecipientSharedHostAuthentication(
|
||||||
await import("../../utils/shared-host-secrets-manager.js");
|
host,
|
||||||
const secret =
|
hostId,
|
||||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
userId,
|
||||||
hostId,
|
connectionType as AuthOverrideProtocol,
|
||||||
userId,
|
);
|
||||||
connectionType as "rdp" | "vnc" | "telnet",
|
const auth =
|
||||||
);
|
resolution.source === "personal-override"
|
||||||
if (secret) {
|
? { ...resolution.credential, domain: null }
|
||||||
|
: resolution.source === "owner-shared"
|
||||||
|
? resolution.secret
|
||||||
|
: null;
|
||||||
|
if (auth) {
|
||||||
if (connectionType === "rdp") {
|
if (connectionType === "rdp") {
|
||||||
host.rdpUser = secret.username ?? null;
|
host.rdpUser = auth.username ?? null;
|
||||||
host.rdpPassword = secret.password ?? null;
|
host.rdpPassword = auth.password ?? null;
|
||||||
if (secret.domain) host.rdpDomain = secret.domain;
|
if (auth.domain) host.rdpDomain = auth.domain;
|
||||||
} else if (connectionType === "vnc") {
|
} else if (connectionType === "vnc") {
|
||||||
host.vncUser = secret.username ?? null;
|
host.vncUser = auth.username ?? null;
|
||||||
host.vncPassword = secret.password ?? null;
|
host.vncPassword = auth.password ?? null;
|
||||||
} else if (connectionType === "telnet") {
|
} else if (connectionType === "telnet") {
|
||||||
host.telnetUser = secret.username ?? null;
|
host.telnetUser = auth.username ?? null;
|
||||||
host.telnetPassword = secret.password ?? null;
|
host.telnetPassword = auth.password ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
guacLogger.warn("Failed to resolve shared host secret", {
|
guacLogger.warn("Failed to resolve shared host auth", {
|
||||||
operation: "guac_shared_secret_resolve",
|
operation: "guac_shared_auth_resolve",
|
||||||
hostId,
|
hostId,
|
||||||
protocol: connectionType,
|
protocol: connectionType,
|
||||||
error: getErrorMessage(e, "Unknown"),
|
error: getErrorMessage(e, "Unknown"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { getAuditUsername } from "../../utils/audit-logger.js";
|
||||||
import {
|
import {
|
||||||
parseWsMessage,
|
parseWsMessage,
|
||||||
asObject,
|
asObject,
|
||||||
@@ -1274,6 +1275,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
userId,
|
userId,
|
||||||
permissionLevel: share.permissionLevel as
|
permissionLevel: share.permissionLevel as
|
||||||
"read-write" | "read-only",
|
"read-write" | "read-only",
|
||||||
|
displayName: await getAuditUsername(userId),
|
||||||
tabInstanceId: joinData.tabInstanceId,
|
tabInstanceId: joinData.tabInstanceId,
|
||||||
shareId: share.id,
|
shareId: share.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface SessionParticipant {
|
|||||||
userId: string | null; // null for anonymous link guests
|
userId: string | null; // null for anonymous link guests
|
||||||
permissionLevel: "read-write" | "read-only";
|
permissionLevel: "read-write" | "read-only";
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
displayName?: string;
|
||||||
guestLabel?: string;
|
guestLabel?: string;
|
||||||
tabInstanceId?: string;
|
tabInstanceId?: string;
|
||||||
joinedViaShareId?: string;
|
joinedViaShareId?: string;
|
||||||
@@ -382,6 +383,7 @@ class TerminalSessionManager {
|
|||||||
});
|
});
|
||||||
session.attachedTabInstanceId = tabInstanceId;
|
session.attachedTabInstanceId = tabInstanceId;
|
||||||
session.lastDetachedAt = null;
|
session.lastDetachedAt = null;
|
||||||
|
this.broadcastParticipants(sessionId);
|
||||||
|
|
||||||
sshLogger.info("WebSocket attached to session", {
|
sshLogger.info("WebSocket attached to session", {
|
||||||
operation: "session_attach",
|
operation: "session_attach",
|
||||||
@@ -403,6 +405,7 @@ class TerminalSessionManager {
|
|||||||
opts: {
|
opts: {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
permissionLevel: "read-write" | "read-only";
|
permissionLevel: "read-write" | "read-only";
|
||||||
|
displayName?: string;
|
||||||
guestLabel?: string;
|
guestLabel?: string;
|
||||||
tabInstanceId?: string;
|
tabInstanceId?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
@@ -417,10 +420,12 @@ class TerminalSessionManager {
|
|||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
permissionLevel: opts.permissionLevel,
|
permissionLevel: opts.permissionLevel,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
displayName: opts.displayName,
|
||||||
guestLabel: opts.guestLabel,
|
guestLabel: opts.guestLabel,
|
||||||
tabInstanceId: opts.tabInstanceId,
|
tabInstanceId: opts.tabInstanceId,
|
||||||
joinedViaShareId: opts.shareId,
|
joinedViaShareId: opts.shareId,
|
||||||
});
|
});
|
||||||
|
this.broadcastParticipants(sessionId);
|
||||||
|
|
||||||
sshLogger.info("Participant joined shared session", {
|
sshLogger.info("Participant joined shared session", {
|
||||||
operation: "session_join_participant",
|
operation: "session_join_participant",
|
||||||
@@ -433,6 +438,25 @@ class TerminalSessionManager {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells everyone in a shared session who is present. Sent on join and
|
||||||
|
* leave, and only while someone besides the owner is (or just was) in the
|
||||||
|
* room - a solo owner never receives presence traffic.
|
||||||
|
*/
|
||||||
|
private broadcastParticipants(sessionId: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const participants = Array.from(session.participants.values()).map(
|
||||||
|
(participant) => ({
|
||||||
|
isOwner: participant.isOwner,
|
||||||
|
permissionLevel: participant.permissionLevel,
|
||||||
|
label: participant.displayName ?? participant.guestLabel ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (participants.every((participant) => participant.isOwner)) return;
|
||||||
|
this.broadcast(sessionId, { type: "participants", participants });
|
||||||
|
}
|
||||||
|
|
||||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||||
broadcast(sessionId: string, message: object): void {
|
broadcast(sessionId: string, message: object): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
@@ -469,6 +493,7 @@ class TerminalSessionManager {
|
|||||||
for (const [id, participant] of session.participants.entries()) {
|
for (const [id, participant] of session.participants.entries()) {
|
||||||
if (participant.ws === ws && !participant.isOwner) {
|
if (participant.ws === ws && !participant.isOwner) {
|
||||||
session.participants.delete(id);
|
session.participants.delete(id);
|
||||||
|
this.broadcastParticipants(sessionId);
|
||||||
sshLogger.info("Participant left shared session", {
|
sshLogger.info("Participant left shared session", {
|
||||||
operation: "session_leave_participant",
|
operation: "session_leave_participant",
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
/**
|
/**
|
||||||
* The security regression test for the whole feature.
|
* The security regression test for the whole feature.
|
||||||
*
|
*
|
||||||
* PermissionManager.requirePermission is defined but mounted on zero routes,
|
* Tools run in-process and never pass through the RBAC-gated routers, so "the
|
||||||
* so RBAC strings do not gate anything at the route layer. "The assistant
|
* assistant cannot reach credentials or user administration" is a property of
|
||||||
* cannot reach credentials or user administration" is therefore a property of
|
|
||||||
* this catalog, and nothing else. If a future change adds a tool that touches a
|
* this catalog, and nothing else. If a future change adds a tool that touches a
|
||||||
* forbidden domain, this test is what catches it.
|
* forbidden domain, this test is what catches it.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ vi.mock("../../../automations/engine.js", () => ({
|
|||||||
AutomationEngine: { getInstance: () => ({ run }) },
|
AutomationEngine: { getInstance: () => ({ run }) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
requirePermission:
|
||||||
|
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||||
|
next(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
AuthManager: {
|
AuthManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import express, {
|
||||||
|
type RequestHandler,
|
||||||
|
type Response,
|
||||||
|
type Router,
|
||||||
|
} from "express";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { registerCredentialBulkRoutes } from "../../../database/routes/credential-bulk-routes.js";
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentCredentialRepository: () => ({
|
||||||
|
reorderForUser: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
authLogger: { error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
function reorderHandlers(router: Router): RequestHandler[] {
|
||||||
|
const layer = router.stack.find(
|
||||||
|
(entry) => entry.route?.path === "/reorder" && entry.route.methods.put,
|
||||||
|
);
|
||||||
|
if (!layer?.route) throw new Error("PUT /reorder was not registered");
|
||||||
|
return layer.route.stack.map((entry) => entry.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("credential reorder route", () => {
|
||||||
|
it("stops before data access when credentials.edit is denied", async () => {
|
||||||
|
const router = express.Router();
|
||||||
|
const authenticate: RequestHandler = (req, _res, next) => {
|
||||||
|
Object.assign(req, { userId: "user-1" });
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
const requireEdit: RequestHandler = (_req, res) => {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "Insufficient permissions",
|
||||||
|
required: "credentials.edit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const requireDataAccess = vi.fn((_req, _res, next) => next());
|
||||||
|
|
||||||
|
registerCredentialBulkRoutes(
|
||||||
|
router,
|
||||||
|
authenticate,
|
||||||
|
requireEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = {} as Response;
|
||||||
|
const status = vi.fn(() => response);
|
||||||
|
const json = vi.fn(() => response);
|
||||||
|
Object.assign(response, { status, json });
|
||||||
|
const request = { body: { positions: [{ id: 1, sortOrder: 0 }] } };
|
||||||
|
|
||||||
|
for (const handler of reorderHandlers(router)) {
|
||||||
|
let continued = false;
|
||||||
|
await handler(request as never, response, () => {
|
||||||
|
continued = true;
|
||||||
|
});
|
||||||
|
if (!continued) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
expect(json).toHaveBeenCalledWith({
|
||||||
|
error: "Insufficient permissions",
|
||||||
|
required: "credentials.edit",
|
||||||
|
});
|
||||||
|
expect(requireDataAccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,14 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
|||||||
PermissionManager: {
|
PermissionManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
canAccessHost: async () => state.access,
|
canAccessHost: async () => state.access,
|
||||||
|
requirePermission:
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
_req: express.Request,
|
||||||
|
_res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) =>
|
||||||
|
next(),
|
||||||
requireAdmin:
|
requireAdmin:
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
@@ -255,14 +263,10 @@ describe("shared host authentication override routes", () => {
|
|||||||
expect(unauthenticatedResponse.status).toBe(401);
|
expect(unauthenticatedResponse.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects recognized but unsupported protocols and invalid protocol names", async () => {
|
it("serves every real protocol and rejects invalid protocol names", async () => {
|
||||||
const unsupportedResponse = await invoke("get", {}, "rdp");
|
const rdpResponse = await invoke("get", {}, "rdp");
|
||||||
expect(unsupportedResponse).toEqual({
|
expect(rdpResponse.status).toBe(200);
|
||||||
status: 400,
|
expect(rdpResponse.body.protocol).toBe("rdp");
|
||||||
body: {
|
|
||||||
error: "RDP authentication overrides are not supported yet",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(state.writes).toEqual([]);
|
expect(state.writes).toEqual([]);
|
||||||
|
|
||||||
const invalidResponse = await invoke("get", {}, "smtp");
|
const invalidResponse = await invoke("get", {}, "smtp");
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import express, { type RequestHandler, type Router } from "express";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
|
||||||
|
import { registerHostFolderRoutes } from "../../../database/routes/host-folder-routes.js";
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: { info: vi.fn(), error: vi.fn() },
|
||||||
|
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const middleware = (): RequestHandler => (_req, _res, next) => next();
|
||||||
|
|
||||||
|
function handlers(router: Router, method: string, path: string) {
|
||||||
|
const layer = router.stack.find(
|
||||||
|
(entry) => entry.route?.path === path && entry.route.methods[method],
|
||||||
|
);
|
||||||
|
if (!layer?.route) throw new Error(`${method.toUpperCase()} ${path} missing`);
|
||||||
|
return layer.route.stack.map((entry) => entry.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RBAC coverage for split host routers", () => {
|
||||||
|
it("gates bulk mutations by action and data-access permissions", () => {
|
||||||
|
const router = express.Router();
|
||||||
|
const authenticate = middleware();
|
||||||
|
const requireCreate = middleware();
|
||||||
|
const requireEdit = middleware();
|
||||||
|
const requireDataAccess = middleware();
|
||||||
|
|
||||||
|
registerHostBulkRoutes(
|
||||||
|
router,
|
||||||
|
authenticate,
|
||||||
|
requireCreate,
|
||||||
|
requireEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handlers(router, "patch", "/bulk-update").slice(0, 3)).toEqual([
|
||||||
|
authenticate,
|
||||||
|
requireEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
]);
|
||||||
|
expect(handlers(router, "put", "/reorder").slice(0, 3)).toEqual([
|
||||||
|
authenticate,
|
||||||
|
requireEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
]);
|
||||||
|
for (const path of ["/bulk-import", "/ssh-config-import"]) {
|
||||||
|
expect(handlers(router, "post", path).slice(0, 4)).toEqual([
|
||||||
|
authenticate,
|
||||||
|
requireCreate,
|
||||||
|
requireEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates folder reads and mutations with their matching permissions", () => {
|
||||||
|
const router = express.Router();
|
||||||
|
const authenticate = middleware();
|
||||||
|
const requireView = middleware();
|
||||||
|
const requireEdit = middleware();
|
||||||
|
const requireDelete = middleware();
|
||||||
|
const requireCredentialEdit = middleware();
|
||||||
|
const requireDataAccess = middleware();
|
||||||
|
|
||||||
|
registerHostFolderRoutes(router, {
|
||||||
|
authenticateJWT: authenticate,
|
||||||
|
requireViewPermission: requireView,
|
||||||
|
requireEditPermission: requireEdit,
|
||||||
|
requireDeletePermission: requireDelete,
|
||||||
|
requireCredentialEditPermission: requireCredentialEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
statsServerUrl: "http://stats.invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handlers(router, "get", "/folders").slice(0, 3)).toEqual([
|
||||||
|
authenticate,
|
||||||
|
requireView,
|
||||||
|
requireDataAccess,
|
||||||
|
]);
|
||||||
|
expect(handlers(router, "put", "/folders/rename").slice(0, 4)).toEqual([
|
||||||
|
authenticate,
|
||||||
|
requireEdit,
|
||||||
|
requireCredentialEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
]);
|
||||||
|
for (const path of ["/folders/metadata", "/folders/reorder"]) {
|
||||||
|
expect(handlers(router, "put", path).slice(0, 3)).toEqual([
|
||||||
|
authenticate,
|
||||||
|
requireEdit,
|
||||||
|
requireDataAccess,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
handlers(router, "delete", "/folders/:name/hosts").slice(0, 3),
|
||||||
|
).toEqual([authenticate, requireDelete, requireDataAccess]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -206,7 +206,14 @@ describe("TerminalSessionManager - multiplayer participants", () => {
|
|||||||
ownerWs,
|
ownerWs,
|
||||||
);
|
);
|
||||||
expect(ownerParticipant?.isOwner).toBe(true);
|
expect(ownerParticipant?.isOwner).toBe(true);
|
||||||
expect(ownerWs.send).not.toHaveBeenCalled();
|
// The join is announced to everyone already in the session - and that is
|
||||||
|
// the only unsolicited message the owner receives.
|
||||||
|
expect(ownerWs.send).toHaveBeenCalledTimes(1);
|
||||||
|
const announced = JSON.parse(
|
||||||
|
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
|
||||||
|
);
|
||||||
|
expect(announced.type).toBe("participants");
|
||||||
|
expect(announced.participants).toHaveLength(2);
|
||||||
|
|
||||||
sessionManager.destroySession(id);
|
sessionManager.destroySession(id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
|||||||
const safeFetch = vi.hoisted(() => vi.fn());
|
const safeFetch = vi.hoisted(() => vi.fn());
|
||||||
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
|
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
getCurrentSettingValue: () => null,
|
||||||
|
}));
|
||||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||||
safeOutboundFetch: safeFetch,
|
safeOutboundFetch: safeFetch,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||||
|
import { getCurrentSettingValue } from "../database/repositories/factory.js";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
import type { AuditLogParams } from "./audit-logger.js";
|
import type { AuditLogParams } from "./audit-logger.js";
|
||||||
|
|
||||||
export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL";
|
export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL";
|
||||||
export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN";
|
export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN";
|
||||||
|
export const AUDIT_FORWARD_URL_SETTING = "audit_log_forward_url";
|
||||||
|
export const AUDIT_FORWARD_TOKEN_SETTING = "audit_log_forward_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings-table read that tolerates running before the database is up (unit
|
||||||
|
* tests, early startup): no readable setting behaves like an unset one.
|
||||||
|
*/
|
||||||
|
function readForwardSetting(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return getCurrentSettingValue(key)?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many consecutive failures before the forwarder stops complaining on every
|
* How many consecutive failures before the forwarder stops complaining on every
|
||||||
@@ -23,9 +38,14 @@ export interface AuditForwardTarget {
|
|||||||
export function auditForwardTarget(
|
export function auditForwardTarget(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): AuditForwardTarget | null {
|
): AuditForwardTarget | null {
|
||||||
const url = env[AUDIT_FORWARD_URL_ENV]?.trim();
|
// An admin-entered collector wins over the deployment env var, so the UI
|
||||||
|
// can configure forwarding - and its token - without a restart.
|
||||||
|
const settingUrl = readForwardSetting(AUDIT_FORWARD_URL_SETTING);
|
||||||
|
const url = settingUrl ?? env[AUDIT_FORWARD_URL_ENV]?.trim();
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const token = env[AUDIT_FORWARD_TOKEN_ENV]?.trim();
|
const token = settingUrl
|
||||||
|
? readForwardSetting(AUDIT_FORWARD_TOKEN_SETTING)
|
||||||
|
: env[AUDIT_FORWARD_TOKEN_ENV]?.trim();
|
||||||
return token ? { url, token } : { url };
|
return token ? { url, token } : { url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,3 +72,22 @@ const VALID_PERMISSIONS = new Set<string>(
|
|||||||
export function isValidPermission(permission: string): boolean {
|
export function isValidPermission(permission: string): boolean {
|
||||||
return VALID_PERMISSIONS.has(permission);
|
return VALID_PERMISSIONS.has(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the seeded system roles grant. Applied only to a role row that has no
|
||||||
|
// permissions yet, so an admin's edits to these roles survive restarts.
|
||||||
|
export const SYSTEM_ROLE_DEFAULTS = {
|
||||||
|
admin: {
|
||||||
|
description: "Administrator with full access",
|
||||||
|
permissions: ["*"],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
description: "Regular user",
|
||||||
|
permissions: [
|
||||||
|
"hosts.*",
|
||||||
|
"snippets.*",
|
||||||
|
"automations.*",
|
||||||
|
"credentials.*",
|
||||||
|
"ai.*",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ class PermissionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// Checked last: it costs two queries and only matters on the deny path.
|
||||||
|
return this.isAdmin(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async canAccessHost(
|
async canAccessHost(
|
||||||
|
|||||||
@@ -33,29 +33,30 @@ export function requiresPersonalHostAuthentication(
|
|||||||
host: Pick<HostResolutionHostRecord, "credentialId" | "authType">,
|
host: Pick<HostResolutionHostRecord, "credentialId" | "authType">,
|
||||||
protocol: AuthOverrideProtocol,
|
protocol: AuthOverrideProtocol,
|
||||||
): boolean {
|
): boolean {
|
||||||
switch (protocol) {
|
// Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only
|
||||||
case "ssh":
|
// SSH, which sits behind shareSshAuth, can leave a recipient without auth.
|
||||||
return (
|
if (protocol !== "ssh") return false;
|
||||||
!!host.credentialId ||
|
return (
|
||||||
host.authType === "password" ||
|
!!host.credentialId ||
|
||||||
host.authType === "key" ||
|
host.authType === "password" ||
|
||||||
host.authType === "credential" ||
|
host.authType === "key" ||
|
||||||
host.authType === "agent"
|
host.authType === "credential" ||
|
||||||
);
|
host.authType === "agent"
|
||||||
// These cases document the extension point without enabling behavior.
|
);
|
||||||
case "rdp":
|
}
|
||||||
case "vnc":
|
|
||||||
case "telnet":
|
/** Whether the owner's auth for this protocol is available to recipients. */
|
||||||
throw new Error(
|
export function isOwnerAuthShared(
|
||||||
`${protocol.toUpperCase()} shared-host authentication is not implemented`,
|
host: Pick<HostResolutionHostRecord, "shareSshAuth">,
|
||||||
);
|
protocol: AuthOverrideProtocol,
|
||||||
}
|
): boolean {
|
||||||
|
return protocol === "ssh" ? !!host.shareSshAuth : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the shared-host authentication precedence independently from any
|
* Applies the shared-host authentication precedence independently from any
|
||||||
* transport: recipient override, explicitly shared owner auth, secretless
|
* transport: recipient override, shared owner auth, secretless auth, then
|
||||||
* auth, then "required". Only SSH is currently enabled by callers.
|
* "required".
|
||||||
*/
|
*/
|
||||||
export async function resolveRecipientSharedHostAuthentication(
|
export async function resolveRecipientSharedHostAuthentication(
|
||||||
host: HostResolutionHostRecord,
|
host: HostResolutionHostRecord,
|
||||||
@@ -96,8 +97,8 @@ export async function resolveRecipientSharedHostAuthentication(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (protocol === "ssh" && host.shareSshAuth) {
|
if (isOwnerAuthShared(host, protocol)) {
|
||||||
if (host.authType === "agent") {
|
if (protocol === "ssh" && host.authType === "agent") {
|
||||||
return {
|
return {
|
||||||
source: "owner-shared",
|
source: "owner-shared",
|
||||||
authType: "agent",
|
authType: "agent",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
createCurrentRoleRepository,
|
||||||
|
createCurrentUserRepository,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import { SYSTEM_ROLE_DEFAULTS } from "./permission-catalog.js";
|
||||||
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the admin/user system roles exist with a permission list and every
|
||||||
|
* account hold one of them, on every dialect.
|
||||||
|
*
|
||||||
|
* Route-level RBAC denies anyone whose roles grant nothing, so this runs at
|
||||||
|
* startup before requests are served: the SQLite bootstrap seeded the roles
|
||||||
|
* with a NULL permission column, and the Postgres/MySQL path never seeded
|
||||||
|
* them at all.
|
||||||
|
*/
|
||||||
|
export async function ensureSystemRoles(): Promise<void> {
|
||||||
|
const roleRepository = createCurrentRoleRepository();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
for (const [name, defaults] of Object.entries(SYSTEM_ROLE_DEFAULTS)) {
|
||||||
|
const permissions = JSON.stringify(defaults.permissions);
|
||||||
|
const existing = await roleRepository.findRoleByName(name);
|
||||||
|
if (!existing) {
|
||||||
|
await roleRepository.createRole({
|
||||||
|
name,
|
||||||
|
displayName: `rbac.roles.${name}`,
|
||||||
|
description: defaults.description,
|
||||||
|
isSystem: true,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existing.permissions === null) {
|
||||||
|
await roleRepository.updateRole(existing.id, {
|
||||||
|
permissions,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await createCurrentUserRepository().listAll();
|
||||||
|
for (const user of users) {
|
||||||
|
const roleIds = await roleRepository.listUserRoleIds(user.id);
|
||||||
|
if (roleIds.length > 0) continue;
|
||||||
|
const assigned = await roleRepository.assignRoleNameToUser({
|
||||||
|
userId: user.id,
|
||||||
|
roleName: user.isAdmin ? "admin" : "user",
|
||||||
|
grantedBy: user.id,
|
||||||
|
});
|
||||||
|
if (!assigned) {
|
||||||
|
databaseLogger.warn("Could not assign a system role", {
|
||||||
|
operation: "ensure_system_roles",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,8 @@ export const AUTH_OVERRIDE_PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const;
|
|||||||
|
|
||||||
export type AuthOverrideProtocol = (typeof AUTH_OVERRIDE_PROTOCOLS)[number];
|
export type AuthOverrideProtocol = (typeof AUTH_OVERRIDE_PROTOCOLS)[number];
|
||||||
|
|
||||||
// Storage and API contracts are protocol-aware, but SSH is intentionally the
|
export const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS =
|
||||||
// only protocol whose recipient override flow is enabled today.
|
AUTH_OVERRIDE_PROTOCOLS satisfies readonly AuthOverrideProtocol[];
|
||||||
export const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS = [
|
|
||||||
"ssh",
|
|
||||||
] as const satisfies readonly AuthOverrideProtocol[];
|
|
||||||
|
|
||||||
export const AUTH_PROTOCOL_METADATA = {
|
export const AUTH_PROTOCOL_METADATA = {
|
||||||
ssh: {
|
ssh: {
|
||||||
|
|||||||
@@ -105,3 +105,33 @@ export async function getAuditLogActions(): Promise<{ actions: string[] }> {
|
|||||||
handleApiError(error, "fetch audit log actions");
|
handleApiError(error, "fetch audit log actions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditForwardingSettings {
|
||||||
|
url: string;
|
||||||
|
hasToken: boolean;
|
||||||
|
envConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditForwarding(): Promise<AuditForwardingSettings> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/audit-forwarding");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get audit forwarding settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAuditForwarding(
|
||||||
|
url: string,
|
||||||
|
token?: string,
|
||||||
|
): Promise<{ url: string; hasToken: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/audit-forwarding", {
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update audit forwarding settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,24 @@ export async function removeRoleFromUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoleMember {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
grantedAt: string;
|
||||||
|
grantedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoleMembers(
|
||||||
|
roleId: number,
|
||||||
|
): Promise<{ members: RoleMember[] }> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.get(`/rbac/roles/${roleId}/members`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get role members");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||||
|
|
||||||
export interface ShareTarget {
|
export interface ShareTarget {
|
||||||
@@ -321,6 +339,23 @@ export async function shareSnippet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function shareSnippetFolder(
|
||||||
|
folder: string,
|
||||||
|
targets: ShareTarget[],
|
||||||
|
durationHours?: number,
|
||||||
|
): Promise<{ success: boolean; snippetsShared: number }> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.post("/rbac/snippet-folder/share", {
|
||||||
|
folder,
|
||||||
|
targets,
|
||||||
|
durationHours,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "share snippet folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnippetAccess(
|
export async function getSnippetAccess(
|
||||||
snippetId: number,
|
snippetId: number,
|
||||||
): Promise<{ accessList: AccessRecord[] }> {
|
): Promise<{ accessList: AccessRecord[] }> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useXTerm } from "react-xtermjs";
|
import { useXTerm } from "react-xtermjs";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { AlertCircle, Eye } from "lucide-react";
|
import { AlertCircle, Eye, Users } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
resolveShareLink,
|
resolveShareLink,
|
||||||
type ResolvedShareLink,
|
type ResolvedShareLink,
|
||||||
@@ -44,6 +44,41 @@ async function resolveTerminalWsBaseUrl(): Promise<string> {
|
|||||||
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionParticipantInfo {
|
||||||
|
isOwner: boolean;
|
||||||
|
permissionLevel: "read-write" | "read-only";
|
||||||
|
label: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticipantsBadge({
|
||||||
|
participants,
|
||||||
|
ownerLabel,
|
||||||
|
}: {
|
||||||
|
participants: SessionParticipantInfo[];
|
||||||
|
ownerLabel: string;
|
||||||
|
}) {
|
||||||
|
if (participants.length < 2) return null;
|
||||||
|
const names = participants
|
||||||
|
.map((participant) =>
|
||||||
|
participant.isOwner ? ownerLabel : (participant.label ?? "?"),
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
border: "1px solid var(--border-base)",
|
||||||
|
}}
|
||||||
|
title={names}
|
||||||
|
>
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
{participants.length}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReadOnlyBadge({ label }: { label: string }) {
|
function ReadOnlyBadge({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -93,6 +128,9 @@ function GuestTerminalView({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||||
const [ended, setEnded] = useState<string | null>(null);
|
const [ended, setEnded] = useState<string | null>(null);
|
||||||
|
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -140,6 +178,11 @@ function GuestTerminalView({
|
|||||||
case "data":
|
case "data":
|
||||||
if (typeof msg.data === "string") terminal.write(msg.data);
|
if (typeof msg.data === "string") terminal.write(msg.data);
|
||||||
break;
|
break;
|
||||||
|
case "participants":
|
||||||
|
if (Array.isArray(msg.participants)) {
|
||||||
|
setParticipants(msg.participants as SessionParticipantInfo[]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "sessionExpired":
|
case "sessionExpired":
|
||||||
case "sessionTerminatedByOwner":
|
case "sessionTerminatedByOwner":
|
||||||
case "session_ended":
|
case "session_ended":
|
||||||
@@ -176,6 +219,10 @@ function GuestTerminalView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
|
<ParticipantsBadge
|
||||||
|
participants={participants}
|
||||||
|
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
|
||||||
|
/>
|
||||||
{share.permissionLevel === "read-only" && (
|
{share.permissionLevel === "read-only" && (
|
||||||
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-1
@@ -1580,7 +1580,9 @@
|
|||||||
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
|
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
|
||||||
"ownerAuthShared": "The host owner has shared SSH authentication for this host. You can use it or choose your own credential from “Set personal SSH authentication.”",
|
"ownerAuthShared": "The host owner has shared SSH authentication for this host. You can use it or choose your own credential from “Set personal SSH authentication.”",
|
||||||
"authOverrideAction": "Set personal SSH authentication",
|
"authOverrideAction": "Set personal SSH authentication",
|
||||||
|
"authOverrideActionProtocol": "Set personal {{protocol}} authentication",
|
||||||
"authOverrideTitle": "Personal SSH authentication",
|
"authOverrideTitle": "Personal SSH authentication",
|
||||||
|
"authOverrideTitleProtocol": "Personal {{protocol}} authentication",
|
||||||
"authOverrideDescriptionPrivate": "The host owner's SSH credentials stay private. Choose one of your saved credentials for connections to {{host}}.",
|
"authOverrideDescriptionPrivate": "The host owner's SSH credentials stay private. Choose one of your saved credentials for connections to {{host}}.",
|
||||||
"authOverrideDescriptionShared": "Use the authentication shared by the host owner, or replace it with one of your saved credentials for connections to {{host}}.",
|
"authOverrideDescriptionShared": "Use the authentication shared by the host owner, or replace it with one of your saved credentials for connections to {{host}}.",
|
||||||
"authOverrideCredentialLabel": "Authentication credential",
|
"authOverrideCredentialLabel": "Authentication credential",
|
||||||
@@ -1810,7 +1812,8 @@
|
|||||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||||
"rateLimited": "Too many attempts, please try again shortly",
|
"rateLimited": "Too many attempts, please try again shortly",
|
||||||
"sessionEnded": "This session has ended",
|
"sessionEnded": "This session has ended",
|
||||||
"readOnlyBadge": "View only"
|
"readOnlyBadge": "View only",
|
||||||
|
"ownerLabel": "Host owner"
|
||||||
},
|
},
|
||||||
"modalTitle": "Share session",
|
"modalTitle": "Share session",
|
||||||
"shareButton": "Share",
|
"shareButton": "Share",
|
||||||
@@ -3291,6 +3294,13 @@
|
|||||||
"analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
|
"analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
|
||||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||||
"sessionSharingGloballyEnabled": "Allow Session Sharing",
|
"sessionSharingGloballyEnabled": "Allow Session Sharing",
|
||||||
|
"auditForwardingTitle": "Forward to SIEM",
|
||||||
|
"auditForwardingDesc": "Ship a copy of every audit entry to an external collector as NDJSON. Local logs stay the source of truth.",
|
||||||
|
"auditForwardingEnvNotice": "An environment variable also configures forwarding; the URL saved here takes precedence.",
|
||||||
|
"auditForwardingToken": "Bearer token (optional)",
|
||||||
|
"auditForwardingTokenStored": "Bearer token (stored)",
|
||||||
|
"auditForwardingSaved": "Audit forwarding settings saved",
|
||||||
|
"auditForwardingSaveError": "Failed to save audit forwarding settings",
|
||||||
"sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.",
|
"sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.",
|
||||||
"updateSessionSharingFailed": "Failed to update session sharing setting",
|
"updateSessionSharingFailed": "Failed to update session sharing setting",
|
||||||
"sessionTimeout": "Session Timeout",
|
"sessionTimeout": "Session Timeout",
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import { AccordionSection } from "./AdminSettingsShared";
|
|||||||
import {
|
import {
|
||||||
getAuditLogs,
|
getAuditLogs,
|
||||||
getAuditLogActions,
|
getAuditLogActions,
|
||||||
|
getAuditForwarding,
|
||||||
|
updateAuditForwarding,
|
||||||
type AuditLog,
|
type AuditLog,
|
||||||
type AuditLogFilters,
|
type AuditLogFilters,
|
||||||
} from "@/api/audit-log-api";
|
} from "@/api/audit-log-api";
|
||||||
|
import { toast } from "sonner";
|
||||||
import type { AdminUser } from "./AdminManagementSections";
|
import type { AdminUser } from "./AdminManagementSections";
|
||||||
|
|
||||||
const RESOURCE_TYPES = [
|
const RESOURCE_TYPES = [
|
||||||
@@ -43,6 +46,39 @@ export function AdminAuditLogSection({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [forwardUrl, setForwardUrl] = useState("");
|
||||||
|
const [forwardToken, setForwardToken] = useState("");
|
||||||
|
const [forwardHasToken, setForwardHasToken] = useState(false);
|
||||||
|
const [forwardEnvConfigured, setForwardEnvConfigured] = useState(false);
|
||||||
|
const [forwardSaving, setForwardSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
getAuditForwarding()
|
||||||
|
.then((settings) => {
|
||||||
|
setForwardUrl(settings.url);
|
||||||
|
setForwardHasToken(settings.hasToken);
|
||||||
|
setForwardEnvConfigured(settings.envConfigured);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function handleSaveForwarding() {
|
||||||
|
setForwardSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateAuditForwarding(
|
||||||
|
forwardUrl,
|
||||||
|
forwardToken || undefined,
|
||||||
|
);
|
||||||
|
setForwardHasToken(result.hasToken || (!!result.url && forwardHasToken));
|
||||||
|
setForwardToken("");
|
||||||
|
toast.success(t("admin.auditForwardingSaved"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.auditForwardingSaveError"));
|
||||||
|
} finally {
|
||||||
|
setForwardSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@@ -170,6 +206,45 @@ export function AdminAuditLogSection({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col pt-2 gap-2">
|
<div className="flex flex-col pt-2 gap-2">
|
||||||
|
{/* SIEM forwarding */}
|
||||||
|
<div className="flex flex-col gap-1.5 pb-2 border-b border-border">
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("admin.auditForwardingTitle")}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{t("admin.auditForwardingDesc")}
|
||||||
|
{forwardEnvConfigured && (
|
||||||
|
<> {t("admin.auditForwardingEnvNotice")}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
placeholder="https://siem.example.com/ingest"
|
||||||
|
value={forwardUrl}
|
||||||
|
onChange={(e) => setForwardUrl(e.target.value)}
|
||||||
|
className="text-[10px] h-7"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
forwardHasToken
|
||||||
|
? t("admin.auditForwardingTokenStored")
|
||||||
|
: t("admin.auditForwardingToken")
|
||||||
|
}
|
||||||
|
value={forwardToken}
|
||||||
|
onChange={(e) => setForwardToken(e.target.value)}
|
||||||
|
className="text-[10px] h-7"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-[10px] self-start"
|
||||||
|
onClick={handleSaveForwarding}
|
||||||
|
disabled={forwardSaving}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
setHostAuthOverride,
|
setHostAuthOverride,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { Credential, Host } from "@/types/ui-types";
|
import type { Credential, Host } from "@/types/ui-types";
|
||||||
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
|
import {
|
||||||
|
AUTH_PROTOCOL_METADATA,
|
||||||
|
type AuthOverrideProtocol,
|
||||||
|
} from "@/types/auth-protocols";
|
||||||
import { mapCredentials } from "./HostManagerData";
|
import { mapCredentials } from "./HostManagerData";
|
||||||
|
|
||||||
export function HostAuthOverrideModal({
|
export function HostAuthOverrideModal({
|
||||||
@@ -106,7 +109,11 @@ export function HostAuthOverrideModal({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("hosts.sharing.authOverrideTitle")}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("hosts.sharing.authOverrideTitleProtocol", {
|
||||||
|
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t(
|
{t(
|
||||||
ownerAuthShared
|
ownerAuthShared
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ import {
|
|||||||
canShareHost,
|
canShareHost,
|
||||||
} from "@/sidebar/host-permissions";
|
} from "@/sidebar/host-permissions";
|
||||||
import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal";
|
import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal";
|
||||||
|
import {
|
||||||
|
AUTH_OVERRIDE_PROTOCOLS,
|
||||||
|
AUTH_PROTOCOL_METADATA,
|
||||||
|
type AuthOverrideProtocol,
|
||||||
|
} from "@/types/auth-protocols";
|
||||||
import {
|
import {
|
||||||
useStatusColorScheme,
|
useStatusColorScheme,
|
||||||
getStatusClasses,
|
getStatusClasses,
|
||||||
@@ -340,8 +345,11 @@ export function HostItem({
|
|||||||
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
|
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
|
||||||
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
|
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
|
||||||
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
|
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
|
||||||
const canOverrideAuth = canOverrideHostAuth(host, "ssh");
|
const authOverrideProtocols = AUTH_OVERRIDE_PROTOCOLS.filter((protocol) =>
|
||||||
const [authOverrideOpen, setAuthOverrideOpen] = useState(false);
|
canOverrideHostAuth(host, protocol),
|
||||||
|
);
|
||||||
|
const [authOverrideProtocol, setAuthOverrideProtocol] =
|
||||||
|
useState<AuthOverrideProtocol | null>(null);
|
||||||
const [parentDragOver, setParentDragOver] = useState(false);
|
const [parentDragOver, setParentDragOver] = useState(false);
|
||||||
const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false);
|
const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false);
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState<{
|
const [contextMenuPosition, setContextMenuPosition] = useState<{
|
||||||
@@ -736,17 +744,20 @@ export function HostItem({
|
|||||||
<Copy className="size-3.5 mr-2" />
|
<Copy className="size-3.5 mr-2" />
|
||||||
{t("hosts.copyAddress")}
|
{t("hosts.copyAddress")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canOverrideAuth && (
|
{authOverrideProtocols.map((protocol) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
key={protocol}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setAuthOverrideOpen(true);
|
setAuthOverrideProtocol(protocol);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<KeyRound className="size-3.5 mr-2" />
|
<KeyRound className="size-3.5 mr-2" />
|
||||||
{t("hosts.sharing.authOverrideAction")}
|
{t("hosts.sharing.authOverrideActionProtocol", {
|
||||||
|
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
|
||||||
|
})}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
))}
|
||||||
{showPasswordCopy && (
|
{showPasswordCopy && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => handleCopyPassword(e, "password")}
|
onClick={(e) => handleCopyPassword(e, "password")}
|
||||||
@@ -1320,12 +1331,14 @@ export function HostItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canOverrideAuth && (
|
{authOverrideProtocol && (
|
||||||
<HostAuthOverrideModal
|
<HostAuthOverrideModal
|
||||||
open={authOverrideOpen}
|
open
|
||||||
onOpenChange={setAuthOverrideOpen}
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setAuthOverrideProtocol(null);
|
||||||
|
}}
|
||||||
host={host}
|
host={host}
|
||||||
protocol="ssh"
|
protocol={authOverrideProtocol}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe("HostAuthOverrideModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("canOverrideHostAuth", () => {
|
describe("canOverrideHostAuth", () => {
|
||||||
it("allows every shared SSH permission level and excludes owners and non-SSH hosts", () => {
|
it("allows every shared permission level and excludes owners and disabled protocols", () => {
|
||||||
for (const permissionLevel of [
|
for (const permissionLevel of [
|
||||||
"connect",
|
"connect",
|
||||||
"view",
|
"view",
|
||||||
@@ -200,6 +200,7 @@ describe("canOverrideHostAuth", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
canOverrideHostAuth({ ...host, enableRdp: true } as Host, "rdp"),
|
canOverrideHostAuth({ ...host, enableRdp: true } as Host, "rdp"),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
|
expect(canOverrideHostAuth(host, "rdp")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user