Files
Termix/src/backend/database/routes/rbac.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

2178 lines
62 KiB
TypeScript

import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
getRequestMeta,
getAuditUsername,
logAudit,
} from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
assertHostsUnlocked,
hasHostLocks,
requireUnlockedHost,
sendHostLocked,
} from "../../hosts/locks.js";
import {
SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError,
} from "../../utils/shared-host-auth-override-service.js";
import {
PermissionManager,
SHARE_PERMISSION_LEVELS,
type SharePermissionLevel,
} from "../../utils/permission-manager.js";
import {
getPermissionCatalog,
isValidPermission,
} from "../../utils/permission-catalog.js";
import {
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentCredentialRepository,
createCurrentCredentialAccessRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const sharedHostAuthOverrideService =
SharedHostAuthOverrideService.getInstance();
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function isSharePermissionLevel(
value: unknown,
): value is SharePermissionLevel {
return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel);
}
export function expiryFromDuration(durationHours: unknown): string | null {
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
const expiryDate = new Date();
expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000);
return expiryDate.toISOString();
}
return null;
}
// Sharing is controlled by the owner or any recipient holding "manage".
async function canManageHostSharing(
userId: string,
hostId: number,
): Promise<{ allowed: boolean; isOwner: boolean }> {
const access = await permissionManager.canAccessHost(
userId,
hostId,
"manage",
);
return { allowed: access.hasAccess, isOwner: access.isOwner };
}
/**
* Drops shares handed out by someone who can no longer manage sharing on the
* host, then repeats so shares they passed on further go too.
*/
export async function pruneHostGrants(hostId: number): Promise<number> {
const repository = createCurrentRbacAccessRepository();
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listHostAccess(hostId)) {
if (!grant.grantedBy || grant.grantedBy === ownerId) continue;
if ((await canManageHostSharing(grant.grantedBy, hostId)).allowed) {
continue;
}
await repository.revokeHostAccess(grant.id, hostId);
removed++;
changed = true;
}
}
return removed;
}
/** pruneHostGrants for credential shares. */
export async function pruneCredentialGrants(
credentialId: number,
): Promise<number> {
const repository = createCurrentCredentialAccessRepository();
const owner =
await createCurrentCredentialRepository().findById(credentialId);
if (!owner) return 0;
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listForCredential(credentialId)) {
if (!grant.grantedBy || grant.grantedBy === owner.userId) continue;
if (
(await canManageCredentialSharing(grant.grantedBy, credentialId))
.allowed
) {
continue;
}
await repository.revoke(grant.id, credentialId);
removed++;
changed = true;
}
}
return removed;
}
/** Prunes everything these users shared, after they lost a role. */
export async function pruneGrantsBy(userIds: string[]): Promise<void> {
for (const userId of userIds) {
permissionManager.invalidateUserPermissionCache(userId);
for (const hostId of await createCurrentRbacAccessRepository().listHostIdsGrantedBy(
userId,
)) {
await pruneHostGrants(hostId);
}
for (const credentialId of await createCurrentCredentialAccessRepository().listCredentialIdsGrantedBy(
userId,
)) {
await pruneCredentialGrants(credentialId);
}
}
}
export interface ShareTarget {
type: "user" | "role";
id: string | number;
}
export function parseShareTargets(
body: Record<string, unknown>,
): ShareTarget[] | null {
const rawTargets = body.targets;
if (!Array.isArray(rawTargets) || rawTargets.length === 0) return null;
const targets: ShareTarget[] = [];
for (const raw of rawTargets) {
if (!raw || typeof raw !== "object") return null;
const { type, id } = raw as { type?: unknown; id?: unknown };
if (type === "user" && isNonEmptyString(id)) {
targets.push({ type: "user", id });
} else if (
type === "role" &&
typeof id === "number" &&
Number.isInteger(id)
) {
targets.push({ type: "role", id });
} else {
return null;
}
}
return targets;
}
/**
* @openapi
* /rbac/host/{id}/share:
* post:
* summary: Share a host
* description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). SSH authentication remains private to the owner; recipients may select one of their own saved SSH credentials.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [targets]
* properties:
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* permissionLevel:
* type: string
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* responses:
* 200:
* description: Host shared successfully.
* 400:
* description: Invalid request body.
* 403:
* description: Caller may not share this host.
* 404:
* description: Target user or role not found.
* 500:
* description: Failed to share host.
*/
router.post(
"/host/:id/share",
authenticateJWT,
permissionManager.requirePermission("hosts.share"),
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const hostId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
try {
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",
});
}
const { durationHours, permissionLevel = "connect" } = req.body;
if (!isSharePermissionLevel(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: SHARE_PERMISSION_LEVELS,
});
}
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
databaseLogger.warn("Permission denied", {
operation: "rbac_permission_denied",
userId,
resource: "host",
resourceId: hostId,
action: "share",
});
return res.status(403).json({ error: "You may not share this host" });
}
const host =
await createCurrentHostResolutionRepository().findHostUpdateState(
hostId,
);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const ownerId = host.userId;
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
if (target.type === "user") {
if (target.id === ownerId) {
return res
.status(400)
.json({ error: "Cannot share a host with its owner" });
}
const targetUser = await userRepository.findById(target.id as string);
if (!targetUser) {
return res.status(404).json({
error: "Target user not found",
targetId: target.id,
});
}
} else {
const targetRole = await roleRepository.findRoleById(
target.id as number,
);
if (!targetRole) {
return res.status(404).json({
error: "Target role not found",
targetId: target.id,
});
}
}
}
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
const results: Array<{
type: "user" | "role";
id: string | number;
accessId: number;
created: boolean;
}> = [];
for (const target of targets) {
const accessGrant = await rbacAccessRepository.upsertHostAccess({
hostId,
grantedBy: userId,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
accessGrant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
accessGrant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch (snapshotError) {
databaseLogger.warn("Share created but secret snapshot failed", {
operation: "rbac_host_share_snapshot_failed",
hostId,
accessId: accessGrant.id,
error: getErrorMessage(snapshotError),
});
}
results.push({
type: target.type,
id: target.id,
accessId: accessGrant.id,
created: accessGrant.created,
});
}
await pruneHostGrants(hostId);
databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success",
userId,
hostId,
targets: results.length,
permissionLevel,
});
res.json({
success: true,
message: "Host shared successfully",
permissionLevel,
expiresAt,
results,
});
} catch (error) {
databaseLogger.error("Failed to share host", error, {
operation: "share_host",
hostId,
userId,
});
res.status(500).json({ error: "Failed to share host" });
}
},
);
/**
* @openapi
* /rbac/folder/share:
* post:
* summary: Share all hosts in a folder
* description: Shares every host within a folder (and its subfolders) with one or more users and/or roles at a permission level. Only hosts owned by the caller are shared; skips hosts the caller may not share.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [folder, targets]
* properties:
* folder:
* type: string
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* permissionLevel:
* type: string
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* responses:
* 200:
* description: Folder shared successfully.
* 400:
* description: Invalid request body.
* 500:
* description: Failed to share folder.
*/
router.post(
"/folder/share",
authenticateJWT,
permissionManager.requirePermission("hosts.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder } = req.body ?? {};
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
if (hasHostLocks()) {
const inFolder =
await createCurrentHostFolderRepository().listHostsInFolder(
userId,
folder,
);
assertHostsUnlocked(inFolder.map((host) => host.id));
}
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",
});
}
const { durationHours, permissionLevel = "connect" } = req.body;
if (!isSharePermissionLevel(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: SHARE_PERMISSION_LEVELS,
});
}
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
if (target.type === "user") {
const targetUser = await userRepository.findById(target.id as string);
if (!targetUser) {
return res.status(404).json({
error: "Target user not found",
targetId: target.id,
});
}
} else {
const targetRole = await roleRepository.findRoleById(
target.id as number,
);
if (!targetRole) {
return res.status(404).json({
error: "Target role not found",
targetId: target.id,
});
}
}
}
const hostsInFolder =
await createCurrentHostFolderRepository().listHostsInFolder(
userId,
folder,
);
const expiresAt = expiryFromDuration(durationHours);
// Remember the share on the folder itself so hosts added later inherit it.
const folderAccessRepository = createCurrentFolderAccessRepository();
for (const target of targets) {
await folderAccessRepository.upsert({
ownerUserId: userId,
folder,
grantedBy: userId,
permissionLevel,
expiresAt,
target:
target.type === "user"
? { targetType: "user", targetUserId: target.id as string }
: { targetType: "role", targetRoleId: target.id as number },
});
}
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
const hostResults: Array<{
hostId: number;
shared: boolean;
reason?: string;
}> = [];
for (const host of hostsInFolder) {
if (targets.some((t) => t.type === "user" && t.id === host.userId)) {
hostResults.push({
hostId: host.id,
shared: false,
reason: "owner",
});
continue;
}
const sharing = await canManageHostSharing(userId, host.id);
if (!sharing.allowed) {
hostResults.push({
hostId: host.id,
shared: false,
reason: "forbidden",
});
continue;
}
for (const target of targets) {
const accessGrant = await rbacAccessRepository.upsertHostAccess({
hostId: host.id,
grantedBy: userId,
permissionLevel,
expiresAt,
...(target.type === "user"
? {
targetType: "user" as const,
targetUserId: target.id as string,
}
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
accessGrant.id,
host.id,
target.id as string,
host.userId,
);
} else {
await secretsManager.snapshotForRole(
accessGrant.id,
host.id,
target.id as number,
host.userId,
);
}
} catch (snapshotError) {
databaseLogger.warn("Share created but secret snapshot failed", {
operation: "rbac_folder_share_snapshot_failed",
hostId: host.id,
accessId: accessGrant.id,
error: getErrorMessage(snapshotError),
});
}
}
hostResults.push({ hostId: host.id, shared: true });
}
const sharedCount = hostResults.filter((r) => r.shared).length;
databaseLogger.success("Folder shared successfully", {
operation: "rbac_folder_share_success",
userId,
folder,
hostsShared: sharedCount,
targets: targets.length,
permissionLevel,
});
res.json({
success: true,
message: "Folder shared successfully",
permissionLevel,
expiresAt,
hostsShared: sharedCount,
hostsTotal: hostsInFolder.length,
hostResults,
});
} catch (error) {
if (sendHostLocked(res, error)) return;
databaseLogger.error("Failed to share folder", error, {
operation: "share_folder",
folder,
userId,
});
res.status(500).json({ error: "Failed to share folder" });
}
},
);
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
* patch:
* summary: Update a host access grant
* description: Changes the permission level and/or expiry of an existing host access grant. Allowed for the host owner or recipients holding the manage level.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: path
* name: accessId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* permissionLevel:
* type: string
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* nullable: true
* description: Hours from now until the grant expires; null clears the expiry.
* responses:
* 200:
* description: Grant updated successfully.
* 400:
* description: Invalid request.
* 403:
* description: Caller may not manage sharing on this host.
* 404:
* description: Grant not found.
* 500:
* description: Failed to update grant.
*/
/**
* @openapi
* /rbac/folder/access:
* get:
* summary: Standing shares on one of your folders (inherited by hosts added later)
* tags:
* - RBAC
* parameters:
* - in: query
* name: folder
* required: true
* schema:
* type: string
*/
router.get(
"/folder/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const folder = String(req.query.folder ?? "");
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "folder is required" });
}
try {
res.json({
rules: await createCurrentFolderAccessRepository().listForFolder(
req.userId!,
folder,
),
});
} catch (error) {
databaseLogger.error("Failed to list folder access rules", error, {
operation: "list_folder_access",
});
res.status(500).json({ error: "Failed to list folder access" });
}
},
);
/**
* @openapi
* /rbac/folder/access/{id}:
* delete:
* summary: Stop a folder share from applying to hosts added later
* description: Grants already fanned out to hosts stay; revoke those per host.
* tags:
* - RBAC
*/
router.delete(
"/folder/access/:id",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const ruleId = parseInt(String(req.params.id), 10);
if (isNaN(ruleId)) {
return res.status(400).json({ error: "Invalid rule ID" });
}
try {
const repository = createCurrentFolderAccessRepository();
if (!(await repository.findById(ruleId, req.userId!))) {
return res.status(404).json({ error: "Rule not found" });
}
await repository.deleteById(ruleId, req.userId!);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to delete folder access rule", error, {
operation: "delete_folder_access",
ruleId,
});
res.status(500).json({ error: "Failed to delete folder access" });
}
},
);
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
* patch:
* summary: Change a host access rule
* description: Updates the permission level or the expiry of one share. Requires the right to manage sharing on the host.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: path
* name: accessId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* permissionLevel:
* type: string
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* nullable: true
* description: Hours from now until the share expires. Null removes the expiry.
* responses:
* 200:
* description: The updated rule.
* 400:
* description: Invalid id or nothing to change.
* 403:
* description: Not allowed to manage sharing on this host.
* 404:
* description: No such rule.
*/
router.patch(
"/host/:id/access/:accessId",
authenticateJWT,
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const hostId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(hostId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
return res
.status(403)
.json({ error: "You may not manage sharing on this host" });
}
const { permissionLevel, durationHours } = req.body ?? {};
if (permissionLevel === undefined && durationHours === undefined) {
return res.status(400).json({
error: "At least one of permissionLevel or durationHours is required",
});
}
if (
permissionLevel !== undefined &&
!isSharePermissionLevel(permissionLevel)
) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: SHARE_PERMISSION_LEVELS,
});
}
const rbacAccessRepository = createCurrentRbacAccessRepository();
const grant = await rbacAccessRepository.findHostAccessById(
accessId,
hostId,
);
if (!grant) {
return res.status(404).json({ error: "Access grant not found" });
}
const update: { permissionLevel?: string; expiresAt?: string | null } =
{};
if (permissionLevel !== undefined) {
update.permissionLevel = permissionLevel;
}
if (durationHours !== undefined) {
update.expiresAt =
durationHours === null ? null : expiryFromDuration(durationHours);
}
await rbacAccessRepository.updateHostAccessGrant(
accessId,
hostId,
update,
);
await pruneHostGrants(hostId);
databaseLogger.info("Host access grant updated", {
operation: "rbac_host_access_update",
userId,
hostId,
accessId,
permissionLevel,
});
res.json({
success: true,
message: "Access updated",
expiresAt: update.expiresAt ?? grant.expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to update host access", error, {
operation: "update_host_access",
hostId,
accessId,
userId,
});
res.status(500).json({ error: "Failed to update access" });
}
},
);
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
* delete:
* summary: Revoke host access
* description: Revokes a user's or role's access to a host.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: path
* name: accessId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Access revoked successfully.
* 400:
* description: Invalid ID.
* 403:
* description: Caller may not manage sharing on this host.
* 500:
* description: Failed to revoke access.
*/
router.delete(
"/host/:id/access/:accessId",
authenticateJWT,
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const hostId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(hostId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
return res
.status(403)
.json({ error: "You may not manage sharing on this host" });
}
await createCurrentRbacAccessRepository().revokeHostAccess(
accessId,
hostId,
);
await pruneHostGrants(hostId);
databaseLogger.info("Permission revoked", {
operation: "rbac_permission_revoke",
adminId: userId,
hostId,
accessId,
});
res.json({ success: true, message: "Access revoked" });
} catch (error) {
databaseLogger.error("Failed to revoke host access", error, {
operation: "revoke_host_access",
hostId,
accessId,
userId,
});
res.status(500).json({ error: "Failed to revoke access" });
}
},
);
/**
* @openapi
* /rbac/host/{id}/access:
* get:
* summary: Get host access list
* description: Retrieves the list of users and roles that have access to a host.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The access list for the host, including each grant's permission level.
* 400:
* description: Invalid host ID.
* 403:
* description: Caller may not manage sharing on this host.
* 500:
* description: Failed to get access list.
*/
router.get(
"/host/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const hostId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
try {
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
return res
.status(403)
.json({ error: "You may not manage sharing on this host" });
}
const accessList =
await createCurrentRbacAccessRepository().listHostAccess(hostId);
res.json({ accessList, isOwner: sharing.isOwner });
} catch (error) {
databaseLogger.error("Failed to get host access list", error, {
operation: "get_host_access_list",
hostId,
userId,
});
res.status(500).json({ error: "Failed to get access list" });
}
},
);
/**
* @openapi
* /rbac/shared-hosts:
* get:
* summary: Get shared hosts
* description: Retrieves the list of hosts that have been shared with the authenticated user.
* tags:
* - RBAC
* responses:
* 200:
* description: A list of shared hosts.
* 500:
* description: Failed to get shared hosts.
*/
router.get(
"/shared-hosts",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listSharedHosts(
userId,
roleIds,
);
res.json({ sharedHosts });
} catch (error) {
databaseLogger.error("Failed to get shared hosts", error, {
operation: "get_shared_hosts",
userId,
});
res.status(500).json({ error: "Failed to get shared hosts" });
}
},
);
/**
* @openapi
* /rbac/roles:
* get:
* summary: Get all roles
* description: Retrieves a list of all roles.
* tags:
* - RBAC
* responses:
* 200:
* description: A list of roles.
* 500:
* description: Failed to get roles.
*/
router.get(
"/roles",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
try {
const rolesList = (await createCurrentRoleRepository().listRoles()).map(
({
id,
name,
displayName,
description,
isSystem,
permissions,
createdAt,
updatedAt,
}) => {
let parsedPermissions: string[] = [];
try {
parsedPermissions = permissions
? (JSON.parse(permissions) as string[])
: [];
} catch {
parsedPermissions = [];
}
return {
id,
name,
displayName,
description,
isSystem,
permissions: parsedPermissions,
createdAt,
updatedAt,
};
},
);
res.json({ roles: rolesList });
} catch (error) {
databaseLogger.error("Failed to get roles", error, {
operation: "get_roles",
});
res.status(500).json({ error: "Failed to get roles" });
}
},
);
/**
* @openapi
* /rbac/roles:
* post:
* summary: Create a new role
* description: Creates a new role.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* displayName:
* type: string
* description:
* type: string
* responses:
* 201:
* description: Role created successfully.
* 400:
* description: Invalid request body.
* 409:
* description: A role with this name already exists.
* 500:
* description: Failed to create role.
*/
router.post(
"/roles",
authenticateJWT,
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const { name, displayName, description } = req.body;
if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) {
return res.status(400).json({
error: "Role name and display name are required",
});
}
if (!/^[a-z0-9_-]+$/.test(name)) {
return res.status(400).json({
error:
"Role name must contain only lowercase letters, numbers, underscores, and hyphens",
});
}
try {
const existing = await createCurrentRoleRepository().findRoleByName(name);
if (existing) {
return res.status(409).json({
error: "A role with this name already exists",
});
}
const newRoleId = await createCurrentRoleRepository().createRole({
name,
displayName,
description: description || null,
isSystem: false,
permissions: null,
});
res.status(201).json({
success: true,
roleId: newRoleId,
message: "Role created successfully",
});
} catch (error) {
databaseLogger.error("Failed to create role", error, {
operation: "create_role",
roleName: name,
});
res.status(500).json({ error: "Failed to create role" });
}
},
);
/**
* @openapi
* /rbac/roles/{id}:
* put:
* summary: Update a role
* description: Updates a role by its ID.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* displayName:
* type: string
* description:
* type: string
* permissions:
* type: array
* items:
* type: string
* description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed).
* responses:
* 200:
* description: Role updated successfully.
* 400:
* description: Invalid request body or role ID.
* 404:
* description: Role not found.
* 500:
* description: Failed to update role.
*/
router.put(
"/roles/:id",
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);
const { displayName, description, permissions } = req.body;
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
if (
!displayName &&
description === undefined &&
permissions === undefined
) {
return res.status(400).json({
error:
"At least one field (displayName, description or permissions) is required",
});
}
if (permissions !== undefined) {
if (
!Array.isArray(permissions) ||
permissions.some((perm) => typeof perm !== "string")
) {
return res
.status(400)
.json({ error: "permissions must be an array of strings" });
}
const invalid = (permissions as string[]).filter(
(perm) => !isValidPermission(perm),
);
if (invalid.length > 0) {
return res.status(400).json({
error: "Unknown permissions",
invalid,
});
}
}
try {
const roleRepository = createCurrentRoleRepository();
const existingRole = await roleRepository.findRoleById(roleId);
if (!existingRole) {
return res.status(404).json({ error: "Role not found" });
}
const updates: {
displayName?: string;
description?: string | null;
permissions?: string | null;
updatedAt: string;
} = {
updatedAt: new Date().toISOString(),
};
if (displayName) {
updates.displayName = displayName;
}
if (description !== undefined) {
updates.description = description || null;
}
if (permissions !== undefined) {
updates.permissions = JSON.stringify(permissions);
}
await roleRepository.updateRole(roleId, updates);
if (permissions !== undefined) {
const memberIds = await roleRepository.listRoleUserIds(roleId);
for (const memberId of memberIds) {
permissionManager.invalidateUserPermissionCache(memberId);
}
}
res.json({
success: true,
message: "Role updated successfully",
});
} catch (error) {
databaseLogger.error("Failed to update role", error, {
operation: "update_role",
roleId,
});
res.status(500).json({ error: "Failed to update role" });
}
},
);
/**
* @openapi
* /rbac/permissions/catalog:
* get:
* summary: Get the role permissions catalog
* description: Returns the grouped catalog of role permission strings used by the role permissions editor.
* tags:
* - RBAC
* responses:
* 200:
* description: The permissions catalog.
*/
router.get(
"/permissions/catalog",
authenticateJWT,
async (_req: AuthenticatedRequest, res: Response) => {
res.json({ catalog: getPermissionCatalog() });
},
);
/**
* @openapi
* /rbac/permissions/me:
* get:
* summary: Get the current user's effective role permissions
* description: >
* Returns the permission strings granted to the authenticated user
* through their roles, plus whether they are an admin. The frontend uses
* this to hide UI a user could not use. It is not a security boundary:
* every protected route checks again server side.
* tags:
* - RBAC
* responses:
* 200:
* description: The current user's permissions.
* 401:
* description: Not authenticated.
*/
router.get(
"/permissions/me",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
// isAdmin travels with the list because hasPermission() falls back to it
// on the deny path. Without it the UI would hide things from an admin
// that the server would allow.
const [permissions, isAdmin] = await Promise.all([
permissionManager.getUserPermissions(userId),
permissionManager.isAdmin(userId),
]);
res.json({ permissions, isAdmin });
} catch (error) {
databaseLogger.error("Failed to get current user permissions", error, {
operation: "get_my_permissions",
userId,
});
res.status(500).json({ error: "Failed to get permissions" });
}
},
);
/**
* @openapi
* /rbac/roles/{id}:
* delete:
* summary: Delete a role
* description: Deletes a role by its ID.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Role deleted successfully.
* 400:
* description: Invalid role ID.
* 403:
* description: Cannot delete system roles.
* 404:
* description: Role not found.
* 500:
* description: Failed to delete role.
*/
router.delete(
"/roles/:id",
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 role = await createCurrentRoleRepository().findRoleById(roleId);
if (!role) {
return res.status(404).json({ error: "Role not found" });
}
if (role.isSystem) {
return res.status(403).json({
error: "Cannot delete system roles",
});
}
const { deletedUserIds } =
await createCurrentRoleRepository().deleteRole(roleId);
for (const userId of deletedUserIds) {
permissionManager.invalidateUserPermissionCache(userId);
}
await pruneGrantsBy(deletedUserIds);
res.json({
success: true,
message: "Role deleted successfully",
});
} catch (error) {
databaseLogger.error("Failed to delete role", error, {
operation: "delete_role",
roleId,
});
res.status(500).json({ error: "Failed to delete role" });
}
},
);
/**
* @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:
* post:
* summary: Assign a role to a user
* description: Assigns a role to a user.
* tags:
* - RBAC
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* roleId:
* type: integer
* responses:
* 200:
* description: Role assigned successfully.
* 400:
* description: Role ID is required.
* 403:
* description: System roles cannot be manually assigned.
* 404:
* description: User or role not found.
* 409:
* description: Role already assigned.
* 500:
* description: Failed to assign role.
*/
router.post(
"/users/:userId/roles",
authenticateJWT,
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
: req.params.userId;
const currentUserId = req.userId!;
try {
const { roleId } = req.body;
if (typeof roleId !== "number") {
return res.status(400).json({ error: "Role ID is required" });
}
const targetUser =
await createCurrentUserRepository().findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
const role = await createCurrentRoleRepository().findRoleById(roleId);
if (!role) {
return res.status(404).json({ error: "Role not found" });
}
if (role.isSystem) {
return res.status(403).json({
error:
"System roles (admin, user) are automatically assigned and cannot be manually assigned",
});
}
const existing = await createCurrentRoleRepository().findUserRole(
targetUserId,
roleId,
);
if (existing) {
return res.status(409).json({ error: "Role already assigned" });
}
await createCurrentRoleRepository().assignRoleToUser({
userId: targetUserId,
roleId,
grantedBy: currentUserId,
});
try {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().snapshotForRoleMember(
roleId,
targetUserId,
);
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().snapshotForRoleMember(
roleId,
targetUserId,
);
} catch (error) {
databaseLogger.error(
"Failed to snapshot shared host secrets for new role member",
error,
{
operation: "assign_role_snapshot_secrets",
targetUserId,
roleId,
},
);
}
permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Role assigned to user", {
operation: "rbac_role_assign",
adminId: currentUserId,
targetUserId,
roleId,
roleName: role.name,
});
res.json({
success: true,
message: "Role assigned successfully",
});
} catch (error) {
databaseLogger.error("Failed to assign role", error, {
operation: "assign_role",
targetUserId,
});
res.status(500).json({ error: "Failed to assign role" });
}
},
);
/**
* @openapi
* /rbac/users/{userId}/roles/{roleId}:
* delete:
* summary: Remove a role from a user
* description: Removes a role from a user.
* tags:
* - RBAC
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* - in: path
* name: roleId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Role removed successfully.
* 400:
* description: Invalid role ID.
* 403:
* description: System roles cannot be removed.
* 404:
* description: Role not found.
* 500:
* description: Failed to remove role.
*/
router.delete(
"/users/:userId/roles/:roleId",
authenticateJWT,
permissionManager.requirePermission("admin.roles.manage"),
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
: req.params.userId;
const roleIdParam = Array.isArray(req.params.roleId)
? req.params.roleId[0]
: req.params.roleId;
const roleId = parseInt(roleIdParam, 10);
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
try {
const role = await createCurrentRoleRepository().findRoleById(roleId);
if (!role) {
return res.status(404).json({ error: "Role not found" });
}
if (role.isSystem) {
return res.status(403).json({
error:
"System roles (admin, user) are automatically assigned and cannot be removed",
});
}
await createCurrentRoleRepository().removeRoleFromUser(
targetUserId,
roleId,
);
try {
const { createCurrentSharedHostSecretsRepository } =
await import("../repositories/factory.js");
await createCurrentSharedHostSecretsRepository().deleteForRoleMember(
roleId,
targetUserId,
);
const { createCurrentSharedCredentialSecretsRepository } =
await import("../repositories/factory.js");
await createCurrentSharedCredentialSecretsRepository().deleteForRoleMember(
roleId,
targetUserId,
);
} catch (cleanupError) {
databaseLogger.warn(
"Failed to clean shared host secrets after role removal",
{
operation: "remove_role_secret_cleanup",
targetUserId,
roleId,
error: getErrorMessage(cleanupError),
},
);
}
permissionManager.invalidateUserPermissionCache(targetUserId);
await pruneGrantsBy([targetUserId]);
databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove",
adminId: req.userId!,
targetUserId,
roleId,
});
res.json({
success: true,
message: "Role removed successfully",
});
} catch (error) {
databaseLogger.error("Failed to remove role", error, {
operation: "remove_role",
targetUserId,
roleId,
});
res.status(500).json({ error: "Failed to remove role" });
}
},
);
/**
* @openapi
* /rbac/users/{userId}/roles:
* get:
* summary: Get user's roles
* description: Retrieves a list of roles for a specific user.
* tags:
* - RBAC
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: A list of roles.
* 403:
* description: Access denied.
* 500:
* description: Failed to get user roles.
*/
router.get(
"/users/:userId/roles",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
: req.params.userId;
const currentUserId = req.userId!;
if (
targetUserId !== currentUserId &&
!(await permissionManager.isAdmin(currentUserId))
) {
return res.status(403).json({ error: "Access denied" });
}
try {
const userRolesList =
await createCurrentRoleRepository().listUserRoles(targetUserId);
res.json({ roles: userRolesList });
} catch (error) {
databaseLogger.error("Failed to get user roles", error, {
operation: "get_user_roles",
targetUserId,
});
res.status(500).json({ error: "Failed to get user roles" });
}
},
);
// CREDENTIAL SHARING
const CREDENTIAL_LEVELS = ["use", "manage"] as const;
type CredentialLevel = (typeof CREDENTIAL_LEVELS)[number];
/** Owner, or a recipient holding "manage". */
async function canManageCredentialSharing(
userId: string,
credentialId: number,
): Promise<{ allowed: boolean; ownerId: string | null }> {
const row = await createCurrentCredentialRepository().findById(credentialId);
if (!row) return { allowed: false, ownerId: null };
if (row.userId === userId) return { allowed: true, ownerId: row.userId };
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
credentialId,
userId,
roleIds,
);
return { allowed: grant?.permissionLevel === "manage", ownerId: row.userId };
}
/**
* @openapi
* /rbac/credential/{id}/share:
* post:
* summary: Share a credential with users or roles
* description: Recipients get a copy of the secrets re-encrypted under their own key. "use" lets them attach it to hosts and connect; "manage" also lets them edit and re-share. Owner or a "manage" recipient only.
* tags:
* - RBAC
*/
router.post(
"/credential/:id/share",
authenticateJWT,
permissionManager.requirePermission("credentials.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const credentialId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(credentialId)) {
return res.status(400).json({ error: "Invalid credential ID" });
}
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",
});
}
const { durationHours, permissionLevel = "use" } = req.body ?? {};
if (!CREDENTIAL_LEVELS.includes(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: CREDENTIAL_LEVELS,
});
}
try {
const sharing = await canManageCredentialSharing(userId, credentialId);
if (!sharing.allowed || !sharing.ownerId) {
return res
.status(403)
.json({ error: "Not allowed to share this credential" });
}
const ownerId = sharing.ownerId;
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 expiresAt = expiryFromDuration(durationHours);
const accessRepository = createCurrentCredentialAccessRepository();
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
const manager = SharedCredentialSecretsManager.getInstance();
for (const target of targets) {
if (target.type === "user" && target.id === ownerId) continue;
const grant = await accessRepository.upsert({
credentialId,
grantedBy: userId,
permissionLevel: permissionLevel as CredentialLevel,
expiresAt,
target:
target.type === "user"
? { targetType: "user", targetUserId: target.id as string }
: { targetType: "role", targetRoleId: target.id as number },
});
try {
if (target.type === "user") {
await manager.snapshotForUser(
grant.id,
credentialId,
target.id as string,
ownerId,
);
} else {
await manager.snapshotForRole(
grant.id,
credentialId,
target.id as number,
ownerId,
);
}
} catch (snapshotError) {
databaseLogger.warn("Credential shared but secret snapshot failed", {
operation: "rbac_credential_share_snapshot_failed",
credentialId,
accessId: grant.id,
error: getErrorMessage(snapshotError),
});
}
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "credential_share",
resourceType: "credential",
resourceId: String(credentialId),
details: JSON.stringify({ targets, permissionLevel, expiresAt }),
ipAddress,
userAgent,
success: true,
});
await pruneCredentialGrants(credentialId);
res.json({ success: true, permissionLevel, expiresAt });
} catch (error) {
databaseLogger.error("Failed to share credential", error, {
operation: "share_credential",
credentialId,
userId,
});
res.status(500).json({ error: "Failed to share credential" });
}
},
);
/**
* @openapi
* /rbac/credential/{id}/access:
* get:
* summary: List who a credential is shared with
* tags:
* - RBAC
*/
router.get(
"/credential/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const credentialId = parseInt(id, 10);
if (isNaN(credentialId)) {
return res.status(400).json({ error: "Invalid credential ID" });
}
try {
const sharing = await canManageCredentialSharing(
req.userId!,
credentialId,
);
if (!sharing.allowed) {
return res.status(403).json({ error: "Not allowed" });
}
res.json({
access:
await createCurrentCredentialAccessRepository().listForCredential(
credentialId,
),
});
} catch (error) {
databaseLogger.error("Failed to list credential access", error, {
operation: "list_credential_access",
credentialId,
});
res.status(500).json({ error: "Failed to list credential access" });
}
},
);
/**
* @openapi
* /rbac/credential/{id}/access/{accessId}:
* delete:
* summary: Revoke a credential share (recipients' copies are removed with it)
* tags:
* - RBAC
*/
router.delete(
"/credential/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const credentialId = parseInt(String(req.params.id), 10);
const accessId = parseInt(String(req.params.accessId), 10);
if (isNaN(credentialId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const sharing = await canManageCredentialSharing(
req.userId!,
credentialId,
);
if (!sharing.allowed) {
return res.status(403).json({ error: "Not allowed" });
}
const repository = createCurrentCredentialAccessRepository();
if (!(await repository.findById(accessId, credentialId))) {
return res.status(404).json({ error: "Access grant not found" });
}
await repository.revoke(accessId, credentialId);
await pruneCredentialGrants(credentialId);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to revoke credential access", error, {
operation: "revoke_credential_access",
credentialId,
accessId,
});
res.status(500).json({ error: "Failed to revoke credential access" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
* put:
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
* tags: [RBAC]
*/
router.put(
"/host-access/:hostId/auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
const protocol = req.params.protocol;
const { credentialId } = req.body;
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
if (
credentialId !== null &&
(!Number.isInteger(credentialId) || credentialId <= 0)
) {
return res.status(400).json({
error: "credentialId must be a positive integer or null",
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await sharedHostAuthOverrideService.setCredentialId(
hostId,
userId,
protocol,
credentialId,
{ ipAddress, userAgent },
);
res.json({ success: true, protocol, credentialId });
} catch (error) {
if (error instanceof SharedHostAuthOverrideServiceError) {
return res.status(error.statusCode).json({ error: error.message });
}
databaseLogger.error("Failed to set override credential", error);
res.status(500).json({ error: "Failed to update credential" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
* get:
* summary: Get the current recipient's shared-host protocol authentication override
* tags: [RBAC]
*/
router.get(
"/host-access/:hostId/auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
const protocol = req.params.protocol;
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
const credentialId = await sharedHostAuthOverrideService.getCredentialId(
hostId,
userId,
protocol,
);
res.json({ protocol, credentialId });
} catch (error) {
if (error instanceof SharedHostAuthOverrideServiceError) {
return res.status(error.statusCode).json({ error: error.message });
}
databaseLogger.error("Failed to get override credential", error);
res.status(500).json({ error: "Failed to fetch credential" });
}
},
);
export default router;