mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
* feat: enforce RBAC and harden collaboration features - Mount requirePermission on hosts/snippets/credentials/automations/AI routes - Seed and backfill system role permissions on every dialect at startup - Support personal credential overrides for RDP/VNC/Telnet shared hosts - Broadcast participant presence in shared terminal sessions - Make audit log forwarding configurable from the admin panel - Add role members endpoint and snippet folder sharing * fix: enforce RBAC across split routes
2042 lines
56 KiB
TypeScript
2042 lines
56 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 } from "../../utils/audit-logger.js";
|
|
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.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 {
|
|
PERMISSION_CATALOG,
|
|
isValidPermission,
|
|
} from "../../utils/permission-catalog.js";
|
|
import {
|
|
createCurrentHostFolderRepository,
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentRbacAccessRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentSnippetRepository,
|
|
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 };
|
|
}
|
|
|
|
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"),
|
|
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,
|
|
});
|
|
}
|
|
|
|
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 {
|
|
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);
|
|
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) {
|
|
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.
|
|
*/
|
|
router.patch(
|
|
"/host/:id/access/:accessId",
|
|
authenticateJWT,
|
|
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,
|
|
);
|
|
|
|
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,
|
|
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,
|
|
);
|
|
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: PERMISSION_CATALOG });
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
|
|
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,
|
|
);
|
|
} 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,
|
|
);
|
|
} 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);
|
|
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" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// SNIPPET SHARING
|
|
|
|
/**
|
|
* @openapi
|
|
* /rbac/snippet/{id}/share:
|
|
* post:
|
|
* summary: Share a snippet
|
|
* description: Shares a snippet with a user or role.
|
|
* tags:
|
|
* - RBAC
|
|
*/
|
|
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);
|
|
const userId = req.userId!;
|
|
|
|
if (isNaN(snippetId)) {
|
|
return res.status(400).json({ error: "Invalid snippet ID" });
|
|
}
|
|
|
|
try {
|
|
const {
|
|
targetType = "user",
|
|
targetUserId,
|
|
targetRoleId,
|
|
durationHours,
|
|
} = req.body;
|
|
|
|
if (!["user", "role"].includes(targetType)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
|
}
|
|
|
|
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Target user ID is required when sharing with user" });
|
|
}
|
|
if (targetType === "role" && !targetRoleId) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Target role ID is required when sharing with role" });
|
|
}
|
|
|
|
const snippet = await createCurrentSnippetRepository().findOwnedById(
|
|
userId,
|
|
snippetId,
|
|
);
|
|
|
|
if (!snippet) {
|
|
return res.status(403).json({ error: "Not snippet owner" });
|
|
}
|
|
|
|
if (targetType === "user") {
|
|
const targetUser =
|
|
await createCurrentUserRepository().findById(targetUserId);
|
|
if (!targetUser) {
|
|
return res.status(404).json({ error: "Target user not found" });
|
|
}
|
|
} else {
|
|
const targetRole =
|
|
await createCurrentRoleRepository().findRoleById(targetRoleId);
|
|
if (!targetRole) {
|
|
return res.status(404).json({ error: "Target role not found" });
|
|
}
|
|
}
|
|
|
|
let expiresAt: string | null = null;
|
|
if (
|
|
durationHours &&
|
|
typeof durationHours === "number" &&
|
|
durationHours > 0
|
|
) {
|
|
const expiryDate = new Date();
|
|
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
|
expiresAt = expiryDate.toISOString();
|
|
}
|
|
|
|
const accessGrant =
|
|
await createCurrentRbacAccessRepository().upsertSnippetAccess({
|
|
snippetId,
|
|
grantedBy: userId,
|
|
expiresAt,
|
|
...(targetType === "user"
|
|
? { targetType: "user" as const, targetUserId: targetUserId! }
|
|
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
|
|
});
|
|
|
|
if (!accessGrant.created) {
|
|
return res.json({
|
|
success: true,
|
|
message: "Snippet access updated",
|
|
expiresAt,
|
|
});
|
|
}
|
|
|
|
databaseLogger.success("Snippet shared successfully", {
|
|
operation: "rbac_snippet_share",
|
|
userId,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Snippet shared successfully with ${targetType}`,
|
|
expiresAt,
|
|
});
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to share snippet", error, {
|
|
operation: "share_snippet",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to share snippet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /rbac/snippet/{id}/access/{accessId}:
|
|
* delete:
|
|
* summary: Revoke snippet access
|
|
* description: Revokes a user's or role's access to a snippet.
|
|
* 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,
|
|
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 snippetId = parseInt(id, 10);
|
|
const accessId = parseInt(accessIdParam, 10);
|
|
const userId = req.userId!;
|
|
|
|
if (isNaN(snippetId) || isNaN(accessId)) {
|
|
return res.status(400).json({ error: "Invalid ID" });
|
|
}
|
|
|
|
try {
|
|
const snippet = await createCurrentSnippetRepository().findOwnedById(
|
|
userId,
|
|
snippetId,
|
|
);
|
|
|
|
if (!snippet) {
|
|
return res.status(403).json({ error: "Not snippet owner" });
|
|
}
|
|
|
|
await createCurrentRbacAccessRepository().revokeSnippetAccess(
|
|
accessId,
|
|
snippetId,
|
|
);
|
|
|
|
res.json({ success: true, message: "Snippet access revoked" });
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to revoke snippet access", error, {
|
|
operation: "revoke_snippet_access",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to revoke access" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /rbac/snippet/{id}/access:
|
|
* get:
|
|
* summary: Get snippet access list
|
|
* description: Retrieves the list of users and roles with access to a snippet.
|
|
* tags:
|
|
* - RBAC
|
|
*/
|
|
router.get(
|
|
"/snippet/:id/access",
|
|
authenticateJWT,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const snippetId = parseInt(id, 10);
|
|
const userId = req.userId!;
|
|
|
|
if (isNaN(snippetId)) {
|
|
return res.status(400).json({ error: "Invalid snippet ID" });
|
|
}
|
|
|
|
try {
|
|
const snippet = await createCurrentSnippetRepository().findOwnedById(
|
|
userId,
|
|
snippetId,
|
|
);
|
|
|
|
if (!snippet) {
|
|
return res.status(403).json({ error: "Not snippet owner" });
|
|
}
|
|
|
|
const accessList =
|
|
await createCurrentRbacAccessRepository().listSnippetAccess(snippetId);
|
|
|
|
res.json({ accessList });
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to get snippet access list", error, {
|
|
operation: "get_snippet_access_list",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to get access list" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /rbac/shared-snippets:
|
|
* get:
|
|
* summary: Get shared snippets
|
|
* description: Retrieves snippets shared with the current user.
|
|
* tags:
|
|
* - RBAC
|
|
*/
|
|
router.get(
|
|
"/shared-snippets",
|
|
authenticateJWT,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const userId = req.userId!;
|
|
|
|
try {
|
|
const roleIds =
|
|
await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
const sharedSnippets =
|
|
await createCurrentRbacAccessRepository().listSharedSnippets(
|
|
userId,
|
|
roleIds,
|
|
);
|
|
|
|
res.json({ sharedSnippets });
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to get shared snippets", error, {
|
|
operation: "get_shared_snippets",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to get shared snippets" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @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. Only SSH is currently supported.
|
|
* tags: [RBAC]
|
|
* security:
|
|
* - bearerAuth: []
|
|
*/
|
|
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]
|
|
* security:
|
|
* - bearerAuth: []
|
|
*/
|
|
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;
|