diff --git a/src/backend/ai/index.ts b/src/backend/ai/index.ts index aa31f605..bd2026b8 100644 --- a/src/backend/ai/index.ts +++ b/src/backend/ai/index.ts @@ -1,6 +1,7 @@ import { getErrorMessage } from "../utils/error-message.js"; import express from "express"; import type { AuthenticatedRequest } from "../../types/index.js"; +import { PermissionManager } from "../utils/permission-manager.js"; import { AuthManager } from "../utils/auth-manager.js"; import { databaseLogger } from "../utils/logger.js"; import { @@ -36,6 +37,7 @@ import { applyProposal } from "./tools/executor.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); +const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); const aiGate = createAiGate(); @@ -97,6 +99,7 @@ router.get("/status", authenticateJWT, async (req, res) => { router.get( "/providers", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { @@ -147,6 +150,7 @@ router.get( router.post( "/providers", authenticateJWT, + permissionManager.requirePermission("ai.manage_providers"), requireDataAccess, aiGate, async (req, res) => { @@ -224,6 +228,7 @@ router.post( router.patch( "/providers/:id", authenticateJWT, + permissionManager.requirePermission("ai.manage_providers"), requireDataAccess, aiGate, async (req, res) => { @@ -286,6 +291,7 @@ router.patch( router.delete( "/providers/:id", authenticateJWT, + permissionManager.requirePermission("ai.manage_providers"), requireDataAccess, aiGate, async (req, res) => { @@ -358,6 +364,7 @@ router.delete( router.post( "/probe-models", authenticateJWT, + permissionManager.requirePermission("ai.manage_providers"), requireDataAccess, aiGate, async (req, res) => { @@ -423,6 +430,7 @@ router.post( router.get( "/providers/:id/models", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { @@ -470,6 +478,7 @@ router.get( router.get( "/conversations", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { @@ -510,6 +519,7 @@ router.get( router.get( "/conversations/:id", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { @@ -560,6 +570,7 @@ router.get( router.delete( "/conversations/:id", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { @@ -622,6 +633,7 @@ router.delete( router.post( "/chat/stream", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { @@ -840,6 +852,7 @@ router.post( router.post( "/proposals/:id/apply", authenticateJWT, + permissionManager.requirePermission("ai.apply_proposals"), requireDataAccess, aiGate, async (req, res) => { @@ -931,6 +944,7 @@ router.post( router.post( "/proposals/:id/reject", authenticateJWT, + permissionManager.requirePermission("ai.use"), requireDataAccess, aiGate, async (req, res) => { diff --git a/src/backend/ai/tools/catalog.ts b/src/backend/ai/tools/catalog.ts index 2edbf55e..7952ae88 100644 --- a/src/backend/ai/tools/catalog.ts +++ b/src/backend/ai/tools/catalog.ts @@ -5,12 +5,10 @@ import type { AiTool, ToolDefinitionShape } from "./types.js"; /** * The allowlist, and the security boundary for the whole feature. * - * A model can only ever invoke what appears here. This matters more than usual - * in this codebase: PermissionManager.requirePermission exists but is currently - * mounted on zero routes, so RBAC strings are a vocabulary for the admin role - * editor rather than route enforcement. "The assistant cannot reach credentials - * or user administration" is therefore a property of this list, not of the - * permission system. + * A model can only ever invoke what appears here. Route-level RBAC gates the + * HTTP API, but tools run in-process with the calling user's identity and never + * pass through a router, so "the assistant cannot reach credentials or user + * administration" is a property of this list, not of the permission system. * * Anything touching credentials, vaults, RBAC, users, identity, certificates, * SSO or instance settings is deliberately absent and must stay absent. diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index dd432756..11bb566b 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -21,6 +21,7 @@ import { } from "../../utils/data-dir-guard.js"; import { getDefaultGuacdUrl } from "../../utils/guacd-config.js"; import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js"; +import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js"; import { connectRemoteDatabase } from "./connect.js"; import { runRemoteMigrations } from "./migrate.js"; import type { PortableDatabase } from "../repositories/database-context.js"; @@ -1953,20 +1954,24 @@ const migrateSchema = () => { }); } - const systemRoles = [ - { - name: "admin", - displayName: "rbac.roles.admin", - description: "Administrator with full access", - permissions: null, - }, - { - name: "user", - displayName: "rbac.roles.user", - description: "Regular user", - permissions: null, - }, - ]; + const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map( + ([name, defaults]) => ({ + name, + displayName: `rbac.roles.${name}`, + description: defaults.description, + permissions: JSON.stringify(defaults.permissions), + }), + ); + + // Route-level RBAC needs the permission lists to exist; roles seeded by + // earlier versions carried NULL there. Backfill only NULL so an admin's + // 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) { const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name); @@ -2986,6 +2991,12 @@ async function initializeRemoteDatabase( await primeCurrentSettingsCache(); 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`, { operation: "db_init_complete", dialect, diff --git a/src/backend/database/repositories/role-repository.ts b/src/backend/database/repositories/role-repository.ts index 3790f238..78059d41 100644 --- a/src/backend/database/repositories/role-repository.ts +++ b/src/backend/database/repositories/role-repository.ts @@ -1,5 +1,5 @@ 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 { rowsAffected } from "./mutation-result.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 RoleUpdate = Pick< Partial, - "displayName" | "description" | "updatedAt" + "displayName" | "description" | "permissions" | "updatedAt" >; export type UserRoleWithRole = { @@ -21,6 +21,13 @@ export type UserRoleWithRole = { grantedAt: string; }; +export type RoleMemberRecord = { + userId: string; + username: string; + grantedAt: string; + grantedBy: string | null; +}; + export type UserRolePermissionRecord = { permissions: string | null; }; @@ -226,6 +233,20 @@ export class RoleRepository { return rows.map((row) => row.userId); } + async listRoleMembers(roleId: number): Promise { + 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( userId: string, ): Promise { diff --git a/src/backend/database/repositories/shared-host-auth-override-repository.ts b/src/backend/database/repositories/shared-host-auth-override-repository.ts index 653c50fb..eb290886 100644 --- a/src/backend/database/repositories/shared-host-auth-override-repository.ts +++ b/src/backend/database/repositories/shared-host-auth-override-repository.ts @@ -33,6 +33,33 @@ export class SharedHostAuthOverrideRepository { return rows[0] ?? null; } + /** Recipient overrides for a host keyed by protocol, in one query. */ + async listCredentialIds( + hostId: number, + userId: string, + ): Promise>> { + 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> = {}; + for (const row of rows) { + if (row.credentialId) { + result[row.protocol as AuthOverrideProtocol] = row.credentialId; + } + } + return result; + } + async findCredentialId( hostId: number, userId: string, diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts index 32b3d56c..2d3625b9 100644 --- a/src/backend/database/repositories/snippet-repository.ts +++ b/src/backend/database/repositories/snippet-repository.ts @@ -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 { snippetFolders, snippets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -108,6 +108,25 @@ export class SnippetRepository { return this.listFolders(userId); } + /** Snippets in a folder or any of its subfolders ("Parent / Child"). */ + async listOwnedSnippetsInFolder( + userId: string, + folder: string, + ): Promise { + 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 { return this.context.drizzle .select() diff --git a/src/backend/database/routes/automations.ts b/src/backend/database/routes/automations.ts index a2a81c6c..1aca7ca1 100644 --- a/src/backend/database/routes/automations.ts +++ b/src/backend/database/routes/automations.ts @@ -7,6 +7,7 @@ import type { Trigger, } from "../../../types/automations.js"; import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { databaseLogger } from "../../utils/logger.js"; import { @@ -26,6 +27,7 @@ import { const router = express.Router(); const authManager = AuthManager.getInstance(); +const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); @@ -244,6 +246,7 @@ async function syncSchedule( router.get( "/", authenticateJWT, + permissionManager.requirePermission("automations.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -282,6 +285,7 @@ router.get( router.get( "/:id", authenticateJWT, + permissionManager.requirePermission("automations.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -333,6 +337,7 @@ router.get( router.post( "/", authenticateJWT, + permissionManager.requirePermission("automations.create"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -423,6 +428,7 @@ router.post( router.put( "/:id", authenticateJWT, + permissionManager.requirePermission("automations.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -531,6 +537,7 @@ router.put( router.delete( "/:id", authenticateJWT, + permissionManager.requirePermission("automations.delete"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -600,6 +607,7 @@ router.delete( router.post( "/:id/run", authenticateJWT, + permissionManager.requirePermission("automations.run"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -673,6 +681,7 @@ router.post( router.get( "/runs/history", authenticateJWT, + permissionManager.requirePermission("automations.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -715,6 +724,7 @@ router.get( router.get( "/runs/:runId/steps", authenticateJWT, + permissionManager.requirePermission("automations.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/database/routes/credential-bulk-routes.ts b/src/backend/database/routes/credential-bulk-routes.ts index 00583d5e..448a7473 100644 --- a/src/backend/database/routes/credential-bulk-routes.ts +++ b/src/backend/database/routes/credential-bulk-routes.ts @@ -6,6 +6,8 @@ import { createCurrentCredentialRepository } from "../repositories/factory.js"; export function registerCredentialBulkRoutes( router: Router, authenticateJWT: RequestHandler, + requireEditPermission: RequestHandler, + requireDataAccess: RequestHandler, ): void { /** * @openapi @@ -42,6 +44,8 @@ export function registerCredentialBulkRoutes( router.put( "/reorder", authenticateJWT, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { positions } = req.body as { diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts index fdf98644..306abed2 100644 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ b/src/backend/database/routes/credential-deploy-routes.ts @@ -359,6 +359,9 @@ async function deploySSHKeyToHost( export function registerCredentialDeployRoutes( router: Router, authenticateJWT: RequestHandler, + requireCredentialViewPermission: RequestHandler, + requireHostEditPermission: RequestHandler, + requireDataAccess: RequestHandler, ): void { /** * @openapi @@ -398,6 +401,9 @@ export function registerCredentialDeployRoutes( router.post( "/:id/deploy-to-host", authenticateJWT, + requireCredentialViewPermission, + requireHostEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] diff --git a/src/backend/database/routes/credential-key-routes.ts b/src/backend/database/routes/credential-key-routes.ts index 0d912789..5cc72b23 100644 --- a/src/backend/database/routes/credential-key-routes.ts +++ b/src/backend/database/routes/credential-key-routes.ts @@ -63,6 +63,8 @@ function generateSSHKeyPair( export function registerCredentialKeyRoutes( router: Router, authenticateJWT: RequestHandler, + requireCredentialViewPermission: RequestHandler, + requireDataAccess: RequestHandler, ): void { /** * @openapi @@ -94,6 +96,8 @@ export function registerCredentialKeyRoutes( router.post( "/detect-key-type", authenticateJWT, + requireCredentialViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const { privateKey, keyPassword } = req.body; @@ -150,6 +154,8 @@ export function registerCredentialKeyRoutes( router.post( "/detect-public-key-type", authenticateJWT, + requireCredentialViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const { publicKey } = req.body; @@ -209,6 +215,8 @@ export function registerCredentialKeyRoutes( router.post( "/validate-key-pair", authenticateJWT, + requireCredentialViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const { privateKey, publicKey, keyPassword } = req.body; @@ -274,6 +282,8 @@ export function registerCredentialKeyRoutes( router.post( "/generate-key-pair", authenticateJWT, + requireCredentialViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body; @@ -339,6 +349,8 @@ export function registerCredentialKeyRoutes( router.post( "/generate-public-key", authenticateJWT, + requireCredentialViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const { privateKey, keyPassword } = req.body; diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index ab6ad60e..71fe880d 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import { authLogger } from "../../utils/logger.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; @@ -26,6 +27,7 @@ function isNonEmptyString(val: unknown): val is string { } const authManager = AuthManager.getInstance(); +const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); @@ -78,6 +80,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware(); router.post( "/", authenticateJWT, + permissionManager.requirePermission("credentials.create"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -250,6 +253,7 @@ router.post( router.get( "/", authenticateJWT, + permissionManager.requirePermission("credentials.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -290,6 +294,7 @@ router.get( router.get( "/folders", authenticateJWT, + permissionManager.requirePermission("credentials.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -311,7 +316,12 @@ router.get( // Registered here (before the PUT /:id route below) so the literal // "/reorder" path segment is matched before Express falls through to the // PUT /:id param route and treats "reorder" as an id. -registerCredentialBulkRoutes(router, authenticateJWT); +registerCredentialBulkRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("credentials.edit"), + requireDataAccess, +); /** * @openapi @@ -340,6 +350,7 @@ registerCredentialBulkRoutes(router, authenticateJWT); router.get( "/:id", authenticateJWT, + permissionManager.requirePermission("credentials.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -432,6 +443,7 @@ router.get( router.post( "/:id/duplicate", authenticateJWT, + permissionManager.requirePermission("credentials.create"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -585,6 +597,7 @@ router.post( router.put( "/:id", authenticateJWT, + permissionManager.requirePermission("credentials.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -747,6 +760,7 @@ router.put( router.delete( "/:id", authenticateJWT, + permissionManager.requirePermission("credentials.delete"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -878,6 +892,7 @@ router.delete( router.post( "/:id/apply-to-host/:hostId", authenticateJWT, + permissionManager.requirePermission("credentials.edit"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const credentialId = Array.isArray(req.params.id) @@ -958,6 +973,7 @@ router.post( router.get( "/:id/hosts", authenticateJWT, + permissionManager.requirePermission("credentials.view"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const credentialId = Array.isArray(req.params.id) @@ -1076,6 +1092,7 @@ function formatSSHHostOutput( router.put( "/folders/rename", authenticateJWT, + permissionManager.requirePermission("credentials.edit"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { oldName, newName } = req.body; @@ -1107,8 +1124,19 @@ router.put( }, ); -registerCredentialKeyRoutes(router, authenticateJWT); +registerCredentialKeyRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("credentials.view"), + requireDataAccess, +); -registerCredentialDeployRoutes(router, authenticateJWT); +registerCredentialDeployRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("credentials.view"), + permissionManager.requirePermission("hosts.edit"), + requireDataAccess, +); export default router; diff --git a/src/backend/database/routes/host-autostart-routes.ts b/src/backend/database/routes/host-autostart-routes.ts index 7c72d8d8..198913c2 100644 --- a/src/backend/database/routes/host-autostart-routes.ts +++ b/src/backend/database/routes/host-autostart-routes.ts @@ -7,12 +7,19 @@ import { createCurrentHostRepository } from "../repositories/factory.js"; type HostAutostartRoutesDeps = { authenticateJWT: RequestHandler; + requireViewPermission: RequestHandler; + requireEditPermission: RequestHandler; requireDataAccess: RequestHandler; }; export function registerHostAutostartRoutes( router: Router, - { authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps, + { + authenticateJWT, + requireViewPermission, + requireEditPermission, + requireDataAccess, + }: HostAutostartRoutesDeps, ): void { /** * @openapi @@ -44,6 +51,7 @@ export function registerHostAutostartRoutes( router.post( "/autostart/enable", authenticateJWT, + requireEditPermission, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -204,6 +212,8 @@ export function registerHostAutostartRoutes( router.delete( "/autostart/disable", authenticateJWT, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { sshConfigId } = req.body; @@ -259,6 +269,8 @@ export function registerHostAutostartRoutes( router.get( "/autostart/status", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index c888f43a..8a96fe46 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -118,6 +118,9 @@ export function importedHostUsername( export function registerHostBulkRoutes( router: Router, authenticateJWT: RequestHandler, + requireCreatePermission: RequestHandler, + requireEditPermission: RequestHandler, + requireDataAccess: RequestHandler, ): void { /** * @openapi @@ -182,6 +185,8 @@ export function registerHostBulkRoutes( router.patch( "/bulk-update", authenticateJWT, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostIds, updates } = req.body; @@ -386,6 +391,8 @@ export function registerHostBulkRoutes( router.put( "/reorder", authenticateJWT, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { positions } = req.body as { @@ -432,6 +439,9 @@ export function registerHostBulkRoutes( router.post( "/bulk-import", authenticateJWT, + requireCreatePermission, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { @@ -844,6 +854,9 @@ export function registerHostBulkRoutes( router.post( "/ssh-config-import", authenticateJWT, + requireCreatePermission, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { content, overwrite } = req.body; diff --git a/src/backend/database/routes/host-command-history-routes.ts b/src/backend/database/routes/host-command-history-routes.ts index 0f704044..5658e6f6 100644 --- a/src/backend/database/routes/host-command-history-routes.ts +++ b/src/backend/database/routes/host-command-history-routes.ts @@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js"; export function registerHostCommandHistoryRoutes( router: Router, authenticateJWT: RequestHandler, + requireViewPermission: RequestHandler, + requireDataAccess: RequestHandler, ): void { /** * @openapi @@ -33,6 +35,8 @@ export function registerHostCommandHistoryRoutes( router.get( "/command-history/:hostId", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdParam = Array.isArray(req.params.hostId) @@ -98,6 +102,8 @@ export function registerHostCommandHistoryRoutes( router.delete( "/command-history", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, command } = req.body; diff --git a/src/backend/database/routes/host-file-manager-bookmark-routes.ts b/src/backend/database/routes/host-file-manager-bookmark-routes.ts index a56f299c..d8d47031 100644 --- a/src/backend/database/routes/host-file-manager-bookmark-routes.ts +++ b/src/backend/database/routes/host-file-manager-bookmark-routes.ts @@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js"; export function registerHostFileManagerBookmarkRoutes( router: Router, authenticateJWT: RequestHandler, + requireViewPermission: RequestHandler, + requireDataAccess: RequestHandler, ): void { /** * @openapi @@ -33,6 +35,8 @@ export function registerHostFileManagerBookmarkRoutes( router.get( "/file_manager/recent", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdQuery = Array.isArray(req.query.hostId) @@ -98,6 +102,8 @@ export function registerHostFileManagerBookmarkRoutes( router.post( "/file_manager/recent", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path, name } = req.body; @@ -155,6 +161,8 @@ export function registerHostFileManagerBookmarkRoutes( router.delete( "/file_manager/recent", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path } = req.body; @@ -203,6 +211,8 @@ export function registerHostFileManagerBookmarkRoutes( router.get( "/file_manager/pinned", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdQuery = Array.isArray(req.query.hostId) @@ -269,6 +279,8 @@ export function registerHostFileManagerBookmarkRoutes( router.post( "/file_manager/pinned", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path, name } = req.body; @@ -327,6 +339,8 @@ export function registerHostFileManagerBookmarkRoutes( router.delete( "/file_manager/pinned", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path } = req.body; @@ -375,6 +389,8 @@ export function registerHostFileManagerBookmarkRoutes( router.get( "/file_manager/shortcuts", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdQuery = Array.isArray(req.query.hostId) @@ -441,6 +457,8 @@ export function registerHostFileManagerBookmarkRoutes( router.post( "/file_manager/shortcuts", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path, name } = req.body; @@ -499,6 +517,8 @@ export function registerHostFileManagerBookmarkRoutes( router.delete( "/file_manager/shortcuts", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path } = req.body; diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index 0c11c475..bbfcfc06 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -17,12 +17,25 @@ import { isNonEmptyString } from "./host-normalizers.js"; type HostFolderRoutesDeps = { authenticateJWT: RequestHandler; + requireViewPermission: RequestHandler; + requireEditPermission: RequestHandler; + requireDeletePermission: RequestHandler; + requireCredentialEditPermission: RequestHandler; + requireDataAccess: RequestHandler; statsServerUrl: string; }; export function registerHostFolderRoutes( router: Router, - { authenticateJWT, statsServerUrl }: HostFolderRoutesDeps, + { + authenticateJWT, + requireViewPermission, + requireEditPermission, + requireDeletePermission, + requireCredentialEditPermission, + requireDataAccess, + statsServerUrl, + }: HostFolderRoutesDeps, ): void { /** * @openapi @@ -54,6 +67,9 @@ export function registerHostFolderRoutes( router.put( "/folders/rename", authenticateJWT, + requireEditPermission, + requireCredentialEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { oldName, newName } = req.body; @@ -113,6 +129,8 @@ export function registerHostFolderRoutes( router.get( "/folders", authenticateJWT, + requireViewPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -170,6 +188,8 @@ export function registerHostFolderRoutes( router.put( "/folders/metadata", authenticateJWT, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { name, color, icon, credentialId } = req.body; @@ -275,6 +295,8 @@ export function registerHostFolderRoutes( router.put( "/folders/reorder", authenticateJWT, + requireEditPermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { positions } = req.body as { @@ -346,6 +368,8 @@ export function registerHostFolderRoutes( router.delete( "/folders/:name/hosts", authenticateJWT, + requireDeletePermission, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const folderName = Array.isArray(req.params.name) diff --git a/src/backend/database/routes/host-network-routes.ts b/src/backend/database/routes/host-network-routes.ts index 257ecdfb..dce7cd3e 100644 --- a/src/backend/database/routes/host-network-routes.ts +++ b/src/backend/database/routes/host-network-routes.ts @@ -7,12 +7,17 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j interface HostNetworkRoutesDeps { authenticateJWT: RequestHandler; + requireViewPermission: RequestHandler; requireDataAccess: RequestHandler; } export function registerHostNetworkRoutes( router: Router, - { authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps, + { + authenticateJWT, + requireViewPermission, + requireDataAccess, + }: HostNetworkRoutesDeps, ): void { /** * @openapi @@ -62,6 +67,7 @@ export function registerHostNetworkRoutes( router.post( "/db/proxy/test", authenticateJWT, + requireViewPermission, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { try { @@ -93,6 +99,7 @@ export function registerHostNetworkRoutes( router.post( "/db/host/:id/wake", authenticateJWT, + requireViewPermission, requireDataAccess, async (req: Request, res: Response) => { const hostId = Number.parseInt(String(req.params.id), 10); diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 7983db2d..b7e7d1aa 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -28,6 +28,7 @@ import { createCurrentHostRepository, createCurrentUserRepository, createCurrentSyncTombstoneRepository, + createCurrentSharedHostAuthOverrideRepository, } from "../repositories/factory.js"; import { containsOwnerPrivateAuthUpdate, @@ -62,6 +63,7 @@ import type { HostResolutionCredentialRecord, HostResolutionHostRecord, } from "../repositories/host-resolution-repository.js"; +import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js"; import { requiresPersonalHostAuthentication, resolveRecipientSharedHostAuthentication, @@ -125,6 +127,7 @@ registerHostInternalRoutes(router); router.post( ["/db/host", "/enroll"], authenticateJWT, + permissionManager.requirePermission("hosts.create"), requireDataAccess, requireHostEnrollmentAccessForPath, upload.single("key"), @@ -817,6 +820,7 @@ router.post( router.put( "/db/host/:id", authenticateJWT, + permissionManager.requirePermission("hosts.edit"), requireDataAccess, upload.single("key"), async (req: Request, res: Response) => { @@ -1458,6 +1462,7 @@ router.put( router.get( "/db/host", authenticateJWT, + permissionManager.requirePermission("hosts.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1601,6 +1606,7 @@ router.get( router.get( "/db/host/:id", authenticateJWT, + permissionManager.requirePermission("hosts.view"), requireDataAccess, async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) @@ -2223,6 +2229,7 @@ router.get( router.delete( "/db/host/:id", authenticateJWT, + permissionManager.requirePermission("hosts.delete"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -2354,11 +2361,18 @@ router.delete( }, ); -registerHostFileManagerBookmarkRoutes(router, authenticateJWT); +registerHostFileManagerBookmarkRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("hosts.view"), + requireDataAccess, +); router.get( "/transfer/recent", authenticateJWT, + permissionManager.requirePermission("hosts.view"), + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const sourceHostIdQuery = Array.isArray(req.query.sourceHostId) @@ -2395,6 +2409,8 @@ router.get( router.post( "/transfer/recent", authenticateJWT, + permissionManager.requirePermission("hosts.view"), + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { sourceHostId, destHostId, destPath, destPathLabel } = req.body; @@ -2426,7 +2442,12 @@ router.post( } }, ); -registerHostCommandHistoryRoutes(router, authenticateJWT); +registerHostCommandHistoryRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("hosts.view"), + requireDataAccess, +); async function resolveHostCredentials( host: Record, @@ -2455,6 +2476,24 @@ async function resolveHostCredentials( required: needsPersonalCredential, ownerAuthShared: !!host.shareSshAuth, }; + // Owner auth for the remote desktop protocols is always snapshotted + // for recipients; only their own override credential varies per user. + const authOverrides: Record = { + 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 = { ...host, credentialId: null, @@ -2462,9 +2501,7 @@ async function resolveHostCredentials( key: null, keyPassword: null, keyType: null, - authOverrides: { - ssh: baseSshOverrideState, - }, + authOverrides, }; try { @@ -2480,6 +2517,7 @@ async function resolveHostCredentials( return { ...recipientHost, authOverrides: { + ...authOverrides, ssh: { credentialId: resolution.credentialId, required: false, @@ -2505,6 +2543,7 @@ async function resolveHostCredentials( return { ...recipientHost, authOverrides: { + ...authOverrides, ssh: { required: false, ownerAuthShared: true, @@ -2524,6 +2563,7 @@ async function resolveHostCredentials( return { ...recipientHost, authOverrides: { + ...authOverrides, ssh: { required: false, ownerAuthShared: true, @@ -2547,6 +2587,7 @@ async function resolveHostCredentials( return { ...recipientHost, authOverrides: { + ...authOverrides, ssh: { required: false, ownerAuthShared: !!host.shareSshAuth, @@ -2605,13 +2646,27 @@ async function resolveHostCredentials( registerHostFolderRoutes(router, { authenticateJWT, + requireViewPermission: permissionManager.requirePermission("hosts.view"), + requireEditPermission: permissionManager.requirePermission("hosts.edit"), + requireDeletePermission: permissionManager.requirePermission("hosts.delete"), + requireCredentialEditPermission: + permissionManager.requirePermission("credentials.edit"), + requireDataAccess, statsServerUrl: STATS_SERVER_URL, }); -registerHostBulkRoutes(router, authenticateJWT); +registerHostBulkRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("hosts.create"), + permissionManager.requirePermission("hosts.edit"), + requireDataAccess, +); registerHostAutostartRoutes(router, { authenticateJWT, + requireViewPermission: permissionManager.requirePermission("hosts.view"), + requireEditPermission: permissionManager.requirePermission("hosts.edit"), requireDataAccess, }); @@ -2656,6 +2711,8 @@ registerHostAutostartRoutes(router, { router.get( "/ssh/opkssh/token/:hostId", authenticateJWT, + permissionManager.requirePermission("hosts.view"), + requireDataAccess, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId; @@ -2727,6 +2784,8 @@ router.get( router.delete( "/ssh/opkssh/token/:hostId", authenticateJWT, + permissionManager.requirePermission("hosts.edit"), + requireDataAccess, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId; @@ -2759,6 +2818,7 @@ registerHostOpksshRoutes(router); registerHostNetworkRoutes(router, { authenticateJWT, + requireViewPermission: permissionManager.requirePermission("hosts.view"), requireDataAccess, }); diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index b8af0503..064b6062 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -154,6 +154,7 @@ export function parseShareTargets( router.post( "/host/:id/share", authenticateJWT, + permissionManager.requirePermission("hosts.share"), async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const hostId = parseInt(id, 10); @@ -365,6 +366,7 @@ router.post( router.post( "/folder/share", authenticateJWT, + permissionManager.requirePermission("hosts.share"), async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId!; const { folder } = req.body ?? {}; @@ -939,7 +941,7 @@ router.get( router.post( "/roles", authenticateJWT, - permissionManager.requireAdmin(), + permissionManager.requirePermission("admin.roles.manage"), async (req: AuthenticatedRequest, res: Response) => { const { name, displayName, description } = req.body; @@ -1031,7 +1033,7 @@ router.post( router.put( "/roles/:id", authenticateJWT, - permissionManager.requireAdmin(), + permissionManager.requirePermission("admin.roles.manage"), async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const roleId = parseInt(id, 10); @@ -1174,7 +1176,7 @@ router.get( router.delete( "/roles/:id", authenticateJWT, - permissionManager.requireAdmin(), + permissionManager.requirePermission("admin.roles.manage"), async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const roleId = parseInt(id, 10); @@ -1217,6 +1219,53 @@ router.delete( }, ); +/** + * @openapi + * /rbac/roles/{id}/members: + * get: + * summary: List the users holding a role + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Role members. + * 404: + * description: Role not found. + */ +router.get( + "/roles/:id/members", + authenticateJWT, + permissionManager.requirePermission("admin.roles.manage"), + async (req: AuthenticatedRequest, res: Response) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const roleId = parseInt(id, 10); + + if (isNaN(roleId)) { + return res.status(400).json({ error: "Invalid role ID" }); + } + + try { + const roleRepository = createCurrentRoleRepository(); + if (!(await roleRepository.findRoleById(roleId))) { + return res.status(404).json({ error: "Role not found" }); + } + res.json({ members: await roleRepository.listRoleMembers(roleId) }); + } catch (error) { + databaseLogger.error("Failed to list role members", error, { + operation: "list_role_members", + roleId, + }); + res.status(500).json({ error: "Failed to list role members" }); + } + }, +); + /** * @openapi * /rbac/users/{userId}/roles: @@ -1257,7 +1306,7 @@ router.delete( router.post( "/users/:userId/roles", authenticateJWT, - permissionManager.requireAdmin(), + permissionManager.requirePermission("admin.roles.manage"), async (req: AuthenticatedRequest, res: Response) => { const targetUserId = Array.isArray(req.params.userId) ? req.params.userId[0] @@ -1382,7 +1431,7 @@ router.post( router.delete( "/users/:userId/roles/:roleId", authenticateJWT, - permissionManager.requireAdmin(), + permissionManager.requirePermission("admin.roles.manage"), async (req: AuthenticatedRequest, res: Response) => { const targetUserId = Array.isArray(req.params.userId) ? req.params.userId[0] @@ -1524,6 +1573,7 @@ router.get( router.post( "/snippet/:id/share", authenticateJWT, + permissionManager.requirePermission("snippets.share"), async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const snippetId = parseInt(id, 10); @@ -1639,6 +1689,131 @@ router.post( * tags: * - RBAC */ +/** + * @openapi + * /rbac/snippet-folder/share: + * post: + * summary: Share every snippet in a folder + * description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles. + * tags: + * - RBAC + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * folder: + * type: string + * targets: + * type: array + * items: + * type: object + * properties: + * type: + * type: string + * enum: [user, role] + * id: + * oneOf: + * - type: string + * - type: integer + * durationHours: + * type: number + * responses: + * 200: + * description: Folder shared. + * 404: + * description: A target was not found. + */ +router.post( + "/snippet-folder/share", + authenticateJWT, + permissionManager.requirePermission("snippets.share"), + async (req: AuthenticatedRequest, res: Response) => { + const userId = req.userId!; + const { folder, durationHours } = req.body ?? {}; + + if (!isNonEmptyString(folder)) { + return res.status(400).json({ error: "Folder name is required" }); + } + + const targets = parseShareTargets(req.body ?? {}); + if (!targets) { + return res.status(400).json({ + error: + "targets must be a non-empty array of { type: 'user'|'role', id } entries", + }); + } + + try { + const userRepository = createCurrentUserRepository(); + const roleRepository = createCurrentRoleRepository(); + for (const target of targets) { + const found = + target.type === "user" + ? await userRepository.findById(target.id as string) + : await roleRepository.findRoleById(target.id as number); + if (!found) { + return res.status(404).json({ + error: `Target ${target.type} not found`, + targetId: target.id, + }); + } + } + + const snippetsInFolder = + await createCurrentSnippetRepository().listOwnedSnippetsInFolder( + userId, + folder, + ); + const expiresAt = expiryFromDuration(durationHours); + const rbacAccessRepository = createCurrentRbacAccessRepository(); + + for (const snippet of snippetsInFolder) { + for (const target of targets) { + if (target.type === "user" && target.id === userId) continue; + await rbacAccessRepository.upsertSnippetAccess({ + snippetId: snippet.id, + grantedBy: userId, + expiresAt, + ...(target.type === "user" + ? { + targetType: "user" as const, + targetUserId: target.id as string, + } + : { + targetType: "role" as const, + targetRoleId: target.id as number, + }), + }); + } + } + + databaseLogger.success("Snippet folder shared successfully", { + operation: "rbac_snippet_folder_share", + userId, + folder, + snippetsShared: snippetsInFolder.length, + targets: targets.length, + }); + + res.json({ + success: true, + expiresAt, + snippetsShared: snippetsInFolder.length, + }); + } catch (error) { + databaseLogger.error("Failed to share snippet folder", error, { + operation: "share_snippet_folder", + folder, + userId, + }); + res.status(500).json({ error: "Failed to share snippet folder" }); + } + }, +); + router.delete( "/snippet/:id/access/:accessId", authenticateJWT, diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 3f0e7b1d..a2202eef 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { extractSnippetReorderUpdates } from "./snippets-reorder.js"; @@ -68,6 +69,7 @@ async function getAccessibleSnippet(snippetId: number, userId: string) { } const authManager = AuthManager.getInstance(); +const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); @@ -90,6 +92,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware(); router.get( "/folders", authenticateJWT, + permissionManager.requirePermission("snippets.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -144,6 +147,7 @@ router.get( router.post( "/folders", authenticateJWT, + permissionManager.requirePermission("snippets.create"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -226,6 +230,7 @@ router.post( router.put( "/folders/:name/metadata", authenticateJWT, + permissionManager.requirePermission("snippets.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -306,6 +311,7 @@ router.put( router.put( "/folders/rename", authenticateJWT, + permissionManager.requirePermission("snippets.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -382,6 +388,7 @@ router.put( router.delete( "/folders/:name", authenticateJWT, + permissionManager.requirePermission("snippets.delete"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -466,6 +473,7 @@ router.delete( router.put( "/reorder", authenticateJWT, + permissionManager.requirePermission("snippets.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -796,6 +804,7 @@ router.post( router.get( "/export", authenticateJWT, + permissionManager.requirePermission("snippets.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -872,6 +881,7 @@ router.get( router.post( "/bulk-import", authenticateJWT, + permissionManager.requirePermission("snippets.create"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -932,6 +942,7 @@ router.post( router.get( "/", authenticateJWT, + permissionManager.requirePermission("snippets.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1003,6 +1014,7 @@ router.get( router.get( "/:id", authenticateJWT, + permissionManager.requirePermission("snippets.view"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1073,6 +1085,7 @@ router.get( router.post( "/", authenticateJWT, + permissionManager.requirePermission("snippets.create"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1183,6 +1196,7 @@ router.post( router.put( "/:id", authenticateJWT, + permissionManager.requirePermission("snippets.edit"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1261,6 +1275,7 @@ router.put( router.delete( "/:id", authenticateJWT, + permissionManager.requirePermission("snippets.delete"), requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 404a44ce..47f8a2cc 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -7,6 +7,11 @@ import { setGlobalLogLevel, } from "../../utils/logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + AUDIT_FORWARD_TOKEN_SETTING, + AUDIT_FORWARD_URL_ENV, + AUDIT_FORWARD_URL_SETTING, +} from "../../utils/audit-forwarder.js"; import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js"; import { @@ -669,6 +674,125 @@ export function registerUserSettingsRoutes( * enabled: * type: boolean */ + /** + * @openapi + * /users/audit-forwarding: + * get: + * summary: Get audit log forwarding settings (admin only) + * tags: + * - Users + * responses: + * 200: + * description: Forwarding target. The token itself is never returned. + */ + router.get("/audit-forwarding", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + if (!(await getAdminActor(userId))) { + return res.status(403).json({ error: "Not authorized" }); + } + const settingsRepository = createCurrentSettingsRepository(); + const url = + (await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? ""; + const hasToken = !!(await settingsRepository.get( + AUDIT_FORWARD_TOKEN_SETTING, + )); + res.json({ + url, + hasToken, + envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(), + }); + } catch (err) { + authLogger.error("Failed to get audit forwarding settings", err); + res + .status(500) + .json({ error: "Failed to get audit forwarding settings" }); + } + }); + + /** + * @openapi + * /users/audit-forwarding: + * patch: + * summary: Update audit log forwarding settings (admin only) + * description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * token: + * type: string + * responses: + * 200: + * description: Setting updated. + * 403: + * description: Not authorized. + */ + router.patch("/audit-forwarding", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await getAdminActor(userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + const { url, token } = req.body ?? {}; + if ( + typeof url !== "string" || + (token !== undefined && typeof token !== "string") + ) { + return res.status(400).json({ error: "url must be a string" }); + } + const trimmedUrl = url.trim(); + if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) { + return res.status(400).json({ error: "url must be http(s)" }); + } + + const settingsRepository = createCurrentSettingsRepository(); + if (!trimmedUrl) { + await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING); + await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING); + } else { + await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl); + if (token !== undefined) { + if (token.trim()) { + await settingsRepository.set( + AUDIT_FORWARD_TOKEN_SETTING, + token.trim(), + ); + } else { + await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING); + } + } + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor.username ?? userId, + action: "update_audit_forwarding", + resourceType: "setting", + details: JSON.stringify({ enabled: !!trimmedUrl }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ url: trimmedUrl, hasToken: !!token?.trim() }); + } catch (err) { + authLogger.error("Failed to update audit forwarding settings", err); + res + .status(500) + .json({ error: "Failed to update audit forwarding settings" }); + } + }); + router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => { try { res.json({ diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index 62a2a78a..46acbe97 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -5,6 +5,8 @@ import { withRecordingSettings } from "./recording-settings.js"; import { guacLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-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 crypto from "crypto"; import path from "path"; @@ -332,30 +334,34 @@ router.post( host.telnetPassword = null; try { - const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); - const secret = - await SharedHostSecretsManager.getInstance().getSecretForUser( - hostId, - userId, - connectionType as "rdp" | "vnc" | "telnet", - ); - if (secret) { + const resolution = await resolveRecipientSharedHostAuthentication( + host, + hostId, + userId, + connectionType as AuthOverrideProtocol, + ); + const auth = + resolution.source === "personal-override" + ? { ...resolution.credential, domain: null } + : resolution.source === "owner-shared" + ? resolution.secret + : null; + if (auth) { if (connectionType === "rdp") { - host.rdpUser = secret.username ?? null; - host.rdpPassword = secret.password ?? null; - if (secret.domain) host.rdpDomain = secret.domain; + host.rdpUser = auth.username ?? null; + host.rdpPassword = auth.password ?? null; + if (auth.domain) host.rdpDomain = auth.domain; } else if (connectionType === "vnc") { - host.vncUser = secret.username ?? null; - host.vncPassword = secret.password ?? null; + host.vncUser = auth.username ?? null; + host.vncPassword = auth.password ?? null; } else if (connectionType === "telnet") { - host.telnetUser = secret.username ?? null; - host.telnetPassword = secret.password ?? null; + host.telnetUser = auth.username ?? null; + host.telnetPassword = auth.password ?? null; } } } catch (e) { - guacLogger.warn("Failed to resolve shared host secret", { - operation: "guac_shared_secret_resolve", + guacLogger.warn("Failed to resolve shared host auth", { + operation: "guac_shared_auth_resolve", hostId, protocol: connectionType, error: getErrorMessage(e, "Unknown"), diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index a6cbef64..74cabc67 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from "../../utils/error-message.js"; +import { getAuditUsername } from "../../utils/audit-logger.js"; import { parseWsMessage, asObject, @@ -1274,6 +1275,7 @@ wss.on("connection", async (ws: WebSocket, req) => { userId, permissionLevel: share.permissionLevel as "read-write" | "read-only", + displayName: await getAuditUsername(userId), tabInstanceId: joinData.tabInstanceId, shareId: share.id, }, diff --git a/src/backend/hosts/terminal/session-manager.ts b/src/backend/hosts/terminal/session-manager.ts index 98c583b6..71befc2f 100644 --- a/src/backend/hosts/terminal/session-manager.ts +++ b/src/backend/hosts/terminal/session-manager.ts @@ -26,6 +26,7 @@ export interface SessionParticipant { userId: string | null; // null for anonymous link guests permissionLevel: "read-write" | "read-only"; isOwner: boolean; + displayName?: string; guestLabel?: string; tabInstanceId?: string; joinedViaShareId?: string; @@ -382,6 +383,7 @@ class TerminalSessionManager { }); session.attachedTabInstanceId = tabInstanceId; session.lastDetachedAt = null; + this.broadcastParticipants(sessionId); sshLogger.info("WebSocket attached to session", { operation: "session_attach", @@ -403,6 +405,7 @@ class TerminalSessionManager { opts: { userId: string | null; permissionLevel: "read-write" | "read-only"; + displayName?: string; guestLabel?: string; tabInstanceId?: string; shareId?: string; @@ -417,10 +420,12 @@ class TerminalSessionManager { userId: opts.userId, permissionLevel: opts.permissionLevel, isOwner: false, + displayName: opts.displayName, guestLabel: opts.guestLabel, tabInstanceId: opts.tabInstanceId, joinedViaShareId: opts.shareId, }); + this.broadcastParticipants(sessionId); sshLogger.info("Participant joined shared session", { operation: "session_join_participant", @@ -433,6 +438,25 @@ class TerminalSessionManager { 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. */ broadcast(sessionId: string, message: object): void { const session = this.sessions.get(sessionId); @@ -469,6 +493,7 @@ class TerminalSessionManager { for (const [id, participant] of session.participants.entries()) { if (participant.ws === ws && !participant.isOwner) { session.participants.delete(id); + this.broadcastParticipants(sessionId); sshLogger.info("Participant left shared session", { operation: "session_leave_participant", sessionId, diff --git a/src/backend/tests/ai/tool-catalog.test.ts b/src/backend/tests/ai/tool-catalog.test.ts index 898302dd..91285c69 100644 --- a/src/backend/tests/ai/tool-catalog.test.ts +++ b/src/backend/tests/ai/tool-catalog.test.ts @@ -10,9 +10,8 @@ import { /** * The security regression test for the whole feature. * - * PermissionManager.requirePermission is defined but mounted on zero routes, - * so RBAC strings do not gate anything at the route layer. "The assistant - * cannot reach credentials or user administration" is therefore a property of + * Tools run in-process and never pass through the RBAC-gated routers, so "the + * assistant cannot reach credentials or user administration" is a property of * this catalog, and nothing else. If a future change adds a tool that touches a * forbidden domain, this test is what catches it. */ diff --git a/src/backend/tests/database/routes/automations.test.ts b/src/backend/tests/database/routes/automations.test.ts index 90ab16c5..635e7cc6 100644 --- a/src/backend/tests/database/routes/automations.test.ts +++ b/src/backend/tests/database/routes/automations.test.ts @@ -48,6 +48,16 @@ vi.mock("../../../automations/engine.js", () => ({ 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", () => ({ AuthManager: { getInstance: () => ({ diff --git a/src/backend/tests/database/routes/credential-bulk-routes.test.ts b/src/backend/tests/database/routes/credential-bulk-routes.test.ts new file mode 100644 index 00000000..68164e8d --- /dev/null +++ b/src/backend/tests/database/routes/credential-bulk-routes.test.ts @@ -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(); + }); +}); diff --git a/src/backend/tests/database/routes/rbac-host-auth-override.test.ts b/src/backend/tests/database/routes/rbac-host-auth-override.test.ts index 232bf0d6..347b3bb2 100644 --- a/src/backend/tests/database/routes/rbac-host-auth-override.test.ts +++ b/src/backend/tests/database/routes/rbac-host-auth-override.test.ts @@ -48,6 +48,14 @@ vi.mock("../../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ canAccessHost: async () => state.access, + requirePermission: + () => + ( + _req: express.Request, + _res: express.Response, + next: express.NextFunction, + ) => + next(), requireAdmin: () => ( @@ -255,14 +263,10 @@ describe("shared host authentication override routes", () => { expect(unauthenticatedResponse.status).toBe(401); }); - it("rejects recognized but unsupported protocols and invalid protocol names", async () => { - const unsupportedResponse = await invoke("get", {}, "rdp"); - expect(unsupportedResponse).toEqual({ - status: 400, - body: { - error: "RDP authentication overrides are not supported yet", - }, - }); + it("serves every real protocol and rejects invalid protocol names", async () => { + const rdpResponse = await invoke("get", {}, "rdp"); + expect(rdpResponse.status).toBe(200); + expect(rdpResponse.body.protocol).toBe("rdp"); expect(state.writes).toEqual([]); const invalidResponse = await invoke("get", {}, "smtp"); diff --git a/src/backend/tests/database/routes/rbac-route-coverage.test.ts b/src/backend/tests/database/routes/rbac-route-coverage.test.ts new file mode 100644 index 00000000..c12df369 --- /dev/null +++ b/src/backend/tests/database/routes/rbac-route-coverage.test.ts @@ -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]); + }); +}); diff --git a/src/backend/tests/hosts/terminal/session-manager.test.ts b/src/backend/tests/hosts/terminal/session-manager.test.ts index 5b94ed1e..f231f2b7 100644 --- a/src/backend/tests/hosts/terminal/session-manager.test.ts +++ b/src/backend/tests/hosts/terminal/session-manager.test.ts @@ -206,7 +206,14 @@ describe("TerminalSessionManager - multiplayer participants", () => { ownerWs, ); 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).mock.calls[0][0] as string, + ); + expect(announced.type).toBe("participants"); + expect(announced.participants).toHaveLength(2); sessionManager.destroySession(id); }); diff --git a/src/backend/tests/utils/audit-forwarder.test.ts b/src/backend/tests/utils/audit-forwarder.test.ts index fdfa6073..eb1c928c 100644 --- a/src/backend/tests/utils/audit-forwarder.test.ts +++ b/src/backend/tests/utils/audit-forwarder.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const safeFetch = vi.hoisted(() => 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", () => ({ safeOutboundFetch: safeFetch, })); diff --git a/src/backend/utils/audit-forwarder.ts b/src/backend/utils/audit-forwarder.ts index 8b906243..e757193c 100644 --- a/src/backend/utils/audit-forwarder.ts +++ b/src/backend/utils/audit-forwarder.ts @@ -1,9 +1,24 @@ import { safeOutboundFetch } from "./safe-outbound-fetch.js"; +import { getCurrentSettingValue } from "../database/repositories/factory.js"; import { databaseLogger } from "./logger.js"; import type { AuditLogParams } from "./audit-logger.js"; export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL"; 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 @@ -23,9 +38,14 @@ export interface AuditForwardTarget { export function auditForwardTarget( env: NodeJS.ProcessEnv = process.env, ): 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; - 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 }; } diff --git a/src/backend/utils/permission-catalog.ts b/src/backend/utils/permission-catalog.ts index 8fa77745..ce62dac7 100644 --- a/src/backend/utils/permission-catalog.ts +++ b/src/backend/utils/permission-catalog.ts @@ -72,3 +72,22 @@ const VALID_PERMISSIONS = new Set( export function isValidPermission(permission: string): boolean { 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; diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index 11140bd7..116919f8 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -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( diff --git a/src/backend/utils/shared-host-auth-resolver.ts b/src/backend/utils/shared-host-auth-resolver.ts index fd179c4e..61106329 100644 --- a/src/backend/utils/shared-host-auth-resolver.ts +++ b/src/backend/utils/shared-host-auth-resolver.ts @@ -33,29 +33,30 @@ export function requiresPersonalHostAuthentication( host: Pick, protocol: AuthOverrideProtocol, ): boolean { - switch (protocol) { - case "ssh": - return ( - !!host.credentialId || - host.authType === "password" || - host.authType === "key" || - host.authType === "credential" || - host.authType === "agent" - ); - // These cases document the extension point without enabling behavior. - case "rdp": - case "vnc": - case "telnet": - throw new Error( - `${protocol.toUpperCase()} shared-host authentication is not implemented`, - ); - } + // Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only + // SSH, which sits behind shareSshAuth, can leave a recipient without auth. + if (protocol !== "ssh") return false; + return ( + !!host.credentialId || + host.authType === "password" || + host.authType === "key" || + host.authType === "credential" || + host.authType === "agent" + ); +} + +/** Whether the owner's auth for this protocol is available to recipients. */ +export function isOwnerAuthShared( + host: Pick, + protocol: AuthOverrideProtocol, +): boolean { + return protocol === "ssh" ? !!host.shareSshAuth : true; } /** * Applies the shared-host authentication precedence independently from any - * transport: recipient override, explicitly shared owner auth, secretless - * auth, then "required". Only SSH is currently enabled by callers. + * transport: recipient override, shared owner auth, secretless auth, then + * "required". */ export async function resolveRecipientSharedHostAuthentication( host: HostResolutionHostRecord, @@ -96,8 +97,8 @@ export async function resolveRecipientSharedHostAuthentication( } } - if (protocol === "ssh" && host.shareSshAuth) { - if (host.authType === "agent") { + if (isOwnerAuthShared(host, protocol)) { + if (protocol === "ssh" && host.authType === "agent") { return { source: "owner-shared", authType: "agent", diff --git a/src/backend/utils/system-roles.ts b/src/backend/utils/system-roles.ts new file mode 100644 index 00000000..e4fd2060 --- /dev/null +++ b/src/backend/utils/system-roles.ts @@ -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 { + 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, + }); + } + } +} diff --git a/src/types/auth-protocols.ts b/src/types/auth-protocols.ts index 664f2006..ffb044d2 100644 --- a/src/types/auth-protocols.ts +++ b/src/types/auth-protocols.ts @@ -2,11 +2,8 @@ export const AUTH_OVERRIDE_PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const; export type AuthOverrideProtocol = (typeof AUTH_OVERRIDE_PROTOCOLS)[number]; -// Storage and API contracts are protocol-aware, but SSH is intentionally the -// only protocol whose recipient override flow is enabled today. -export const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS = [ - "ssh", -] as const satisfies readonly AuthOverrideProtocol[]; +export const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS = + AUTH_OVERRIDE_PROTOCOLS satisfies readonly AuthOverrideProtocol[]; export const AUTH_PROTOCOL_METADATA = { ssh: { diff --git a/src/ui/api/audit-log-api.ts b/src/ui/api/audit-log-api.ts index 21fca344..786ddb86 100644 --- a/src/ui/api/audit-log-api.ts +++ b/src/ui/api/audit-log-api.ts @@ -105,3 +105,33 @@ export async function getAuditLogActions(): Promise<{ actions: string[] }> { handleApiError(error, "fetch audit log actions"); } } + +export interface AuditForwardingSettings { + url: string; + hasToken: boolean; + envConfigured: boolean; +} + +export async function getAuditForwarding(): Promise { + 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"); + } +} diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 5f78b3a4..388ad726 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -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 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( snippetId: number, ): Promise<{ accessList: AccessRecord[] }> { diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx index 8827b5ca..56b6d7c2 100644 --- a/src/ui/features/session-sharing/SharedSessionView.tsx +++ b/src/ui/features/session-sharing/SharedSessionView.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; -import { AlertCircle, Eye } from "lucide-react"; +import { AlertCircle, Eye, Users } from "lucide-react"; import { resolveShareLink, type ResolvedShareLink, @@ -44,6 +44,41 @@ async function resolveTerminalWsBaseUrl(): Promise { 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 ( +
+ + {participants.length} +
+ ); +} + function ReadOnlyBadge({ label }: { label: string }) { return (
(null); + const [participants, setParticipants] = useState( + [], + ); const wsRef = useRef(null); const pingIntervalRef = useRef | null>(null); @@ -140,6 +178,11 @@ function GuestTerminalView({ case "data": if (typeof msg.data === "string") terminal.write(msg.data); break; + case "participants": + if (Array.isArray(msg.participants)) { + setParticipants(msg.participants as SessionParticipantInfo[]); + } + break; case "sessionExpired": case "sessionTerminatedByOwner": case "session_ended": @@ -176,6 +219,10 @@ function GuestTerminalView({ return (
+ {share.permissionLevel === "read-only" && ( )} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index b7a0406e..6b115c54 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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.", "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", + "authOverrideActionProtocol": "Set personal {{protocol}} 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}}.", "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", @@ -1810,7 +1812,8 @@ "linkInvalid": "This share link is invalid, expired, or has been revoked", "rateLimited": "Too many attempts, please try again shortly", "sessionEnded": "This session has ended", - "readOnlyBadge": "View only" + "readOnlyBadge": "View only", + "ownerLabel": "Host owner" }, "modalTitle": "Share session", "shareButton": "Share", @@ -3291,6 +3294,13 @@ "analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.", "updateAnalyticsFailed": "Failed to update analytics setting", "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.", "updateSessionSharingFailed": "Failed to update session sharing setting", "sessionTimeout": "Session Timeout", diff --git a/src/ui/sidebar/AdminAuditLogSection.tsx b/src/ui/sidebar/AdminAuditLogSection.tsx index 85fa2900..b1a01655 100644 --- a/src/ui/sidebar/AdminAuditLogSection.tsx +++ b/src/ui/sidebar/AdminAuditLogSection.tsx @@ -13,9 +13,12 @@ import { AccordionSection } from "./AdminSettingsShared"; import { getAuditLogs, getAuditLogActions, + getAuditForwarding, + updateAuditForwarding, type AuditLog, type AuditLogFilters, } from "@/api/audit-log-api"; +import { toast } from "sonner"; import type { AdminUser } from "./AdminManagementSections"; const RESOURCE_TYPES = [ @@ -43,6 +46,39 @@ export function AdminAuditLogSection({ const { t } = useTranslation(); const [logs, setLogs] = useState([]); + 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 [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -170,6 +206,45 @@ export function AdminAuditLogSection({ onToggle={onToggle} >
+ {/* SIEM forwarding */} +
+ + {t("admin.auditForwardingTitle")} + +

+ {t("admin.auditForwardingDesc")} + {forwardEnvConfigured && ( + <> {t("admin.auditForwardingEnvNotice")} + )} +

+ setForwardUrl(e.target.value)} + className="text-[10px] h-7" + /> + setForwardToken(e.target.value)} + className="text-[10px] h-7" + /> + +
+ {/* Filters */}
diff --git a/src/ui/sidebar/HostAuthOverrideModal.tsx b/src/ui/sidebar/HostAuthOverrideModal.tsx index 6256168a..18ed47be 100644 --- a/src/ui/sidebar/HostAuthOverrideModal.tsx +++ b/src/ui/sidebar/HostAuthOverrideModal.tsx @@ -16,7 +16,10 @@ import { setHostAuthOverride, } from "@/main-axios"; 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"; export function HostAuthOverrideModal({ @@ -106,7 +109,11 @@ export function HostAuthOverrideModal({ - {t("hosts.sharing.authOverrideTitle")} + + {t("hosts.sharing.authOverrideTitleProtocol", { + protocol: AUTH_PROTOCOL_METADATA[protocol].label, + })} + {t( ownerAuthShared diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index caaed603..1686d118 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -57,6 +57,11 @@ import { canShareHost, } from "@/sidebar/host-permissions"; import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal"; +import { + AUTH_OVERRIDE_PROTOCOLS, + AUTH_PROTOCOL_METADATA, + type AuthOverrideProtocol, +} from "@/types/auth-protocols"; import { useStatusColorScheme, getStatusClasses, @@ -340,8 +345,11 @@ export function HostItem({ !alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly); const showPasswordCopy = !host.isShared && canCopyHostPassword(host); const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host); - const canOverrideAuth = canOverrideHostAuth(host, "ssh"); - const [authOverrideOpen, setAuthOverrideOpen] = useState(false); + const authOverrideProtocols = AUTH_OVERRIDE_PROTOCOLS.filter((protocol) => + canOverrideHostAuth(host, protocol), + ); + const [authOverrideProtocol, setAuthOverrideProtocol] = + useState(null); const [parentDragOver, setParentDragOver] = useState(false); const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false); const [contextMenuPosition, setContextMenuPosition] = useState<{ @@ -736,17 +744,20 @@ export function HostItem({ {t("hosts.copyAddress")} - {canOverrideAuth && ( + {authOverrideProtocols.map((protocol) => ( { e.stopPropagation(); - setAuthOverrideOpen(true); + setAuthOverrideProtocol(protocol); }} > - {t("hosts.sharing.authOverrideAction")} + {t("hosts.sharing.authOverrideActionProtocol", { + protocol: AUTH_PROTOCOL_METADATA[protocol].label, + })} - )} + ))} {showPasswordCopy && ( handleCopyPassword(e, "password")} @@ -1320,12 +1331,14 @@ export function HostItem({
- {canOverrideAuth && ( + {authOverrideProtocol && ( { + if (!open) setAuthOverrideProtocol(null); + }} host={host} - protocol="ssh" + protocol={authOverrideProtocol} /> )}
diff --git a/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx b/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx index 244c3074..de275172 100644 --- a/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx +++ b/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx @@ -181,7 +181,7 @@ describe("HostAuthOverrideModal", () => { }); 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 [ "connect", "view", @@ -200,6 +200,7 @@ describe("canOverrideHostAuth", () => { ).toBe(false); expect( canOverrideHostAuth({ ...host, enableRdp: true } as Host, "rdp"), - ).toBe(false); + ).toBe(true); + expect(canOverrideHostAuth(host, "rdp")).toBe(false); }); });