diff --git a/src/app/domains/components/DomainsTable.tsx b/src/app/domains/components/DomainsTable.tsx
index 1ef7328..a9d6374 100644
--- a/src/app/domains/components/DomainsTable.tsx
+++ b/src/app/domains/components/DomainsTable.tsx
@@ -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}
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f51a4da..5e20b98 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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(null);
const [serverData, setServerData] = useState([]);
const [liveMetrics, setLiveMetrics] = useState(
@@ -625,7 +628,10 @@ function DashboardContent() {
margin: 0,
}}
>
- {s.ip}
+
diff --git a/src/app/projects/[projectId]/environments/[environmentId]/components/header/EnvironmentHeader.tsx b/src/app/projects/[projectId]/environments/[environmentId]/components/header/EnvironmentHeader.tsx
index 0d71a9c..faf460d 100644
--- a/src/app/projects/[projectId]/environments/[environmentId]/components/header/EnvironmentHeader.tsx
+++ b/src/app/projects/[projectId]/environments/[environmentId]/components/header/EnvironmentHeader.tsx
@@ -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 (
{summary.environmentKind} environment on {summary.serverName} (
- {summary.serverIp})
+
)
Container inventory updated {summary.updatedAt}
diff --git a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/environment/EnvironmentTabPanel.tsx b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/environment/EnvironmentTabPanel.tsx
index c617b72..d3e811f 100644
--- a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/environment/EnvironmentTabPanel.tsx
+++ b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/environment/EnvironmentTabPanel.tsx
@@ -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)" }}
/>
- Project .env file is unavailable
+
+ {isViewer
+ ? "Environment variables are restricted"
+ : "Project .env file is unavailable"}
+
- {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."}
@@ -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."}
) : null}
+ {!environment.editor.found ? (
+
+ {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."}
+
+ ) : null}
+
{environment.editor.validation.map((item) => (
{app.path} . {" "}
- {app.serverName} ({app.serverIp}){" "}
+ {app.serverName} ({
+
+ }){" "}
. {" "}
{app.environmentName}
diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx
index 07d5d8a..d7b2925 100644
--- a/src/app/projects/[projectId]/page.tsx
+++ b/src/app/projects/[projectId]/page.tsx
@@ -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" ? (
) : null}
- {meta.value}
+ {meta.label === "Server IP" ? (
+
+ ) : (
+ meta.value
+ )}
))}
diff --git a/src/app/servers/components/ServersTable.tsx b/src/app/servers/components/ServersTable.tsx
index 0df3c8f..293bb2c 100644
--- a/src/app/servers/components/ServersTable.tsx
+++ b/src/app/servers/components/ServersTable.tsx
@@ -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}
+
@@ -385,7 +391,7 @@ export default function ServersTable({
)}
- void onTestConnection(server.id)}
className="btn btn-ghost"
@@ -397,15 +403,15 @@ export default function ServersTable({
) : (
)}
-
- }
+ {showConnectionDetails && onOpenTerminal(server)}
className="btn btn-ghost"
style={{ padding: "4px 8px", fontSize: 11 }}
>
-
+ }
{canInstallDocker ? (
{value || "—"}>;
+ }
+
+ return (
+
+ ••••••••••••
+
+ );
+}
diff --git a/src/lib/access-policy.ts b/src/lib/access-policy.ts
new file mode 100644
index 0000000..c4f6f6c
--- /dev/null
+++ b/src/lib/access-policy.ts
@@ -0,0 +1,24 @@
+export type ApplicationRole =
+ | "SUPER_ADMIN"
+ | "OPERATOR"
+ | "DEVELOPER"
+ | "VIEWER";
+
+const SENSITIVE_DATA_ROLES = new Set([
+ "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);
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index afa58af..729be7e 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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) {
diff --git a/src/server/index.ts b/src/server/index.ts
index 6263d12..4ee3034 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -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);
});
}
diff --git a/src/server/lib/access-policy.ts b/src/server/lib/access-policy.ts
new file mode 100644
index 0000000..b9ffd13
--- /dev/null
+++ b/src/server/lib/access-policy.ts
@@ -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));
+}
diff --git a/src/server/lib/viewer-response-sanitizer.ts b/src/server/lib/viewer-response-sanitizer.ts
new file mode 100644
index 0000000..9f2322c
--- /dev/null
+++ b/src/server/lib/viewer-response-sanitizer.ts
@@ -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;
+ 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;
+ if (isServersEndpoint && "data" in sanitized) {
+ sanitized.data = sanitize(sanitized.data, true);
+ }
+
+ return JSON.stringify(sanitized);
+}
diff --git a/src/server/routes/containers.ts b/src/server/routes/containers.ts
index fe6e1d9..b72993d 100644
--- a/src/server/routes/containers.ts
+++ b/src/server/routes/containers.ts
@@ -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({
diff --git a/src/server/routes/domains.ts b/src/server/routes/domains.ts
index 524f944..fd1374e 100644
--- a/src/server/routes/domains.ts
+++ b/src/server/routes/domains.ts
@@ -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 {
+ 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