mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: enforce RBAC and harden collaboration features (#1327)
* feat: enforce RBAC and harden collaboration features - Mount requirePermission on hosts/snippets/credentials/automations/AI routes - Seed and backfill system role permissions on every dialect at startup - Support personal credential overrides for RDP/VNC/Telnet shared hosts - Broadcast participant presence in shared terminal sessions - Make audit log forwarding configurable from the admin panel - Add role members endpoint and snippet folder sharing * fix: enforce RBAC across split routes
This commit is contained in:
@@ -105,3 +105,33 @@ export async function getAuditLogActions(): Promise<{ actions: string[] }> {
|
||||
handleApiError(error, "fetch audit log actions");
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditForwardingSettings {
|
||||
url: string;
|
||||
hasToken: boolean;
|
||||
envConfigured: boolean;
|
||||
}
|
||||
|
||||
export async function getAuditForwarding(): Promise<AuditForwardingSettings> {
|
||||
try {
|
||||
const response = await authApi.get("/users/audit-forwarding");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get audit forwarding settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAuditForwarding(
|
||||
url: string,
|
||||
token?: string,
|
||||
): Promise<{ url: string; hasToken: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/audit-forwarding", {
|
||||
url,
|
||||
token,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update audit forwarding settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,24 @@ export async function removeRoleFromUser(
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoleMember {
|
||||
userId: string;
|
||||
username: string;
|
||||
grantedAt: string;
|
||||
grantedBy: string | null;
|
||||
}
|
||||
|
||||
export async function getRoleMembers(
|
||||
roleId: number,
|
||||
): Promise<{ members: RoleMember[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/roles/${roleId}/members`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get role members");
|
||||
}
|
||||
}
|
||||
|
||||
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||
|
||||
export interface ShareTarget {
|
||||
@@ -321,6 +339,23 @@ export async function shareSnippet(
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareSnippetFolder(
|
||||
folder: string,
|
||||
targets: ShareTarget[],
|
||||
durationHours?: number,
|
||||
): Promise<{ success: boolean; snippetsShared: number }> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/snippet-folder/share", {
|
||||
folder,
|
||||
targets,
|
||||
durationHours,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share snippet folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippetAccess(
|
||||
snippetId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { AlertCircle, Eye } from "lucide-react";
|
||||
import { AlertCircle, Eye, Users } from "lucide-react";
|
||||
import {
|
||||
resolveShareLink,
|
||||
type ResolvedShareLink,
|
||||
@@ -44,6 +44,41 @@ async function resolveTerminalWsBaseUrl(): Promise<string> {
|
||||
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
||||
}
|
||||
|
||||
export interface SessionParticipantInfo {
|
||||
isOwner: boolean;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
function ParticipantsBadge({
|
||||
participants,
|
||||
ownerLabel,
|
||||
}: {
|
||||
participants: SessionParticipantInfo[];
|
||||
ownerLabel: string;
|
||||
}) {
|
||||
if (participants.length < 2) return null;
|
||||
const names = participants
|
||||
.map((participant) =>
|
||||
participant.isOwner ? ownerLabel : (participant.label ?? "?"),
|
||||
)
|
||||
.join(", ");
|
||||
return (
|
||||
<div
|
||||
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
title={names}
|
||||
>
|
||||
<Users className="size-3.5" />
|
||||
{participants.length}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -93,6 +128,9 @@ function GuestTerminalView({
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const [ended, setEnded] = useState<string | null>(null);
|
||||
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
|
||||
[],
|
||||
);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -140,6 +178,11 @@ function GuestTerminalView({
|
||||
case "data":
|
||||
if (typeof msg.data === "string") terminal.write(msg.data);
|
||||
break;
|
||||
case "participants":
|
||||
if (Array.isArray(msg.participants)) {
|
||||
setParticipants(msg.participants as SessionParticipantInfo[]);
|
||||
}
|
||||
break;
|
||||
case "sessionExpired":
|
||||
case "sessionTerminatedByOwner":
|
||||
case "session_ended":
|
||||
@@ -176,6 +219,10 @@ function GuestTerminalView({
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ParticipantsBadge
|
||||
participants={participants}
|
||||
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
|
||||
/>
|
||||
{share.permissionLevel === "read-only" && (
|
||||
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
||||
)}
|
||||
|
||||
+11
-1
@@ -1580,7 +1580,9 @@
|
||||
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
|
||||
"ownerAuthShared": "The host owner has shared SSH authentication for this host. You can use it or choose your own credential from “Set personal SSH authentication.”",
|
||||
"authOverrideAction": "Set personal SSH authentication",
|
||||
"authOverrideActionProtocol": "Set personal {{protocol}} authentication",
|
||||
"authOverrideTitle": "Personal SSH authentication",
|
||||
"authOverrideTitleProtocol": "Personal {{protocol}} authentication",
|
||||
"authOverrideDescriptionPrivate": "The host owner's SSH credentials stay private. Choose one of your saved credentials for connections to {{host}}.",
|
||||
"authOverrideDescriptionShared": "Use the authentication shared by the host owner, or replace it with one of your saved credentials for connections to {{host}}.",
|
||||
"authOverrideCredentialLabel": "Authentication credential",
|
||||
@@ -1810,7 +1812,8 @@
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only"
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
@@ -3291,6 +3294,13 @@
|
||||
"analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
|
||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||
"sessionSharingGloballyEnabled": "Allow Session Sharing",
|
||||
"auditForwardingTitle": "Forward to SIEM",
|
||||
"auditForwardingDesc": "Ship a copy of every audit entry to an external collector as NDJSON. Local logs stay the source of truth.",
|
||||
"auditForwardingEnvNotice": "An environment variable also configures forwarding; the URL saved here takes precedence.",
|
||||
"auditForwardingToken": "Bearer token (optional)",
|
||||
"auditForwardingTokenStored": "Bearer token (stored)",
|
||||
"auditForwardingSaved": "Audit forwarding settings saved",
|
||||
"auditForwardingSaveError": "Failed to save audit forwarding settings",
|
||||
"sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.",
|
||||
"updateSessionSharingFailed": "Failed to update session sharing setting",
|
||||
"sessionTimeout": "Session Timeout",
|
||||
|
||||
@@ -13,9 +13,12 @@ import { AccordionSection } from "./AdminSettingsShared";
|
||||
import {
|
||||
getAuditLogs,
|
||||
getAuditLogActions,
|
||||
getAuditForwarding,
|
||||
updateAuditForwarding,
|
||||
type AuditLog,
|
||||
type AuditLogFilters,
|
||||
} from "@/api/audit-log-api";
|
||||
import { toast } from "sonner";
|
||||
import type { AdminUser } from "./AdminManagementSections";
|
||||
|
||||
const RESOURCE_TYPES = [
|
||||
@@ -43,6 +46,39 @@ export function AdminAuditLogSection({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [forwardUrl, setForwardUrl] = useState("");
|
||||
const [forwardToken, setForwardToken] = useState("");
|
||||
const [forwardHasToken, setForwardHasToken] = useState(false);
|
||||
const [forwardEnvConfigured, setForwardEnvConfigured] = useState(false);
|
||||
const [forwardSaving, setForwardSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
getAuditForwarding()
|
||||
.then((settings) => {
|
||||
setForwardUrl(settings.url);
|
||||
setForwardHasToken(settings.hasToken);
|
||||
setForwardEnvConfigured(settings.envConfigured);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open]);
|
||||
|
||||
async function handleSaveForwarding() {
|
||||
setForwardSaving(true);
|
||||
try {
|
||||
const result = await updateAuditForwarding(
|
||||
forwardUrl,
|
||||
forwardToken || undefined,
|
||||
);
|
||||
setForwardHasToken(result.hasToken || (!!result.url && forwardHasToken));
|
||||
setForwardToken("");
|
||||
toast.success(t("admin.auditForwardingSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.auditForwardingSaveError"));
|
||||
} finally {
|
||||
setForwardSaving(false);
|
||||
}
|
||||
}
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
@@ -170,6 +206,45 @@ export function AdminAuditLogSection({
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col pt-2 gap-2">
|
||||
{/* SIEM forwarding */}
|
||||
<div className="flex flex-col gap-1.5 pb-2 border-b border-border">
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.auditForwardingTitle")}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("admin.auditForwardingDesc")}
|
||||
{forwardEnvConfigured && (
|
||||
<> {t("admin.auditForwardingEnvNotice")}</>
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
placeholder="https://siem.example.com/ingest"
|
||||
value={forwardUrl}
|
||||
onChange={(e) => setForwardUrl(e.target.value)}
|
||||
className="text-[10px] h-7"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
forwardHasToken
|
||||
? t("admin.auditForwardingTokenStored")
|
||||
: t("admin.auditForwardingToken")
|
||||
}
|
||||
value={forwardToken}
|
||||
onChange={(e) => setForwardToken(e.target.value)}
|
||||
className="text-[10px] h-7"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[10px] self-start"
|
||||
onClick={handleSaveForwarding}
|
||||
disabled={forwardSaving}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
setHostAuthOverride,
|
||||
} from "@/main-axios";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
|
||||
import {
|
||||
AUTH_PROTOCOL_METADATA,
|
||||
type AuthOverrideProtocol,
|
||||
} from "@/types/auth-protocols";
|
||||
import { mapCredentials } from "./HostManagerData";
|
||||
|
||||
export function HostAuthOverrideModal({
|
||||
@@ -106,7 +109,11 @@ export function HostAuthOverrideModal({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hosts.sharing.authOverrideTitle")}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("hosts.sharing.authOverrideTitleProtocol", {
|
||||
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
ownerAuthShared
|
||||
|
||||
@@ -57,6 +57,11 @@ import {
|
||||
canShareHost,
|
||||
} from "@/sidebar/host-permissions";
|
||||
import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal";
|
||||
import {
|
||||
AUTH_OVERRIDE_PROTOCOLS,
|
||||
AUTH_PROTOCOL_METADATA,
|
||||
type AuthOverrideProtocol,
|
||||
} from "@/types/auth-protocols";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
@@ -340,8 +345,11 @@ export function HostItem({
|
||||
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
|
||||
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
|
||||
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
|
||||
const canOverrideAuth = canOverrideHostAuth(host, "ssh");
|
||||
const [authOverrideOpen, setAuthOverrideOpen] = useState(false);
|
||||
const authOverrideProtocols = AUTH_OVERRIDE_PROTOCOLS.filter((protocol) =>
|
||||
canOverrideHostAuth(host, protocol),
|
||||
);
|
||||
const [authOverrideProtocol, setAuthOverrideProtocol] =
|
||||
useState<AuthOverrideProtocol | null>(null);
|
||||
const [parentDragOver, setParentDragOver] = useState(false);
|
||||
const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{
|
||||
@@ -736,17 +744,20 @@ export function HostItem({
|
||||
<Copy className="size-3.5 mr-2" />
|
||||
{t("hosts.copyAddress")}
|
||||
</DropdownMenuItem>
|
||||
{canOverrideAuth && (
|
||||
{authOverrideProtocols.map((protocol) => (
|
||||
<DropdownMenuItem
|
||||
key={protocol}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAuthOverrideOpen(true);
|
||||
setAuthOverrideProtocol(protocol);
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5 mr-2" />
|
||||
{t("hosts.sharing.authOverrideAction")}
|
||||
{t("hosts.sharing.authOverrideActionProtocol", {
|
||||
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
))}
|
||||
{showPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
@@ -1320,12 +1331,14 @@ export function HostItem({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canOverrideAuth && (
|
||||
{authOverrideProtocol && (
|
||||
<HostAuthOverrideModal
|
||||
open={authOverrideOpen}
|
||||
onOpenChange={setAuthOverrideOpen}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAuthOverrideProtocol(null);
|
||||
}}
|
||||
host={host}
|
||||
protocol="ssh"
|
||||
protocol={authOverrideProtocol}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("HostAuthOverrideModal", () => {
|
||||
});
|
||||
|
||||
describe("canOverrideHostAuth", () => {
|
||||
it("allows every shared SSH permission level and excludes owners and non-SSH hosts", () => {
|
||||
it("allows every shared permission level and excludes owners and disabled protocols", () => {
|
||||
for (const permissionLevel of [
|
||||
"connect",
|
||||
"view",
|
||||
@@ -200,6 +200,7 @@ describe("canOverrideHostAuth", () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOverrideHostAuth({ ...host, enableRdp: true } as Host, "rdp"),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(canOverrideHostAuth(host, "rdp")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user