mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
1528 lines
42 KiB
TypeScript
1528 lines
42 KiB
TypeScript
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import express from "express";
|
|
import { db } from "../db/index.js";
|
|
import {
|
|
hostAccess,
|
|
hosts,
|
|
users,
|
|
roles,
|
|
userRoles,
|
|
sharedCredentials,
|
|
snippets,
|
|
snippetAccess,
|
|
} from "../db/schema.js";
|
|
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
|
import type { Response } from "express";
|
|
import { databaseLogger } from "../../utils/logger.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
|
|
|
const router = express.Router();
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const permissionManager = PermissionManager.getInstance();
|
|
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
|
|
function isNonEmptyString(value: unknown): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /rbac/host/{id}/share:
|
|
* post:
|
|
* summary: Share a host
|
|
* description: Shares a host with a user or a role.
|
|
* tags:
|
|
* - RBAC
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* targetType:
|
|
* type: string
|
|
* enum: [user, role]
|
|
* targetUserId:
|
|
* type: string
|
|
* targetRoleId:
|
|
* type: integer
|
|
* durationHours:
|
|
* type: number
|
|
* permissionLevel:
|
|
* type: string
|
|
* enum: [view]
|
|
* responses:
|
|
* 200:
|
|
* description: Host shared successfully.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
* 403:
|
|
* description: Not host owner.
|
|
* 404:
|
|
* description: Target user or role not found.
|
|
* 500:
|
|
* description: Failed to share host.
|
|
*/
|
|
router.post(
|
|
"/host/:id/share",
|
|
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 {
|
|
targetType = "user",
|
|
targetUserId,
|
|
targetRoleId,
|
|
durationHours,
|
|
permissionLevel = "view",
|
|
} = 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 host = await db
|
|
.select()
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
|
|
if (host.length === 0) {
|
|
databaseLogger.warn("Permission denied", {
|
|
operation: "rbac_permission_denied",
|
|
userId,
|
|
resource: "host",
|
|
resourceId: hostId,
|
|
action: "share",
|
|
});
|
|
return res.status(403).json({ error: "Not host owner" });
|
|
}
|
|
|
|
if (!host[0].credentialId && host[0].authType !== "opkssh") {
|
|
return res.status(400).json({
|
|
error:
|
|
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
|
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
|
|
});
|
|
}
|
|
|
|
if (targetType === "user") {
|
|
const targetUser = await db
|
|
.select({ id: users.id, username: users.username })
|
|
.from(users)
|
|
.where(eq(users.id, targetUserId))
|
|
.limit(1);
|
|
|
|
if (targetUser.length === 0) {
|
|
return res.status(404).json({ error: "Target user not found" });
|
|
}
|
|
} else {
|
|
const targetRole = await db
|
|
.select({ id: roles.id, name: roles.name })
|
|
.from(roles)
|
|
.where(eq(roles.id, targetRoleId))
|
|
.limit(1);
|
|
|
|
if (targetRole.length === 0) {
|
|
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 validLevels = ["view"];
|
|
if (!validLevels.includes(permissionLevel)) {
|
|
return res.status(400).json({
|
|
error: "Invalid permission level. Only 'view' is supported.",
|
|
validLevels,
|
|
});
|
|
}
|
|
|
|
const whereConditions = [eq(hostAccess.hostId, hostId)];
|
|
if (targetType === "user") {
|
|
whereConditions.push(eq(hostAccess.userId, targetUserId));
|
|
} else {
|
|
whereConditions.push(eq(hostAccess.roleId, targetRoleId));
|
|
}
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(hostAccess)
|
|
.where(and(...whereConditions))
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
await db
|
|
.update(hostAccess)
|
|
.set({
|
|
permissionLevel,
|
|
expiresAt,
|
|
})
|
|
.where(eq(hostAccess.id, existing[0].id));
|
|
|
|
await db
|
|
.delete(sharedCredentials)
|
|
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
|
|
|
if (host[0].credentialId) {
|
|
const { SharedCredentialManager } =
|
|
await import("../../utils/shared-credential-manager.js");
|
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
if (targetType === "user") {
|
|
await sharedCredManager.createSharedCredentialForUser(
|
|
existing[0].id,
|
|
host[0].credentialId,
|
|
targetUserId!,
|
|
userId,
|
|
);
|
|
} else {
|
|
await sharedCredManager.createSharedCredentialsForRole(
|
|
existing[0].id,
|
|
host[0].credentialId,
|
|
targetRoleId!,
|
|
userId,
|
|
);
|
|
}
|
|
}
|
|
databaseLogger.info("Permission granted", {
|
|
operation: "rbac_permission_grant",
|
|
adminId: userId,
|
|
hostId,
|
|
resource: "host",
|
|
action: "view",
|
|
});
|
|
|
|
return res.json({
|
|
success: true,
|
|
message: "Host access updated",
|
|
expiresAt,
|
|
});
|
|
}
|
|
|
|
const result = await db.insert(hostAccess).values({
|
|
hostId,
|
|
userId: targetType === "user" ? targetUserId : null,
|
|
roleId: targetType === "role" ? targetRoleId : null,
|
|
grantedBy: userId,
|
|
permissionLevel,
|
|
expiresAt,
|
|
});
|
|
|
|
const { SharedCredentialManager } =
|
|
await import("../../utils/shared-credential-manager.js");
|
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
|
|
if (host[0].credentialId) {
|
|
if (targetType === "user") {
|
|
await sharedCredManager.createSharedCredentialForUser(
|
|
result.lastInsertRowid as number,
|
|
host[0].credentialId,
|
|
targetUserId!,
|
|
userId,
|
|
);
|
|
} else {
|
|
await sharedCredManager.createSharedCredentialsForRole(
|
|
result.lastInsertRowid as number,
|
|
host[0].credentialId,
|
|
targetRoleId!,
|
|
userId,
|
|
);
|
|
}
|
|
}
|
|
databaseLogger.success("Host shared successfully", {
|
|
operation: "rbac_host_share_success",
|
|
userId,
|
|
hostId,
|
|
targetUserId: targetType === "user" ? targetUserId : undefined,
|
|
permissionLevel,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Host shared successfully with ${targetType}`,
|
|
expiresAt,
|
|
});
|
|
} 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/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: Not host owner.
|
|
* 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 host = await db
|
|
.select()
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
|
|
if (host.length === 0) {
|
|
return res.status(403).json({ error: "Not host owner" });
|
|
}
|
|
|
|
await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
|
|
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.
|
|
* 400:
|
|
* description: Invalid host ID.
|
|
* 403:
|
|
* description: Not host owner.
|
|
* 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 host = await db
|
|
.select()
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
|
|
if (host.length === 0) {
|
|
return res.status(403).json({ error: "Not host owner" });
|
|
}
|
|
|
|
const rawAccessList = await db
|
|
.select({
|
|
id: hostAccess.id,
|
|
userId: hostAccess.userId,
|
|
roleId: hostAccess.roleId,
|
|
username: users.username,
|
|
roleName: roles.name,
|
|
roleDisplayName: roles.displayName,
|
|
grantedBy: hostAccess.grantedBy,
|
|
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`,
|
|
permissionLevel: hostAccess.permissionLevel,
|
|
expiresAt: hostAccess.expiresAt,
|
|
createdAt: hostAccess.createdAt,
|
|
})
|
|
.from(hostAccess)
|
|
.leftJoin(users, eq(hostAccess.userId, users.id))
|
|
.leftJoin(roles, eq(hostAccess.roleId, roles.id))
|
|
.where(eq(hostAccess.hostId, hostId))
|
|
.orderBy(desc(hostAccess.createdAt));
|
|
|
|
const accessList = rawAccessList.map((access) => ({
|
|
id: access.id,
|
|
targetType: access.userId ? "user" : "role",
|
|
userId: access.userId,
|
|
roleId: access.roleId,
|
|
username: access.username,
|
|
roleName: access.roleName,
|
|
roleDisplayName: access.roleDisplayName,
|
|
grantedBy: access.grantedBy,
|
|
grantedByUsername: access.grantedByUsername,
|
|
permissionLevel: access.permissionLevel,
|
|
expiresAt: access.expiresAt,
|
|
createdAt: access.createdAt,
|
|
}));
|
|
|
|
res.json({ accessList });
|
|
} 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 now = new Date().toISOString();
|
|
|
|
const sharedHosts = await db
|
|
.select({
|
|
id: hosts.id,
|
|
name: hosts.name,
|
|
ip: hosts.ip,
|
|
port: hosts.port,
|
|
username: hosts.username,
|
|
folder: hosts.folder,
|
|
tags: hosts.tags,
|
|
permissionLevel: hostAccess.permissionLevel,
|
|
expiresAt: hostAccess.expiresAt,
|
|
grantedBy: hostAccess.grantedBy,
|
|
ownerUsername: users.username,
|
|
})
|
|
.from(hostAccess)
|
|
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
|
.innerJoin(users, eq(hosts.userId, users.id))
|
|
.where(
|
|
and(
|
|
eq(hostAccess.userId, userId),
|
|
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
|
),
|
|
)
|
|
.orderBy(desc(hostAccess.createdAt));
|
|
|
|
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 db
|
|
.select({
|
|
id: roles.id,
|
|
name: roles.name,
|
|
displayName: roles.displayName,
|
|
description: roles.description,
|
|
isSystem: roles.isSystem,
|
|
createdAt: roles.createdAt,
|
|
updatedAt: roles.updatedAt,
|
|
})
|
|
.from(roles)
|
|
.orderBy(roles.isSystem, roles.name);
|
|
|
|
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.requireAdmin(),
|
|
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 db
|
|
.select({ id: roles.id })
|
|
.from(roles)
|
|
.where(eq(roles.name, name))
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
return res.status(409).json({
|
|
error: "A role with this name already exists",
|
|
});
|
|
}
|
|
|
|
const result = await db.insert(roles).values({
|
|
name,
|
|
displayName,
|
|
description: description || null,
|
|
isSystem: false,
|
|
permissions: null,
|
|
});
|
|
|
|
const newRoleId = result.lastInsertRowid;
|
|
|
|
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
|
|
* 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.requireAdmin(),
|
|
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 } = req.body;
|
|
|
|
if (isNaN(roleId)) {
|
|
return res.status(400).json({ error: "Invalid role ID" });
|
|
}
|
|
|
|
if (!displayName && description === undefined) {
|
|
return res.status(400).json({
|
|
error: "At least one field (displayName or description) is required",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const existingRole = await db
|
|
.select({
|
|
id: roles.id,
|
|
name: roles.name,
|
|
isSystem: roles.isSystem,
|
|
})
|
|
.from(roles)
|
|
.where(eq(roles.id, roleId))
|
|
.limit(1);
|
|
|
|
if (existingRole.length === 0) {
|
|
return res.status(404).json({ error: "Role not found" });
|
|
}
|
|
|
|
const updates: {
|
|
displayName?: string;
|
|
description?: string | null;
|
|
updatedAt: string;
|
|
} = {
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
if (displayName) {
|
|
updates.displayName = displayName;
|
|
}
|
|
|
|
if (description !== undefined) {
|
|
updates.description = description || null;
|
|
}
|
|
|
|
await db.update(roles).set(updates).where(eq(roles.id, roleId));
|
|
|
|
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/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.requireAdmin(),
|
|
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 db
|
|
.select({
|
|
id: roles.id,
|
|
name: roles.name,
|
|
isSystem: roles.isSystem,
|
|
})
|
|
.from(roles)
|
|
.where(eq(roles.id, roleId))
|
|
.limit(1);
|
|
|
|
if (role.length === 0) {
|
|
return res.status(404).json({ error: "Role not found" });
|
|
}
|
|
|
|
if (role[0].isSystem) {
|
|
return res.status(403).json({
|
|
error: "Cannot delete system roles",
|
|
});
|
|
}
|
|
|
|
const deletedUserRoles = await db
|
|
.delete(userRoles)
|
|
.where(eq(userRoles.roleId, roleId))
|
|
.returning({ userId: userRoles.userId });
|
|
|
|
for (const { userId } of deletedUserRoles) {
|
|
permissionManager.invalidateUserPermissionCache(userId);
|
|
}
|
|
|
|
await db.delete(hostAccess).where(eq(hostAccess.roleId, roleId));
|
|
|
|
await db.delete(roles).where(eq(roles.id, roleId));
|
|
|
|
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/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.requireAdmin(),
|
|
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 db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, targetUserId))
|
|
.limit(1);
|
|
|
|
if (targetUser.length === 0) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
const role = await db
|
|
.select()
|
|
.from(roles)
|
|
.where(eq(roles.id, roleId))
|
|
.limit(1);
|
|
|
|
if (role.length === 0) {
|
|
return res.status(404).json({ error: "Role not found" });
|
|
}
|
|
|
|
if (role[0].isSystem) {
|
|
return res.status(403).json({
|
|
error:
|
|
"System roles (admin, user) are automatically assigned and cannot be manually assigned",
|
|
});
|
|
}
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(userRoles)
|
|
.where(
|
|
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
|
)
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
return res.status(409).json({ error: "Role already assigned" });
|
|
}
|
|
|
|
await db.insert(userRoles).values({
|
|
userId: targetUserId,
|
|
roleId,
|
|
grantedBy: currentUserId,
|
|
});
|
|
|
|
const hostsSharedWithRole = await db
|
|
.select()
|
|
.from(hostAccess)
|
|
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
|
.where(eq(hostAccess.roleId, roleId));
|
|
|
|
const { SharedCredentialManager } =
|
|
await import("../../utils/shared-credential-manager.js");
|
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
|
|
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
|
if (ssh_data.credentialId) {
|
|
try {
|
|
await sharedCredManager.createSharedCredentialForUser(
|
|
host_access.id,
|
|
ssh_data.credentialId,
|
|
targetUserId,
|
|
ssh_data.userId,
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to create shared credential for new role member",
|
|
error,
|
|
{
|
|
operation: "assign_role_create_credentials",
|
|
targetUserId,
|
|
roleId,
|
|
hostId: ssh_data.id,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
permissionManager.invalidateUserPermissionCache(targetUserId);
|
|
databaseLogger.info("Role assigned to user", {
|
|
operation: "rbac_role_assign",
|
|
adminId: currentUserId,
|
|
targetUserId,
|
|
roleId,
|
|
roleName: role[0].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.requireAdmin(),
|
|
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 db
|
|
.select({
|
|
id: roles.id,
|
|
name: roles.name,
|
|
isSystem: roles.isSystem,
|
|
})
|
|
.from(roles)
|
|
.where(eq(roles.id, roleId))
|
|
.limit(1);
|
|
|
|
if (role.length === 0) {
|
|
return res.status(404).json({ error: "Role not found" });
|
|
}
|
|
|
|
if (role[0].isSystem) {
|
|
return res.status(403).json({
|
|
error:
|
|
"System roles (admin, user) are automatically assigned and cannot be removed",
|
|
});
|
|
}
|
|
|
|
await db
|
|
.delete(userRoles)
|
|
.where(
|
|
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
|
);
|
|
|
|
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 db
|
|
.select({
|
|
id: userRoles.id,
|
|
roleId: roles.id,
|
|
roleName: roles.name,
|
|
roleDisplayName: roles.displayName,
|
|
description: roles.description,
|
|
isSystem: roles.isSystem,
|
|
grantedAt: userRoles.grantedAt,
|
|
})
|
|
.from(userRoles)
|
|
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
|
.where(eq(userRoles.userId, 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,
|
|
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 db
|
|
.select()
|
|
.from(snippets)
|
|
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
|
.limit(1);
|
|
|
|
if (snippet.length === 0) {
|
|
return res.status(403).json({ error: "Not snippet owner" });
|
|
}
|
|
|
|
if (targetType === "user") {
|
|
const targetUser = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(eq(users.id, targetUserId))
|
|
.limit(1);
|
|
if (targetUser.length === 0) {
|
|
return res.status(404).json({ error: "Target user not found" });
|
|
}
|
|
} else {
|
|
const targetRole = await db
|
|
.select({ id: roles.id })
|
|
.from(roles)
|
|
.where(eq(roles.id, targetRoleId))
|
|
.limit(1);
|
|
if (targetRole.length === 0) {
|
|
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 whereConditions = [eq(snippetAccess.snippetId, snippetId)];
|
|
if (targetType === "user") {
|
|
whereConditions.push(eq(snippetAccess.userId, targetUserId));
|
|
} else {
|
|
whereConditions.push(eq(snippetAccess.roleId, targetRoleId));
|
|
}
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(snippetAccess)
|
|
.where(and(...whereConditions))
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
await db
|
|
.update(snippetAccess)
|
|
.set({ expiresAt })
|
|
.where(eq(snippetAccess.id, existing[0].id));
|
|
|
|
return res.json({
|
|
success: true,
|
|
message: "Snippet access updated",
|
|
expiresAt,
|
|
});
|
|
}
|
|
|
|
await db.insert(snippetAccess).values({
|
|
snippetId,
|
|
userId: targetType === "user" ? targetUserId : null,
|
|
roleId: targetType === "role" ? targetRoleId : null,
|
|
grantedBy: userId,
|
|
permissionLevel: "view",
|
|
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
|
|
*/
|
|
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 db
|
|
.select()
|
|
.from(snippets)
|
|
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
|
.limit(1);
|
|
|
|
if (snippet.length === 0) {
|
|
return res.status(403).json({ error: "Not snippet owner" });
|
|
}
|
|
|
|
await db.delete(snippetAccess).where(eq(snippetAccess.id, accessId));
|
|
|
|
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 db
|
|
.select()
|
|
.from(snippets)
|
|
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
|
.limit(1);
|
|
|
|
if (snippet.length === 0) {
|
|
return res.status(403).json({ error: "Not snippet owner" });
|
|
}
|
|
|
|
const rawAccessList = await db
|
|
.select({
|
|
id: snippetAccess.id,
|
|
userId: snippetAccess.userId,
|
|
roleId: snippetAccess.roleId,
|
|
username: users.username,
|
|
roleName: roles.name,
|
|
roleDisplayName: roles.displayName,
|
|
grantedBy: snippetAccess.grantedBy,
|
|
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
|
|
permissionLevel: snippetAccess.permissionLevel,
|
|
expiresAt: snippetAccess.expiresAt,
|
|
createdAt: snippetAccess.createdAt,
|
|
})
|
|
.from(snippetAccess)
|
|
.leftJoin(users, eq(snippetAccess.userId, users.id))
|
|
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
|
|
.where(eq(snippetAccess.snippetId, snippetId))
|
|
.orderBy(desc(snippetAccess.createdAt));
|
|
|
|
const accessList = rawAccessList.map((access) => ({
|
|
id: access.id,
|
|
targetType: access.userId ? "user" : "role",
|
|
userId: access.userId,
|
|
roleId: access.roleId,
|
|
username: access.username,
|
|
roleName: access.roleName,
|
|
roleDisplayName: access.roleDisplayName,
|
|
grantedBy: access.grantedBy,
|
|
grantedByUsername: access.grantedByUsername,
|
|
permissionLevel: access.permissionLevel,
|
|
expiresAt: access.expiresAt,
|
|
createdAt: access.createdAt,
|
|
}));
|
|
|
|
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 now = new Date().toISOString();
|
|
|
|
const directShared = await db
|
|
.select({
|
|
id: snippets.id,
|
|
name: snippets.name,
|
|
content: snippets.content,
|
|
description: snippets.description,
|
|
folder: snippets.folder,
|
|
ownerUsername: users.username,
|
|
permissionLevel: snippetAccess.permissionLevel,
|
|
expiresAt: snippetAccess.expiresAt,
|
|
})
|
|
.from(snippetAccess)
|
|
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
|
.innerJoin(users, eq(snippets.userId, users.id))
|
|
.where(
|
|
and(
|
|
eq(snippetAccess.userId, userId),
|
|
or(
|
|
isNull(snippetAccess.expiresAt),
|
|
gte(snippetAccess.expiresAt, now),
|
|
),
|
|
),
|
|
);
|
|
|
|
const userRoleRows = await db
|
|
.select({ roleId: userRoles.roleId })
|
|
.from(userRoles)
|
|
.where(eq(userRoles.userId, userId));
|
|
const roleIds = userRoleRows.map((r) => r.roleId);
|
|
|
|
let roleShared: typeof directShared = [];
|
|
if (roleIds.length > 0) {
|
|
const directIds = directShared.map((s) => s.id);
|
|
const roleResults = await db
|
|
.select({
|
|
id: snippets.id,
|
|
name: snippets.name,
|
|
content: snippets.content,
|
|
description: snippets.description,
|
|
folder: snippets.folder,
|
|
ownerUsername: users.username,
|
|
permissionLevel: snippetAccess.permissionLevel,
|
|
expiresAt: snippetAccess.expiresAt,
|
|
})
|
|
.from(snippetAccess)
|
|
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
|
.innerJoin(users, eq(snippets.userId, users.id))
|
|
.where(
|
|
and(
|
|
or(
|
|
isNull(snippetAccess.expiresAt),
|
|
gte(snippetAccess.expiresAt, now),
|
|
),
|
|
sql`${snippetAccess.roleId} IN (${sql.join(
|
|
roleIds.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})`,
|
|
),
|
|
);
|
|
|
|
roleShared = roleResults.filter((s) => !directIds.includes(s.id));
|
|
}
|
|
|
|
res.json({ sharedSnippets: [...directShared, ...roleShared] });
|
|
} 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" });
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|