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