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:
KodekaTeam committed 2026-08-09 00:37:15 +07:00
1 parent b12f0f9669
commit 8dac31b71f
15 files changed
+312 -37

No files matched your search

+7 -2
View File
@@ -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
View File
@@ -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>
@@ -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}
@@ -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}
@@ -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>
+12 -2
View File
@@ -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>
))} ))}
+11 -5
View File
@@ -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>
);
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}); });
} }
+12
View File
@@ -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);
}
+6 -2
View File
@@ -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({
+40 -5
View File
@@ -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