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