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 { Domain, SslCert } from "@/lib/api";
|
||||
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||
import { canViewServerConnection, Domain, SslCert } from "@/lib/api";
|
||||
import {
|
||||
ExternalLink,
|
||||
Globe,
|
||||
@@ -62,6 +63,7 @@ export default function DomainsTable({
|
||||
onDelete,
|
||||
onRenew,
|
||||
}: DomainsTableProps) {
|
||||
const isConnectionRedacted = !canViewServerConnection();
|
||||
if (domains.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -186,7 +188,10 @@ export default function DomainsTable({
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{domain.value}
|
||||
<SensitiveConnectionValue
|
||||
value={domain.value}
|
||||
isRedacted={isConnectionRedacted}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<SSLBadge status={status} />
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
DashboardOverview,
|
||||
DashboardLiveMetrics,
|
||||
AuditLog,
|
||||
canViewServerConnection,
|
||||
} from "@/lib/api";
|
||||
import type { Server as ServerInfo } from "@/lib/api";
|
||||
import { useRequireAuth } from "@/lib/auth-state";
|
||||
@@ -95,6 +97,7 @@ function formatUpdatedAt(value?: string) {
|
||||
}
|
||||
|
||||
function DashboardContent() {
|
||||
const isConnectionRedacted = !canViewServerConnection();
|
||||
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
||||
const [serverData, setServerData] = useState<ServerInfo[]>([]);
|
||||
const [liveMetrics, setLiveMetrics] = useState<DashboardLiveMetrics | null>(
|
||||
@@ -625,7 +628,10 @@ function DashboardContent() {
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{s.ip}
|
||||
<SensitiveConnectionValue
|
||||
value={s.ip}
|
||||
isRedacted={isConnectionRedacted}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Layers3 } from "lucide-react";
|
||||
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||
import { canViewServerConnection } from "@/lib/api";
|
||||
import type { EnvironmentSummary } from "../../types/environment-container-types";
|
||||
|
||||
interface EnvironmentHeaderProps {
|
||||
@@ -11,6 +13,7 @@ export default function EnvironmentHeader({
|
||||
projectId,
|
||||
summary,
|
||||
}: EnvironmentHeaderProps) {
|
||||
const isConnectionRedacted = !canViewServerConnection();
|
||||
return (
|
||||
<section className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
@@ -93,7 +96,10 @@ export default function EnvironmentHeader({
|
||||
}}
|
||||
>
|
||||
{summary.environmentKind} environment on {summary.serverName} (
|
||||
{summary.serverIp})
|
||||
<SensitiveConnectionValue
|
||||
value={summary.serverIp}
|
||||
isRedacted={isConnectionRedacted}
|
||||
/>)
|
||||
</p>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 4 }}>
|
||||
Container inventory updated {summary.updatedAt}
|
||||
|
||||
+32
-7
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||
import { canViewEnvironmentValues } from "@/lib/api";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
@@ -103,6 +104,7 @@ export default function EnvironmentTabPanel({
|
||||
environment,
|
||||
onSaveProjectEnv,
|
||||
}: EnvironmentTabPanelProps) {
|
||||
const isViewer = !canViewEnvironmentValues();
|
||||
const [editorDraft, setEditorDraft] = useState(() => ({
|
||||
source: environment.editor.content,
|
||||
content: environment.editor.content,
|
||||
@@ -160,7 +162,7 @@ export default function EnvironmentTabPanel({
|
||||
[editorContent],
|
||||
);
|
||||
const editableSource = getEditableSource(environment.editor.source);
|
||||
const editorDisabled = !environment.editor.found;
|
||||
const editorDisabled = isViewer || !environment.editor.found;
|
||||
const canSave =
|
||||
Boolean(environment.editor.found && environment.editor.path) &&
|
||||
editableSource !== null;
|
||||
@@ -258,7 +260,11 @@ export default function EnvironmentTabPanel({
|
||||
size={30}
|
||||
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
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
@@ -267,7 +273,9 @@ export default function EnvironmentTabPanel({
|
||||
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."}
|
||||
</span>
|
||||
</div>
|
||||
@@ -406,13 +414,13 @@ export default function EnvironmentTabPanel({
|
||||
? "Project .env saved. Restart or rebuild the app to apply the changes."
|
||||
: editorStatus === "validated"
|
||||
? "Draft format checked in the UI."
|
||||
: editorDirty
|
||||
? "You have unsaved editor changes."
|
||||
: environment.editor.found
|
||||
: editorDirty
|
||||
? "You have unsaved editor changes."
|
||||
: environment.editor.found
|
||||
? environment.editor.source === "container"
|
||||
? "Editor is loaded from container filesystem .env."
|
||||
: "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
|
||||
style={{
|
||||
@@ -459,6 +467,23 @@ export default function EnvironmentTabPanel({
|
||||
</div>
|
||||
) : 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) => (
|
||||
<div
|
||||
key={item.label}
|
||||
|
||||
+9
-1
@@ -1,4 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||
import { canViewServerConnection } from "@/lib/api";
|
||||
import { ChevronRight, Container, Loader2, MoreHorizontal } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -23,6 +25,7 @@ export default function AppDetailHeader({
|
||||
activeAction,
|
||||
onAction,
|
||||
}: AppDetailHeaderProps) {
|
||||
const isConnectionRedacted = !canViewServerConnection();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
top: number;
|
||||
@@ -192,7 +195,12 @@ export default function AppDetailHeader({
|
||||
}}
|
||||
>
|
||||
{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>{" "}
|
||||
{app.environmentName}
|
||||
</p>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import EnvironmentFormModal from "@/app/projects/components/EnvironmentFormModal";
|
||||
import EnvironmentOpsModal from "@/app/projects/components/EnvironmentOpsModal";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
projectsApi,
|
||||
type Server,
|
||||
servers,
|
||||
canViewServerConnection,
|
||||
} from "@/lib/api";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
|
||||
@@ -44,6 +46,7 @@ function formatKindLabel(kind: string) {
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const isConnectionRedacted = !canViewServerConnection();
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const router = useRouter();
|
||||
const projectId = params.projectId;
|
||||
@@ -186,7 +189,7 @@ export default function ProjectDetailPage() {
|
||||
environment.kind.toLowerCase().includes(query) ||
|
||||
(environment.description ?? "").toLowerCase().includes(query) ||
|
||||
environment.server.name.toLowerCase().includes(query) ||
|
||||
environment.server.ip.toLowerCase().includes(query)
|
||||
environment.server.ip?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [project, query]);
|
||||
@@ -612,7 +615,14 @@ export default function ProjectDetailPage() {
|
||||
{meta.label === "Server IP" ? (
|
||||
<ServerIcon size={12} />
|
||||
) : null}
|
||||
{meta.value}
|
||||
{meta.label === "Server IP" ? (
|
||||
<SensitiveConnectionValue
|
||||
value={String(meta.value ?? "")}
|
||||
isRedacted={isConnectionRedacted}
|
||||
/>
|
||||
) : (
|
||||
meta.value
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import SensitiveConnectionValue from "@/components/SensitiveConnectionValue";
|
||||
import type {
|
||||
DockerRuntimeStatus,
|
||||
Server as ServerType,
|
||||
ServerMetric,
|
||||
} from "@/lib/api";
|
||||
import { canViewServerConnection } from "@/lib/api";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -84,6 +86,7 @@ export default function ServersTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ServersTableProps) {
|
||||
const showConnectionDetails = canViewServerConnection();
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
@@ -210,7 +213,10 @@ export default function ServersTable({
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{server.ip}:{server.sshPort}
|
||||
<SensitiveConnectionValue
|
||||
value={`${server.ip}:${server.sshPort}`}
|
||||
isRedacted={!showConnectionDetails}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,7 +391,7 @@ export default function ServersTable({
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
{showConnectionDetails && <button
|
||||
title="Test SSH"
|
||||
onClick={() => void onTestConnection(server.id)}
|
||||
className="btn btn-ghost"
|
||||
@@ -397,15 +403,15 @@ export default function ServersTable({
|
||||
) : (
|
||||
<Wifi size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
</button>}
|
||||
{showConnectionDetails && <button
|
||||
title="Terminal"
|
||||
onClick={() => onOpenTerminal(server)}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||
>
|
||||
<Terminal size={12} />
|
||||
</button>
|
||||
</button>}
|
||||
{canInstallDocker ? (
|
||||
<button
|
||||
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,
|
||||
} from "@/lib/organization-state";
|
||||
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";
|
||||
|
||||
@@ -572,7 +576,8 @@ export function formatApiErrorValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter(
|
||||
(item): item is string => typeof item === "string" && item.trim() !== "",
|
||||
(item): item is string =>
|
||||
typeof item === "string" && item.trim() !== "",
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -590,13 +595,14 @@ export function formatApiErrorValue(value: unknown): string {
|
||||
}
|
||||
|
||||
if (error.fieldErrors && typeof error.fieldErrors === "object") {
|
||||
const fieldErrors = Object.values(error.fieldErrors).flatMap((messages) =>
|
||||
Array.isArray(messages)
|
||||
? messages.filter(
|
||||
(message): message is string =>
|
||||
typeof message === "string" && message.trim() !== "",
|
||||
)
|
||||
: [],
|
||||
const fieldErrors = Object.values(error.fieldErrors).flatMap(
|
||||
(messages) =>
|
||||
Array.isArray(messages)
|
||||
? messages.filter(
|
||||
(message): message is string =>
|
||||
typeof message === "string" && message.trim() !== "",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
|
||||
if (fieldErrors.length > 0) {
|
||||
@@ -610,6 +616,15 @@ export function formatApiErrorValue(value: unknown): string {
|
||||
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) {
|
||||
if (response.status >= 500) {
|
||||
return "Server returned an internal error. Please try again or check the backend logs.";
|
||||
@@ -654,7 +669,8 @@ async function readApiErrorMessage(
|
||||
fallbackMessage,
|
||||
);
|
||||
const message =
|
||||
formatApiErrorValue(payload.error) || formatApiErrorValue(payload.message);
|
||||
formatApiErrorValue(payload.error) ||
|
||||
formatApiErrorValue(payload.message);
|
||||
|
||||
return message || buildResponseFallbackMessage(response, fallbackMessage);
|
||||
} catch (error) {
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import multipart from "@fastify/multipart";
|
||||
import websocket from "@fastify/websocket";
|
||||
import prisma from "./lib/prisma";
|
||||
import { validateEncryptionConfiguration } from "./lib/crypto";
|
||||
import { sanitizeViewerResponsePayload } from "./lib/viewer-response-sanitizer";
|
||||
|
||||
import { authRoutes } from "./routes/auth";
|
||||
import { serverRoutes } from "./routes/servers";
|
||||
@@ -65,7 +66,7 @@ function getCorsOrigins(env = process.env): string[] {
|
||||
}
|
||||
|
||||
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-Frame-Options", "DENY");
|
||||
reply.header("Referrer-Policy", "no-referrer");
|
||||
@@ -76,7 +77,7 @@ function setDefaultSecurityHeaders() {
|
||||
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||
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,
|
||||
} from "@prisma/client";
|
||||
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 { dispatchRuntimeNotification } from "../services/notification.service";
|
||||
import {
|
||||
@@ -2150,7 +2154,7 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get(
|
||||
"/:id/project-env",
|
||||
{ preHandler: containerReadAccess },
|
||||
{ preHandler: [...containerReadAccess, requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const container = await prisma.container.findFirst({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Server,
|
||||
} from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { canViewSensitiveServerData } from "../lib/access-policy";
|
||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
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) {
|
||||
// GET /domains
|
||||
app.get("/", { preHandler: domainReadAccess }, async (req, reply) => {
|
||||
@@ -1310,12 +1322,20 @@ export async function domainRoutes(app: FastifyInstance) {
|
||||
where: { organizationId: req.organizationId! },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
server: { select: { name: true } },
|
||||
server: { select: { name: true, ip: true } },
|
||||
targetContainer: { select: { id: true, name: true, image: 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) => {
|
||||
@@ -1501,13 +1521,22 @@ export async function domainRoutes(app: FastifyInstance) {
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
server: { select: { name: true } },
|
||||
server: { select: { name: true, ip: true } },
|
||||
targetContainer: { select: { id: true, name: true, image: 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
|
||||
@@ -1525,7 +1554,13 @@ export async function domainRoutes(app: FastifyInstance) {
|
||||
return reply
|
||||
.status(404)
|
||||
.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
|
||||
|
||||
Reference in new issue
Block a user