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:
ZacharyZcR
2026-08-24 19:47:29 +08:00
committed by GitHub
parent 69002e6416
commit f3a1087f51
45 changed files with 1262 additions and 115 deletions
+30
View File
@@ -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");
}
}
+35
View File
@@ -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
View File
@@ -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",
+75
View File
@@ -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">
+9 -2
View File
@@ -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
+23 -10
View File
@@ -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);
});
});