feat: implement sensitive data redaction for viewer roles
- Introduced SensitiveConnectionValue component to handle redaction of sensitive connection details across the frontend. - Updated API to include canViewServerConnection and canViewEnvironmentValues functions to determine access based on user roles. - Enhanced backend response sanitization to remove sensitive data for viewer roles, ensuring no sensitive information is exposed in API responses. - Applied redaction logic in various components including ServersTable, DomainsTable, and EnvironmentHeader to maintain UI consistency while hiding sensitive information. - Added comprehensive audit documentation outlining the implementation and maintenance rules for sensitive data handling.
This commit is contained in:
1 parent
b12f0f9669
commit
8dac31b71f
15 files changed
+312
-37
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import TablePagination from "@/components/TablePagination";
|
import TablePagination from "@/components/TablePagination";
|
||||||
import { Domain, SslCert } from "@/lib/api";
|
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||||
|
import { canViewServerConnection, Domain, SslCert } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -62,6 +63,7 @@ export default function DomainsTable({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRenew,
|
onRenew,
|
||||||
}: DomainsTableProps) {
|
}: DomainsTableProps) {
|
||||||
|
const isConnectionRedacted = !canViewServerConnection();
|
||||||
if (domains.length === 0) {
|
if (domains.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -186,7 +188,10 @@ export default function DomainsTable({
|
|||||||
color: "var(--text-secondary)",
|
color: "var(--text-secondary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{domain.value}
|
<SensitiveConnectionValue
|
||||||
|
value={domain.value}
|
||||||
|
isRedacted={isConnectionRedacted}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<SSLBadge status={status} />
|
<SSLBadge status={status} />
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||||
import TablePagination from "@/components/TablePagination";
|
import TablePagination from "@/components/TablePagination";
|
||||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
DashboardOverview,
|
DashboardOverview,
|
||||||
DashboardLiveMetrics,
|
DashboardLiveMetrics,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
|
canViewServerConnection,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type { Server as ServerInfo } from "@/lib/api";
|
import type { Server as ServerInfo } from "@/lib/api";
|
||||||
import { useRequireAuth } from "@/lib/auth-state";
|
import { useRequireAuth } from "@/lib/auth-state";
|
||||||
@@ -95,6 +97,7 @@ function formatUpdatedAt(value?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DashboardContent() {
|
function DashboardContent() {
|
||||||
|
const isConnectionRedacted = !canViewServerConnection();
|
||||||
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
||||||
const [serverData, setServerData] = useState<ServerInfo[]>([]);
|
const [serverData, setServerData] = useState<ServerInfo[]>([]);
|
||||||
const [liveMetrics, setLiveMetrics] = useState<DashboardLiveMetrics | null>(
|
const [liveMetrics, setLiveMetrics] = useState<DashboardLiveMetrics | null>(
|
||||||
@@ -625,7 +628,10 @@ function DashboardContent() {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s.ip}
|
<SensitiveConnectionValue
|
||||||
|
value={s.ip}
|
||||||
|
isRedacted={isConnectionRedacted}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight, Layers3 } from "lucide-react";
|
import { ChevronRight, Layers3 } from "lucide-react";
|
||||||
|
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||||
|
import { canViewServerConnection } from "@/lib/api";
|
||||||
import type { EnvironmentSummary } from "../../types/environment-container-types";
|
import type { EnvironmentSummary } from "../../types/environment-container-types";
|
||||||
|
|
||||||
interface EnvironmentHeaderProps {
|
interface EnvironmentHeaderProps {
|
||||||
@@ -11,6 +13,7 @@ export default function EnvironmentHeader({
|
|||||||
projectId,
|
projectId,
|
||||||
summary,
|
summary,
|
||||||
}: EnvironmentHeaderProps) {
|
}: EnvironmentHeaderProps) {
|
||||||
|
const isConnectionRedacted = !canViewServerConnection();
|
||||||
return (
|
return (
|
||||||
<section className="card" style={{ padding: 0, overflow: "hidden" }}>
|
<section className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
<div
|
<div
|
||||||
@@ -93,7 +96,10 @@ export default function EnvironmentHeader({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{summary.environmentKind} environment on {summary.serverName} (
|
{summary.environmentKind} environment on {summary.serverName} (
|
||||||
{summary.serverIp})
|
<SensitiveConnectionValue
|
||||||
|
value={summary.serverIp}
|
||||||
|
isRedacted={isConnectionRedacted}
|
||||||
|
/>)
|
||||||
</p>
|
</p>
|
||||||
<p style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 4 }}>
|
<p style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 4 }}>
|
||||||
Container inventory updated {summary.updatedAt}
|
Container inventory updated {summary.updatedAt}
|
||||||
|
|||||||
+32
-7
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||||
|
import { canViewEnvironmentValues } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -103,6 +104,7 @@ export default function EnvironmentTabPanel({
|
|||||||
environment,
|
environment,
|
||||||
onSaveProjectEnv,
|
onSaveProjectEnv,
|
||||||
}: EnvironmentTabPanelProps) {
|
}: EnvironmentTabPanelProps) {
|
||||||
|
const isViewer = !canViewEnvironmentValues();
|
||||||
const [editorDraft, setEditorDraft] = useState(() => ({
|
const [editorDraft, setEditorDraft] = useState(() => ({
|
||||||
source: environment.editor.content,
|
source: environment.editor.content,
|
||||||
content: environment.editor.content,
|
content: environment.editor.content,
|
||||||
@@ -160,7 +162,7 @@ export default function EnvironmentTabPanel({
|
|||||||
[editorContent],
|
[editorContent],
|
||||||
);
|
);
|
||||||
const editableSource = getEditableSource(environment.editor.source);
|
const editableSource = getEditableSource(environment.editor.source);
|
||||||
const editorDisabled = !environment.editor.found;
|
const editorDisabled = isViewer || !environment.editor.found;
|
||||||
const canSave =
|
const canSave =
|
||||||
Boolean(environment.editor.found && environment.editor.path) &&
|
Boolean(environment.editor.found && environment.editor.path) &&
|
||||||
editableSource !== null;
|
editableSource !== null;
|
||||||
@@ -258,7 +260,11 @@ export default function EnvironmentTabPanel({
|
|||||||
size={30}
|
size={30}
|
||||||
style={{ color: "var(--accent-yellow)" }}
|
style={{ color: "var(--accent-yellow)" }}
|
||||||
/>
|
/>
|
||||||
<strong>Project .env file is unavailable</strong>
|
<strong>
|
||||||
|
{isViewer
|
||||||
|
? "Environment variables are restricted"
|
||||||
|
: "Project .env file is unavailable"}
|
||||||
|
</strong>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
@@ -267,7 +273,9 @@ export default function EnvironmentTabPanel({
|
|||||||
maxWidth: 560,
|
maxWidth: 560,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{environment.editor.message ||
|
{isViewer
|
||||||
|
? "Viewer accounts cannot access environment variable names or values."
|
||||||
|
: environment.editor.message ||
|
||||||
"No project .env file was found in the deployment or mounted project path."}
|
"No project .env file was found in the deployment or mounted project path."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,13 +414,13 @@ export default function EnvironmentTabPanel({
|
|||||||
? "Project .env saved. Restart or rebuild the app to apply the changes."
|
? "Project .env saved. Restart or rebuild the app to apply the changes."
|
||||||
: editorStatus === "validated"
|
: editorStatus === "validated"
|
||||||
? "Draft format checked in the UI."
|
? "Draft format checked in the UI."
|
||||||
: editorDirty
|
: editorDirty
|
||||||
? "You have unsaved editor changes."
|
? "You have unsaved editor changes."
|
||||||
: environment.editor.found
|
: environment.editor.found
|
||||||
? environment.editor.source === "container"
|
? environment.editor.source === "container"
|
||||||
? "Editor is loaded from container filesystem .env."
|
? "Editor is loaded from container filesystem .env."
|
||||||
: "Editor is loaded from project .env file."
|
: "Editor is loaded from project .env file."
|
||||||
: "Editor is disabled because no project .env file was found."}
|
: "Editor is disabled because no project .env file was found."}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@@ -459,6 +467,23 @@ export default function EnvironmentTabPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!environment.editor.found ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 7,
|
||||||
|
background: "rgba(245,158,11,0.1)",
|
||||||
|
color: "var(--accent-yellow)",
|
||||||
|
padding: 11,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{environment.editor.message ||
|
||||||
|
"No project .env file was found. The editor is read-only until a .env file exists in the deployment or mounted project path."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{environment.editor.validation.map((item) => (
|
{environment.editor.validation.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
|||||||
+9
-1
@@ -1,4 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||||
|
import { canViewServerConnection } from "@/lib/api";
|
||||||
import { ChevronRight, Container, Loader2, MoreHorizontal } from "lucide-react";
|
import { ChevronRight, Container, Loader2, MoreHorizontal } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -23,6 +25,7 @@ export default function AppDetailHeader({
|
|||||||
activeAction,
|
activeAction,
|
||||||
onAction,
|
onAction,
|
||||||
}: AppDetailHeaderProps) {
|
}: AppDetailHeaderProps) {
|
||||||
|
const isConnectionRedacted = !canViewServerConnection();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [menuPosition, setMenuPosition] = useState<{
|
const [menuPosition, setMenuPosition] = useState<{
|
||||||
top: number;
|
top: number;
|
||||||
@@ -192,7 +195,12 @@ export default function AppDetailHeader({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{app.path} <span style={{ color: "var(--text-secondary)" }}>.</span>{" "}
|
{app.path} <span style={{ color: "var(--text-secondary)" }}>.</span>{" "}
|
||||||
{app.serverName} ({app.serverIp}){" "}
|
{app.serverName} ({
|
||||||
|
<SensitiveConnectionValue
|
||||||
|
value={app.serverIp}
|
||||||
|
isRedacted={isConnectionRedacted}
|
||||||
|
/>
|
||||||
|
}){" "}
|
||||||
<span style={{ color: "var(--text-secondary)" }}>.</span>{" "}
|
<span style={{ color: "var(--text-secondary)" }}>.</span>{" "}
|
||||||
{app.environmentName}
|
{app.environmentName}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||||
import ToastViewport from "@/components/ToastViewport";
|
import ToastViewport from "@/components/ToastViewport";
|
||||||
import EnvironmentFormModal from "@/app/projects/components/EnvironmentFormModal";
|
import EnvironmentFormModal from "@/app/projects/components/EnvironmentFormModal";
|
||||||
import EnvironmentOpsModal from "@/app/projects/components/EnvironmentOpsModal";
|
import EnvironmentOpsModal from "@/app/projects/components/EnvironmentOpsModal";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
projectsApi,
|
projectsApi,
|
||||||
type Server,
|
type Server,
|
||||||
servers,
|
servers,
|
||||||
|
canViewServerConnection,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useToastManager } from "@/lib/use-toast-manager";
|
import { useToastManager } from "@/lib/use-toast-manager";
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ function formatKindLabel(kind: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
|
const isConnectionRedacted = !canViewServerConnection();
|
||||||
const params = useParams<{ projectId: string }>();
|
const params = useParams<{ projectId: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const projectId = params.projectId;
|
const projectId = params.projectId;
|
||||||
@@ -186,7 +189,7 @@ export default function ProjectDetailPage() {
|
|||||||
environment.kind.toLowerCase().includes(query) ||
|
environment.kind.toLowerCase().includes(query) ||
|
||||||
(environment.description ?? "").toLowerCase().includes(query) ||
|
(environment.description ?? "").toLowerCase().includes(query) ||
|
||||||
environment.server.name.toLowerCase().includes(query) ||
|
environment.server.name.toLowerCase().includes(query) ||
|
||||||
environment.server.ip.toLowerCase().includes(query)
|
environment.server.ip?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [project, query]);
|
}, [project, query]);
|
||||||
@@ -612,7 +615,14 @@ export default function ProjectDetailPage() {
|
|||||||
{meta.label === "Server IP" ? (
|
{meta.label === "Server IP" ? (
|
||||||
<ServerIcon size={12} />
|
<ServerIcon size={12} />
|
||||||
) : null}
|
) : null}
|
||||||
{meta.value}
|
{meta.label === "Server IP" ? (
|
||||||
|
<SensitiveConnectionValue
|
||||||
|
value={String(meta.value ?? "")}
|
||||||
|
isRedacted={isConnectionRedacted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
meta.value
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import TablePagination from "@/components/TablePagination";
|
import TablePagination from "@/components/TablePagination";
|
||||||
|
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||||
import type {
|
import type {
|
||||||
DockerRuntimeStatus,
|
DockerRuntimeStatus,
|
||||||
Server as ServerType,
|
Server as ServerType,
|
||||||
ServerMetric,
|
ServerMetric,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { canViewServerConnection } from "@/lib/api";
|
||||||
import { Fragment, useCallback, useState } from "react";
|
import { Fragment, useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -84,6 +86,7 @@ export default function ServersTable({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ServersTableProps) {
|
}: ServersTableProps) {
|
||||||
|
const showConnectionDetails = canViewServerConnection();
|
||||||
const [menuPosition, setMenuPosition] = useState<{
|
const [menuPosition, setMenuPosition] = useState<{
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
@@ -210,7 +213,10 @@ export default function ServersTable({
|
|||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{server.ip}:{server.sshPort}
|
<SensitiveConnectionValue
|
||||||
|
value={`${server.ip}:${server.sshPort}`}
|
||||||
|
isRedacted={!showConnectionDetails}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,7 +391,7 @@ export default function ServersTable({
|
|||||||
<RefreshCw size={12} />
|
<RefreshCw size={12} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{showConnectionDetails && <button
|
||||||
title="Test SSH"
|
title="Test SSH"
|
||||||
onClick={() => void onTestConnection(server.id)}
|
onClick={() => void onTestConnection(server.id)}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
@@ -397,15 +403,15 @@ export default function ServersTable({
|
|||||||
) : (
|
) : (
|
||||||
<Wifi size={12} />
|
<Wifi size={12} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>}
|
||||||
<button
|
{showConnectionDetails && <button
|
||||||
title="Terminal"
|
title="Terminal"
|
||||||
onClick={() => onOpenTerminal(server)}
|
onClick={() => onOpenTerminal(server)}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||||
>
|
>
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
</button>
|
</button>}
|
||||||
{canInstallDocker ? (
|
{canInstallDocker ? (
|
||||||
<button
|
<button
|
||||||
title="Install Docker"
|
title="Install Docker"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
interface SensitiveConnectionValueProps {
|
||||||
|
value?: string | null;
|
||||||
|
isRedacted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps connection fields discoverable without exposing their values to viewers. */
|
||||||
|
export default function SensitiveConnectionValue({
|
||||||
|
value,
|
||||||
|
isRedacted,
|
||||||
|
}: SensitiveConnectionValueProps) {
|
||||||
|
if (!isRedacted) {
|
||||||
|
return <>{value || "—"}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="Connection address hidden for viewer"
|
||||||
|
title="Connection address hidden"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 7px",
|
||||||
|
background: "var(--bg-input)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
••••••••••••
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type ApplicationRole =
|
||||||
|
| "SUPER_ADMIN"
|
||||||
|
| "OPERATOR"
|
||||||
|
| "DEVELOPER"
|
||||||
|
| "VIEWER";
|
||||||
|
|
||||||
|
const SENSITIVE_DATA_ROLES = new Set<ApplicationRole>([
|
||||||
|
"SUPER_ADMIN",
|
||||||
|
"OPERATOR",
|
||||||
|
"DEVELOPER",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Unknown roles are intentionally treated as least-privileged. */
|
||||||
|
export function canViewServerConnectionForRole(
|
||||||
|
role: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return SENSITIVE_DATA_ROLES.has(role as ApplicationRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canViewEnvironmentValuesForRole(
|
||||||
|
role: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return SENSITIVE_DATA_ROLES.has(role as ApplicationRole);
|
||||||
|
}
|
||||||
+25
-9
@@ -20,6 +20,10 @@ import {
|
|||||||
setStoredOrganizationId,
|
setStoredOrganizationId,
|
||||||
} from "@/lib/organization-state";
|
} from "@/lib/organization-state";
|
||||||
import { parseServerSentEventBlock } from "@/lib/server-sent-events";
|
import { parseServerSentEventBlock } from "@/lib/server-sent-events";
|
||||||
|
import {
|
||||||
|
canViewEnvironmentValuesForRole,
|
||||||
|
canViewServerConnectionForRole,
|
||||||
|
} from "@/lib/access-policy";
|
||||||
|
|
||||||
const DEFAULT_API_PORT = process.env.NEXT_PUBLIC_API_PORT || "4000";
|
const DEFAULT_API_PORT = process.env.NEXT_PUBLIC_API_PORT || "4000";
|
||||||
|
|
||||||
@@ -572,7 +576,8 @@ export function formatApiErrorValue(value: unknown): string {
|
|||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
.filter(
|
.filter(
|
||||||
(item): item is string => typeof item === "string" && item.trim() !== "",
|
(item): item is string =>
|
||||||
|
typeof item === "string" && item.trim() !== "",
|
||||||
)
|
)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
@@ -590,13 +595,14 @@ export function formatApiErrorValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error.fieldErrors && typeof error.fieldErrors === "object") {
|
if (error.fieldErrors && typeof error.fieldErrors === "object") {
|
||||||
const fieldErrors = Object.values(error.fieldErrors).flatMap((messages) =>
|
const fieldErrors = Object.values(error.fieldErrors).flatMap(
|
||||||
Array.isArray(messages)
|
(messages) =>
|
||||||
? messages.filter(
|
Array.isArray(messages)
|
||||||
(message): message is string =>
|
? messages.filter(
|
||||||
typeof message === "string" && message.trim() !== "",
|
(message): message is string =>
|
||||||
)
|
typeof message === "string" && message.trim() !== "",
|
||||||
: [],
|
)
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fieldErrors.length > 0) {
|
if (fieldErrors.length > 0) {
|
||||||
@@ -610,6 +616,15 @@ export function formatApiErrorValue(value: unknown): string {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SSH connection metadata is intentionally unavailable to viewer accounts. */
|
||||||
|
export function canViewServerConnection(): boolean {
|
||||||
|
return canViewServerConnectionForRole(getUser()?.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canViewEnvironmentValues(): boolean {
|
||||||
|
return canViewEnvironmentValuesForRole(getUser()?.role);
|
||||||
|
}
|
||||||
|
|
||||||
function buildResponseFallbackMessage(response: Response, fallback: string) {
|
function buildResponseFallbackMessage(response: Response, fallback: string) {
|
||||||
if (response.status >= 500) {
|
if (response.status >= 500) {
|
||||||
return "Server returned an internal error. Please try again or check the backend logs.";
|
return "Server returned an internal error. Please try again or check the backend logs.";
|
||||||
@@ -654,7 +669,8 @@ async function readApiErrorMessage(
|
|||||||
fallbackMessage,
|
fallbackMessage,
|
||||||
);
|
);
|
||||||
const message =
|
const message =
|
||||||
formatApiErrorValue(payload.error) || formatApiErrorValue(payload.message);
|
formatApiErrorValue(payload.error) ||
|
||||||
|
formatApiErrorValue(payload.message);
|
||||||
|
|
||||||
return message || buildResponseFallbackMessage(response, fallbackMessage);
|
return message || buildResponseFallbackMessage(response, fallbackMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+3
-2
@@ -7,6 +7,7 @@ import multipart from "@fastify/multipart";
|
|||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
import prisma from "./lib/prisma";
|
import prisma from "./lib/prisma";
|
||||||
import { validateEncryptionConfiguration } from "./lib/crypto";
|
import { validateEncryptionConfiguration } from "./lib/crypto";
|
||||||
|
import { sanitizeViewerResponsePayload } from "./lib/viewer-response-sanitizer";
|
||||||
|
|
||||||
import { authRoutes } from "./routes/auth";
|
import { authRoutes } from "./routes/auth";
|
||||||
import { serverRoutes } from "./routes/servers";
|
import { serverRoutes } from "./routes/servers";
|
||||||
@@ -65,7 +66,7 @@ function getCorsOrigins(env = process.env): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultSecurityHeaders() {
|
function setDefaultSecurityHeaders() {
|
||||||
app.addHook("onSend", async (_request, reply, payload) => {
|
app.addHook("onSend", async (request, reply, payload) => {
|
||||||
reply.header("X-Content-Type-Options", "nosniff");
|
reply.header("X-Content-Type-Options", "nosniff");
|
||||||
reply.header("X-Frame-Options", "DENY");
|
reply.header("X-Frame-Options", "DENY");
|
||||||
reply.header("Referrer-Policy", "no-referrer");
|
reply.header("Referrer-Policy", "no-referrer");
|
||||||
@@ -76,7 +77,7 @@ function setDefaultSecurityHeaders() {
|
|||||||
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||||
reply.header("X-DNS-Prefetch-Control", "off");
|
reply.header("X-DNS-Prefetch-Control", "off");
|
||||||
return payload;
|
return sanitizeViewerResponsePayload(request, payload);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const SENSITIVE_DATA_ROLES = new Set([
|
||||||
|
"SUPER_ADMIN",
|
||||||
|
"OPERATOR",
|
||||||
|
"DEVELOPER",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Unknown roles are intentionally treated as least-privileged. */
|
||||||
|
export function canViewSensitiveServerData(
|
||||||
|
role: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(role && SENSITIVE_DATA_ROLES.has(role));
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { canViewSensitiveServerData } from "./access-policy";
|
||||||
|
|
||||||
|
const SERVER_CONNECTION_FIELDS = new Set([
|
||||||
|
"ip",
|
||||||
|
"sshPort",
|
||||||
|
"username",
|
||||||
|
"authType",
|
||||||
|
"sshKeyEnc",
|
||||||
|
"passwordEnc",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CONTAINER_ENVIRONMENT_FIELDS = new Set(["envVars"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes SSH connection details from JSON API responses for read-only users.
|
||||||
|
*
|
||||||
|
* This is deliberately performed at the response boundary so a newly added
|
||||||
|
* endpoint cannot accidentally expose a nested `server.ip` to a viewer.
|
||||||
|
*/
|
||||||
|
export function sanitizeViewerResponsePayload(
|
||||||
|
request: FastifyRequest,
|
||||||
|
payload: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (canViewSensitiveServerData(request.userRole) || typeof payload !== "string") {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isServersEndpoint = request.url.startsWith("/api/v1/servers");
|
||||||
|
|
||||||
|
const sanitize = (
|
||||||
|
value: unknown,
|
||||||
|
isServer: boolean,
|
||||||
|
isContainerInspect = false,
|
||||||
|
): unknown => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => sanitize(item, isServer, isContainerInspect));
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(record).flatMap(([key, child]) => {
|
||||||
|
// serverIp is the flattened representation used by environment and
|
||||||
|
// terminal payloads; it is always an SSH server connection address.
|
||||||
|
if (key === "serverIp") return [];
|
||||||
|
// These values can contain credentials and application configuration.
|
||||||
|
// Preserve the shape expected by read-only UI while returning no
|
||||||
|
// environment variable names or values.
|
||||||
|
if (CONTAINER_ENVIRONMENT_FIELDS.has(key)) return [[key, []]];
|
||||||
|
if (isContainerInspect && key === "Env") return [];
|
||||||
|
|
||||||
|
const childIsServer =
|
||||||
|
isServer || key === "server" || key === "targetServer";
|
||||||
|
if (childIsServer && SERVER_CONNECTION_FIELDS.has(key)) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
sanitize(child, childIsServer, isContainerInspect || key === "inspect"),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// /servers returns server records directly under `data`, rather than under
|
||||||
|
// a `server` property as the other APIs do.
|
||||||
|
const sanitized = sanitize(parsed, false) as Record<string, unknown>;
|
||||||
|
if (isServersEndpoint && "data" in sanitized) {
|
||||||
|
sanitized.data = sanitize(sanitized.data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(sanitized);
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
RepositoryVisibility,
|
RepositoryVisibility,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { decrypt, encrypt } from "../lib/crypto";
|
import { decrypt, encrypt } from "../lib/crypto";
|
||||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
import {
|
||||||
|
authenticate,
|
||||||
|
requireApiKeyPermission,
|
||||||
|
requireRole,
|
||||||
|
} from "../middleware/auth";
|
||||||
import { auditLog } from "../services/audit.service";
|
import { auditLog } from "../services/audit.service";
|
||||||
import { dispatchRuntimeNotification } from "../services/notification.service";
|
import { dispatchRuntimeNotification } from "../services/notification.service";
|
||||||
import {
|
import {
|
||||||
@@ -2150,7 +2154,7 @@ export async function containerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/:id/project-env",
|
"/:id/project-env",
|
||||||
{ preHandler: containerReadAccess },
|
{ preHandler: [...containerReadAccess, requireRole("DEVELOPER")] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const container = await prisma.container.findFirst({
|
const container = await prisma.container.findFirst({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
import { canViewSensitiveServerData } from "../lib/access-policy";
|
||||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||||
import { auditLog } from "../services/audit.service";
|
import { auditLog } from "../services/audit.service";
|
||||||
import {
|
import {
|
||||||
@@ -1303,6 +1304,17 @@ function toDomainUpdateData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeDomainForViewer<
|
||||||
|
T extends { value: string; server?: { ip?: string | null } | null },
|
||||||
|
>(domain: T, isViewer: boolean): Omit<T, "value"> | T {
|
||||||
|
if (!isViewer || domain.value.trim() !== domain.server?.ip?.trim()) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value: _connectionIp, ...safeDomain } = domain;
|
||||||
|
return safeDomain;
|
||||||
|
}
|
||||||
|
|
||||||
export async function domainRoutes(app: FastifyInstance) {
|
export async function domainRoutes(app: FastifyInstance) {
|
||||||
// GET /domains
|
// GET /domains
|
||||||
app.get("/", { preHandler: domainReadAccess }, async (req, reply) => {
|
app.get("/", { preHandler: domainReadAccess }, async (req, reply) => {
|
||||||
@@ -1310,12 +1322,20 @@ export async function domainRoutes(app: FastifyInstance) {
|
|||||||
where: { organizationId: req.organizationId! },
|
where: { organizationId: req.organizationId! },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
server: { select: { name: true } },
|
server: { select: { name: true, ip: true } },
|
||||||
targetContainer: { select: { id: true, name: true, image: true } },
|
targetContainer: { select: { id: true, name: true, image: true } },
|
||||||
sslCert: true,
|
sslCert: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return reply.send({ success: true, data: domains });
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: domains.map((domain) =>
|
||||||
|
serializeDomainForViewer(
|
||||||
|
domain,
|
||||||
|
!canViewSensitiveServerData(req.userRole),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/sync", { preHandler: domainWriteAccess }, async (req, reply) => {
|
app.post("/sync", { preHandler: domainWriteAccess }, async (req, reply) => {
|
||||||
@@ -1501,13 +1521,22 @@ export async function domainRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
server: { select: { name: true } },
|
server: { select: { name: true, ip: true } },
|
||||||
targetContainer: { select: { id: true, name: true, image: true } },
|
targetContainer: { select: { id: true, name: true, image: true } },
|
||||||
sslCert: true,
|
sslCert: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.send({ success: true, data: domains, meta: { summary } });
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: domains.map((domain) =>
|
||||||
|
serializeDomainForViewer(
|
||||||
|
domain,
|
||||||
|
!canViewSensitiveServerData(req.userRole),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
meta: { summary },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /domains/:id
|
// GET /domains/:id
|
||||||
@@ -1525,7 +1554,13 @@ export async function domainRoutes(app: FastifyInstance) {
|
|||||||
return reply
|
return reply
|
||||||
.status(404)
|
.status(404)
|
||||||
.send({ success: false, error: "Domain not found" });
|
.send({ success: false, error: "Domain not found" });
|
||||||
return reply.send({ success: true, data: domain });
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: serializeDomainForViewer(
|
||||||
|
domain,
|
||||||
|
!canViewSensitiveServerData(req.userRole),
|
||||||
|
),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /domains
|
// POST /domains
|
||||||
|
|||||||
Reference in new issue
Block a user