feat(servers): Added features for adding host users and secure group management.

This commit is contained in:
KodekaTeam committed 2026-07-20 01:50:54 +07:00
1 parent 9486f68022
commit e1d09fdf46
10 files changed
+2549 -546

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -46,6 +46,38 @@ export type ServerPendingConfirm =
buildCache: boolean;
};
}
| {
kind: "system-user";
username: string;
groups: string[];
privileged: boolean;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-group";
groupName: string;
privileged: boolean;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-user-update";
username: string;
groups: string[];
shell: string;
expectedGroups: string[];
expectedShell: string;
privileged: boolean;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "docker";
action: "install" | "uninstall" | "reinstall";
@@ -407,6 +439,7 @@ export function createUnavailableServerConfigSnapshot(
users: [],
rootUser: null,
nonRootUsers: [],
systemGroups: [],
hasRootUser: false,
sudoNonInteractive: false,
docker: {
@@ -1,6 +1,7 @@
"use client";
import { KeyRound } from "lucide-react";
import type { ReactNode } from "react";
import { LockKeyhole, Pencil, ShieldCheck } from "lucide-react";
import type { ServerSystemUser } from "@/lib/api";
import type { UserBadgeTone } from "@/app/servers/components/server-config-utils";
@@ -99,7 +100,19 @@ export function ServiceStatusBadge({ state }: { state: string }) {
return <UserBadge label={state} tone={tone} />;
}
export function ServerUserCard({ user }: { user: ServerSystemUser }) {
export function ServerUserCard({
user,
canEdit = false,
isEditing = false,
onEdit,
children,
}: {
user: ServerSystemUser;
canEdit?: boolean;
isEditing?: boolean;
onEdit?: () => void;
children?: ReactNode;
}) {
const accountLabel = user.isRoot ? "root" : "non-root";
const accountTone = user.isRoot ? "danger" : "info";
@@ -143,19 +156,67 @@ export function ServerUserCard({ user }: { user: ServerSystemUser }) {
UID {user.uid ?? "—"} • GID {user.gid ?? "—"}
</p>
</div>
<KeyRound
size={16}
style={{ color: user.isRoot ? "#ef4444" : "#3b82f6" }}
/>
{user.isRoot ? (
<span
className="ui-badge"
title="Root account is protected and cannot be edited here"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "#ef4444",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.2)",
}}
>
<LockKeyhole size={13} /> Protected
</span>
) : (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onEdit}
disabled={!canEdit}
aria-expanded={isEditing}
title={
canEdit
? user.isSshUser
? "Edit access with SSH login protections"
: "Edit user access"
: "Operator or Super Admin role required"
}
style={{
color: user.isSshUser ? "#f59e0b" : "#3b82f6",
border: user.isSshUser
? "1px solid rgba(245,158,11,0.2)"
: "1px solid rgba(59,130,246,0.2)",
background: user.isSshUser
? "rgba(245,158,11,0.08)"
: "rgba(59,130,246,0.08)",
}}
>
{user.isSshUser ? (
<ShieldCheck size={13} />
) : (
<Pencil size={13} />
)}
{user.isSshUser ? "Edit access" : "Edit"}
</button>
)}
</div>
<div style={{ display: "grid", gap: 8 }}>
<ConfigInfoRow label="Home" value={user.home ?? "—"} />
<ConfigInfoRow label="Shell" value={user.shell ?? "—"} />
<ConfigInfoRow
label="Groups"
label="Primary group"
value={user.primaryGroup ?? "—"}
/>
<ConfigInfoRow
label="All groups"
value={user.groups.length > 0 ? user.groups.join(", ") : "—"}
/>
</div>
{children}
</div>
);
}
@@ -1,24 +1,172 @@
"use client";
import type { ServerConfigSnapshot } from "@/lib/api";
import { useMemo, useState } from "react";
import {
Group,
Loader2,
LockKeyhole,
Plus,
ShieldAlert,
UserPlus,
} from "lucide-react";
import type { ServerConfigSnapshot, ServerSystemUser } from "@/lib/api";
import {
ServerUserCard,
UserBadge,
} from "@/app/servers/components/server-config/ServerConfigPrimitives";
import SearchableSelect from "@/components/SearchableSelect";
interface ServerConfigUsersPanelProps {
snapshot: ServerConfigSnapshot;
snapshotLoadError?: string | null;
canManageSystemAccounts: boolean;
isActionRunning: (actionKey: string) => boolean;
getSystemUserUpdateActionKey: (username: string) => string;
onRequestCreateUserConfirm: (username: string, groups: string[]) => void;
onRequestCreateGroupConfirm: (groupName: string) => void;
onRequestUpdateUserConfirm: (
user: ServerSystemUser,
groups: string[],
shell: string,
) => void;
}
const accountNamePattern = /^[a-z_][a-z0-9_-]{0,31}$/;
const sensitiveGroups = new Set(["docker", "root", "sudo", "wheel"]);
export default function ServerConfigUsersPanel({
snapshot,
snapshotLoadError,
canManageSystemAccounts,
isActionRunning,
getSystemUserUpdateActionKey,
onRequestCreateUserConfirm,
onRequestCreateGroupConfirm,
onRequestUpdateUserConfirm,
}: ServerConfigUsersPanelProps) {
const users = [
...(snapshot.rootUser ? [snapshot.rootUser] : []),
...snapshot.nonRootUsers,
];
const [previewMode, setPreviewMode] = useState<"user" | "group">("user");
const [previewUsername, setPreviewUsername] = useState("deploy");
const [previewGroupName, setPreviewGroupName] = useState("docker");
const [selectedGroups, setSelectedGroups] = useState<string[]>(() =>
snapshot.systemGroups.includes("docker") ? ["docker"] : [],
);
const [editingUsername, setEditingUsername] = useState<string | null>(null);
const [editDraftGroups, setEditDraftGroups] = useState<string[]>([]);
const [editDraftShell, setEditDraftShell] = useState("/bin/bash");
const users = useMemo(
() => [
...(snapshot.rootUser ? [snapshot.rootUser] : []),
...snapshot.nonRootUsers,
],
[snapshot.nonRootUsers, snapshot.rootUser],
);
const detectedGroups = useMemo(
() =>
Array.from(
new Set(
users.flatMap((user) => user.groups).filter((group) => group.trim()),
),
).sort((left, right) => left.localeCompare(right)),
[users],
);
const commonGroups = ["docker", "sudo", "wheel", "root", "www-data", "adm"];
const groupOptions = Array.from(
new Set([...commonGroups, ...detectedGroups, ...snapshot.systemGroups]),
).filter(
(group) =>
snapshot.systemGroups.includes(group) && accountNamePattern.test(group),
);
const selectedSensitiveGroups = selectedGroups.filter((group) =>
sensitiveGroups.has(group),
);
const privilegedSelection =
previewMode === "user"
? selectedSensitiveGroups.length > 0
: sensitiveGroups.has(previewGroupName.trim());
const quickGroupOptions = Array.from(
new Set([...selectedGroups, ...groupOptions.slice(0, 8)]),
);
const normalizedUsername = previewUsername.trim();
const normalizedGroupName = previewGroupName.trim();
const usernameExists = users.some(
(user) => user.username === normalizedUsername,
);
const groupExists = snapshot.systemGroups.includes(normalizedGroupName);
const usernameError = !accountNamePattern.test(normalizedUsername)
? "Use 1–32 lowercase letters, numbers, underscores, or hyphens; start with a letter or underscore."
: normalizedUsername === "root"
? "The root account cannot be created or replaced."
: usernameExists
? "This user already exists on the host."
: null;
const groupNameError = !accountNamePattern.test(normalizedGroupName)
? "Use 1–32 lowercase letters, numbers, underscores, or hyphens; start with a letter or underscore."
: groupExists
? "This group already exists on the host."
: null;
const actionKey =
previewMode === "user" ? "system-user:create" : "system-group:create";
const actionRunning = isActionRunning(actionKey);
const formInvalid =
previewMode === "user" ? Boolean(usernameError) : Boolean(groupNameError);
const previewCommand =
previewMode === "user"
? [
`useradd -m -s /bin/bash${
selectedGroups.length > 0
? ` -G ${selectedGroups.join(",")}`
: ""
} ${previewUsername || "<username>"}`,
]
.filter(Boolean)
.join(" && ")
: `groupadd ${previewGroupName || "<group>"}`;
const toggleGroup = (group: string) => {
setSelectedGroups((current) =>
current.includes(group)
? current.filter((item) => item !== group)
: current.length < 16
? [...current, group]
: current,
);
};
const beginEdit = (user: ServerSystemUser) => {
if (user.isRoot) return;
if (editingUsername === user.username) {
setEditingUsername(null);
return;
}
setEditingUsername(user.username);
setEditDraftGroups(user.groups);
setEditDraftShell(user.shell ?? "/bin/bash");
};
const toggleEditGroup = (group: string, sourceUser: ServerSystemUser) => {
setEditDraftGroups((current) => {
if (current.includes(group)) {
const isPrimaryGroup = group === sourceUser.primaryGroup;
const isProtectedSshGroup =
sourceUser.isSshUser && sourceUser.groups.includes(group);
if (isPrimaryGroup || isProtectedSshGroup) {
return current;
}
return current.filter((item) => item !== group);
}
return current.length < 16 ? [...current, group] : current;
});
};
const requestEditConfirmation = (user: ServerSystemUser) => {
setEditingUsername(null);
onRequestUpdateUserConfirm(
user,
editDraftGroups,
user.isSshUser ? user.shell ?? editDraftShell : editDraftShell,
);
};
return (
<div
@@ -51,6 +199,7 @@ export default function ServerConfigUsersPanel({
display: "flex",
gap: 8,
flexWrap: "wrap",
alignItems: "center",
}}
>
<UserBadge
@@ -64,6 +213,327 @@ export default function ServerConfigUsersPanel({
</div>
</div>
</div>
<div
className="card"
style={{
padding: 18,
display: "grid",
gap: 16,
borderColor: "rgba(59,130,246,0.24)",
background:
"linear-gradient(180deg, rgba(59,130,246,0.045), var(--bg-card) 68%)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
User & Group Management
</strong>
<p
style={{
marginTop: 6,
color: "var(--text-muted)",
fontSize: 13,
maxWidth: 620,
}}
>
Create passwordless host accounts, create groups, and assign
supplementary group access without opening a terminal.
</p>
</div>
<UserBadge label="Audited action" tone="success" />
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gap: 16,
alignItems: "start",
}}
>
<div style={{ display: "grid", gap: 12, alignContent: "start" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
height: 40,
alignSelf: "start",
}}
>
{[
{ key: "user", label: "Add User", icon: UserPlus },
{ key: "group", label: "Add Group", icon: Group },
].map((item) => {
const Icon = item.icon;
const active = previewMode === item.key;
return (
<button
key={item.key}
type="button"
className="btn"
onClick={() => setPreviewMode(item.key as "user" | "group")}
style={{
height: 40,
minHeight: 40,
padding: "0 12px",
justifyContent: "center",
borderColor: active ? "var(--accent)" : "var(--border)",
background: active
? "rgba(59,130,246,0.12)"
: "var(--bg-input)",
color: active ? "var(--accent)" : "var(--text-primary)",
}}
>
<Icon size={14} />
{item.label}
</button>
);
})}
</div>
{previewMode === "user" ? (
<>
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Username
</span>
<input
className="input"
value={previewUsername}
onChange={(event) =>
setPreviewUsername(event.target.value)
}
placeholder="deploy"
autoComplete="off"
aria-invalid={Boolean(usernameError)}
/>
{usernameError ? (
<span style={{ color: "#ef4444", fontSize: 12 }}>
{usernameError}
</span>
) : null}
</label>
<div style={{ display: "grid", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Initial groups ({selectedGroups.length}/16)
</span>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{quickGroupOptions.map((group) => {
const active = selectedGroups.includes(group);
const sensitive = sensitiveGroups.has(group);
return (
<button
key={group}
type="button"
className="btn"
onClick={() => toggleGroup(group)}
style={{
padding: "5px 10px",
fontSize: 12,
borderColor: active
? sensitive
? "rgba(245,158,11,0.38)"
: "rgba(59,130,246,0.38)"
: "var(--border)",
background: active
? sensitive
? "rgba(245,158,11,0.1)"
: "rgba(59,130,246,0.1)"
: "var(--bg-input)",
color: active
? sensitive
? "#f59e0b"
: "var(--accent)"
: "var(--text-secondary)",
}}
>
{group}
</button>
);
})}
</div>
{groupOptions.some(
(group) => !selectedGroups.includes(group),
) ? (
<SearchableSelect
value=""
options={groupOptions
.filter((group) => !selectedGroups.includes(group))
.map((group) => ({
value: group,
label: group,
description: sensitiveGroups.has(group)
? "Privileged"
: undefined,
}))}
onChange={(value) => {
if (value) toggleGroup(value);
}}
placeholder="Select another existing group..."
searchPlaceholder="Search group..."
emptyText="No matching group found"
/>
) : null}
</div>
</>
) : (
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Group name
</span>
<input
className="input"
value={previewGroupName}
onChange={(event) => setPreviewGroupName(event.target.value)}
placeholder="docker"
autoComplete="off"
aria-invalid={Boolean(groupNameError)}
/>
{groupNameError ? (
<span style={{ color: "#ef4444", fontSize: 12 }}>
{groupNameError}
</span>
) : null}
</label>
)}
</div>
<div
style={{
display: "grid",
gap: 12,
alignContent: "start",
}}
>
<div
style={{
border: "1px solid var(--border)",
borderRadius: 8,
padding: 14,
background: "var(--bg-input)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
}}
>
<strong
style={{ color: "var(--text-primary)", fontSize: 13 }}
>
Planned host change
</strong>
{privilegedSelection ? (
<UserBadge label="Needs confirm" tone="warning" />
) : (
<UserBadge label="Low risk" tone="success" />
)}
</div>
<code
style={{
display: "block",
marginTop: 12,
color: "var(--text-secondary)",
fontSize: 12,
lineHeight: 1.6,
whiteSpace: "normal",
wordBreak: "break-word",
}}
>
{previewCommand}
</code>
{previewMode === "user" ? (
<p
style={{
marginTop: 10,
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
}}
>
No password is set. Configure an SSH public key separately
before using this account for remote login.
</p>
) : null}
</div>
{privilegedSelection ? (
<div
style={{
display: "flex",
gap: 10,
padding: 12,
borderRadius: 8,
color: "#b45309",
background: "rgba(245,158,11,0.09)",
border: "1px solid rgba(245,158,11,0.22)",
fontSize: 12,
lineHeight: 1.5,
}}
>
<ShieldAlert size={16} style={{ flex: "0 0 auto" }} />
<span>
{previewMode === "user"
? `Membership in ${selectedSensitiveGroups.join(", ")} can grant administrative or root-equivalent access.`
: `The ${normalizedGroupName} group is treated as privileged.`}{" "}
An explicit confirmation and audit event are required.
</span>
</div>
) : null}
<button
type="button"
className="btn btn-primary"
disabled={formInvalid || actionRunning || !canManageSystemAccounts}
onClick={() => {
if (previewMode === "user") {
onRequestCreateUserConfirm(normalizedUsername, selectedGroups);
} else {
onRequestCreateGroupConfirm(normalizedGroupName);
}
}}
style={{
justifyContent: "center",
opacity:
formInvalid || actionRunning || !canManageSystemAccounts
? 0.65
: 1,
}}
title={
canManageSystemAccounts
? undefined
: "Operator or Super Admin role required"
}
>
{actionRunning ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} />
)}
{previewMode === "user" ? "Create User" : "Create Group"}
</button>
{!canManageSystemAccounts ? (
<p style={{ color: "var(--text-muted)", fontSize: 12 }}>
Operator or Super Admin role is required for host account
changes.
</p>
) : null}
</div>
</div>
</div>
{users.length > 0 ? (
<div
style={{
@@ -73,9 +543,244 @@ export default function ServerConfigUsersPanel({
gap: 16,
}}
>
{users.map((user) => (
<ServerUserCard key={user.username} user={user} />
))}
{users.map((user) => {
const sourceUser = user;
const isEditing = editingUsername === user.username;
const updateActionKey = getSystemUserUpdateActionKey(user.username);
const updateRunning = isActionRunning(updateActionKey);
const editGroupOptions = Array.from(
new Set([...editDraftGroups, ...groupOptions.slice(0, 10)]),
);
const selectedPrivilegedEditGroups = editDraftGroups.filter(
(group) =>
sensitiveGroups.has(group) && !sourceUser.groups.includes(group),
);
const groupChanges =
editDraftGroups.length !== sourceUser.groups.length ||
editDraftGroups.some(
(group) => !sourceUser.groups.includes(group),
);
const shellChanges =
!sourceUser.isSshUser && editDraftShell !== sourceUser.shell;
const hasChanges = groupChanges || shellChanges;
return (
<ServerUserCard
key={user.username}
user={user}
canEdit={canManageSystemAccounts && !updateRunning}
isEditing={isEditing}
onEdit={() => beginEdit(sourceUser)}
>
{isEditing ? (
<div
style={{
display: "grid",
gap: 12,
paddingTop: 14,
borderTop: "1px solid var(--border)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
}}
>
<strong
style={{ color: "var(--text-primary)", fontSize: 13 }}
>
{sourceUser.isSshUser
? "Edit SSH user access"
: "Edit user access"}
</strong>
<UserBadge label="Confirmation required" tone="warning" />
</div>
{sourceUser.isSshUser ? (
<div
style={{
padding: 10,
borderRadius: 8,
color: "#b45309",
background: "rgba(245,158,11,0.08)",
border: "1px solid rgba(245,158,11,0.2)",
fontSize: 12,
lineHeight: 1.5,
}}
>
The active SSH login keeps its current shell and groups.
You can only add supplementary groups. Changes apply to
new SSH sessions after confirmation.
</div>
) : null}
<label style={{ display: "grid", gap: 6 }}>
<span
style={{ fontSize: 12, color: "var(--text-muted)" }}
>
Login shell
</span>
<select
className="input"
value={editDraftShell}
disabled={sourceUser.isSshUser}
onChange={(event) =>
setEditDraftShell(event.target.value)
}
>
{Array.from(
new Set([
sourceUser.shell ?? "/bin/bash",
"/bin/bash",
"/bin/sh",
"/usr/sbin/nologin",
]),
).map((shell) => (
<option key={shell} value={shell}>
{shell}
</option>
))}
</select>
</label>
<div style={{ display: "grid", gap: 8 }}>
<span
style={{ fontSize: 12, color: "var(--text-muted)" }}
>
Group memberships ({editDraftGroups.length}/16)
</span>
<div
style={{ display: "flex", gap: 7, flexWrap: "wrap" }}
>
{editGroupOptions.map((group) => {
const selected = editDraftGroups.includes(group);
const locked =
group === sourceUser.primaryGroup ||
(sourceUser.isSshUser &&
sourceUser.groups.includes(group));
const privileged = sensitiveGroups.has(group);
return (
<button
key={group}
type="button"
className="btn"
onClick={() =>
toggleEditGroup(group, sourceUser)
}
title={
locked
? group === sourceUser.primaryGroup
? "The primary group cannot be removed"
: "Current SSH login groups cannot be removed"
: undefined
}
style={{
padding: "4px 9px",
fontSize: 12,
color: selected
? privileged
? "#f59e0b"
: "#3b82f6"
: "var(--text-secondary)",
borderColor: selected
? privileged
? "rgba(245,158,11,0.35)"
: "rgba(59,130,246,0.35)"
: "var(--border)",
background: selected
? privileged
? "rgba(245,158,11,0.08)"
: "rgba(59,130,246,0.08)"
: "var(--bg-input)",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
{locked ? <LockKeyhole size={11} /> : null}
{group}
</button>
);
})}
</div>
{groupOptions.some(
(group) => !editDraftGroups.includes(group),
) ? (
<SearchableSelect
value=""
options={groupOptions
.filter(
(group) => !editDraftGroups.includes(group),
)
.map((group) => ({
value: group,
label: group,
description: sensitiveGroups.has(group)
? "Privileged"
: undefined,
}))}
onChange={(value) => {
if (value) {
toggleEditGroup(value, sourceUser);
}
}}
placeholder="Add another existing group..."
searchPlaceholder="Search group..."
emptyText="No matching group found"
/>
) : null}
</div>
{selectedPrivilegedEditGroups.length > 0 ? (
<div
style={{
color: "#b45309",
fontSize: 12,
lineHeight: 1.5,
}}
>
New privileged access selected:{" "}
{selectedPrivilegedEditGroups.join(", ")}. The live
action requires explicit confirmation and is audited.
</div>
) : null}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
flexWrap: "wrap",
}}
>
<button
type="button"
className="btn btn-sm"
onClick={() => setEditingUsername(null)}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!hasChanges || updateRunning}
onClick={() => requestEditConfirmation(sourceUser)}
style={{ opacity: !hasChanges || updateRunning ? 0.65 : 1 }}
>
{updateRunning ? (
<Loader2 size={13} className="animate-spin" />
) : null}
Save Changes
</button>
</div>
</div>
) : null}
</ServerUserCard>
);
})}
</div>
) : (
<div
+56
View File
@@ -1337,6 +1337,7 @@ export interface ServerSystemUser {
home: string | null;
shell: string | null;
groups: string[];
primaryGroup: string | null;
isRoot: boolean;
isSshUser: boolean;
}
@@ -1430,6 +1431,7 @@ export interface ServerConfigSnapshot {
users: ServerSystemUser[];
rootUser: ServerSystemUser | null;
nonRootUsers: ServerSystemUser[];
systemGroups: string[];
hasRootUser: boolean;
sudoNonInteractive: boolean;
docker: DockerRuntimeStatus;
@@ -1456,6 +1458,34 @@ export interface ServerDeleteBody {
confirmation: "DELETE";
}
export interface ServerSystemUserCreateBody {
username: string;
groups: string[];
acknowledgePrivilegedGroups: boolean;
}
export interface ServerSystemGroupCreateBody {
groupName: string;
acknowledgePrivilegedGroup: boolean;
}
export interface ServerSystemUserUpdateBody {
groups: string[];
shell: string;
expectedGroups: string[];
expectedShell: string;
acknowledgePrivilegedGroups: boolean;
}
export interface ServerSystemUserUpdateResult {
username: string;
isSshUser: boolean;
addedGroups: string[];
removedGroups: string[];
previousShell: string;
shell: string;
}
export const servers = {
list: () =>
get<{ success: boolean; data: Server[] }>("/servers", { timeoutMs: 10000 }),
@@ -1485,6 +1515,32 @@ export const servers = {
`/servers/${id}/config`,
{ timeoutMs: 45000 },
),
createSystemUser: (id: string, body: ServerSystemUserCreateBody) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/system-users`,
body,
{ timeoutMs: 30000 },
),
createSystemGroup: (id: string, body: ServerSystemGroupCreateBody) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/system-groups`,
body,
{ timeoutMs: 30000 },
),
updateSystemUser: (
id: string,
username: string,
body: ServerSystemUserUpdateBody,
) =>
patch<{
success: boolean;
data: ServerSystemUserUpdateResult;
message: string;
}>(
`/servers/${id}/system-users/${encodeURIComponent(username)}`,
body,
{ timeoutMs: 30000 },
),
reset: (id: string, confirmation: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/reset`,
+296 -1
View File
@@ -2,7 +2,11 @@ import { FastifyInstance } from "fastify";
import { z } from "zod";
import prisma from "../lib/prisma";
import { 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 {
@@ -12,6 +16,14 @@ import {
} from "../services/server-access.service";
import * as ssh from "../services/ssh.service";
import { closeTerminalSessionsForServer } from "./terminal";
import {
getPrivilegedSystemGroups,
hasRequiredPrivilegeAcknowledgement,
LINUX_ACCOUNT_NAME_PATTERN,
PRIVILEGED_SYSTEM_GROUPS,
SystemAccountConflictError,
SystemAccountInputError,
} from "../services/ssh-services/internal/system-account-security";
function serializeMetric(
metric: {
@@ -95,6 +107,74 @@ const ServerDeleteSchema = z.object({
const ServerResetSchema = z.object({
confirmation: z.literal("DELETE"),
});
const LinuxAccountNameSchema = z
.string()
.trim()
.regex(
LINUX_ACCOUNT_NAME_PATTERN,
"Must start with a lowercase letter or underscore and contain only lowercase letters, numbers, underscores, or hyphens (max 32 characters)",
);
const ServerSystemUserCreateSchema = z
.object({
username: LinuxAccountNameSchema.refine((value) => value !== "root", {
message: "The root account cannot be created or replaced",
}),
groups: z
.array(LinuxAccountNameSchema)
.max(16, "A user can be assigned to at most 16 groups in one action")
.default([])
.refine((groups) => new Set(groups).size === groups.length, {
message: "Group names must be unique",
}),
acknowledgePrivilegedGroups: z.boolean().default(false),
})
.superRefine((value, context) => {
const selectedPrivilegedGroups = getPrivilegedSystemGroups(value.groups);
if (!hasRequiredPrivilegeAcknowledgement(
value.groups,
value.acknowledgePrivilegedGroups,
)) {
context.addIssue({
code: "custom",
path: ["acknowledgePrivilegedGroups"],
message: `Explicit confirmation is required for privileged groups: ${selectedPrivilegedGroups.join(", ")}`,
});
}
});
const ServerSystemUserUpdateSchema = z.object({
groups: z
.array(LinuxAccountNameSchema)
.max(16, "A user can have at most 16 group memberships")
.refine((groups) => new Set(groups).size === groups.length, {
message: "Group names must be unique",
}),
shell: z.string().trim().min(1).max(128),
expectedGroups: z
.array(LinuxAccountNameSchema)
.max(16, "The expected group list is too large")
.refine((groups) => new Set(groups).size === groups.length, {
message: "Expected group names must be unique",
}),
expectedShell: z.string().trim().min(1).max(128),
acknowledgePrivilegedGroups: z.boolean().default(false),
});
const ServerSystemGroupCreateSchema = z
.object({
groupName: LinuxAccountNameSchema,
acknowledgePrivilegedGroup: z.boolean().default(false),
})
.superRefine((value, context) => {
if (
PRIVILEGED_SYSTEM_GROUPS.has(value.groupName) &&
!value.acknowledgePrivilegedGroup
) {
context.addIssue({
code: "custom",
path: ["acknowledgePrivilegedGroup"],
message: `Explicit confirmation is required for privileged group ${value.groupName}`,
});
}
});
const DockerPruneSchema = z.object({
options: z
.object({
@@ -154,6 +234,10 @@ export async function serverRoutes(app: FastifyInstance) {
authenticate,
requireApiKeyPermission("write:servers"),
];
const serverSystemAccountWriteAccess = [
...serverWriteAccess,
requireRole("OPERATOR"),
];
const serverContainerReadAccess = [
authenticate,
requireApiKeyPermission("read:containers"),
@@ -581,6 +665,217 @@ export async function serverRoutes(app: FastifyInstance) {
},
);
app.post(
"/:id/system-users",
{ preHandler: serverSystemAccountWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ServerSystemUserCreateSchema.safeParse(req.body ?? {});
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const server = await getAccessibleServer(
req.userId,
id,
req.organizationId,
);
if (!server) {
return reply.status(403).send({
success: false,
error: "Forbidden — you do not have access to this server",
});
}
const privilegedGroups = getPrivilegedSystemGroups(body.data.groups);
try {
await ssh.createServerSystemUser(server, body.data);
const message = `System user ${body.data.username} created on ${server.name}`;
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: id,
action: "SERVER_SYSTEM_USER_CREATE",
category: "SERVER",
level: privilegedGroups.length > 0 ? "WARNING" : "SUCCESS",
message,
meta: {
username: body.data.username,
groups: body.data.groups,
privilegedGroups,
},
});
return reply.status(201).send({ success: true, message });
} catch (err: unknown) {
const message =
err instanceof SystemAccountInputError
? err.message
: "The host could not create the system user. Verify sudo access and Linux account-management tools, then try again.";
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: id,
action: "SERVER_SYSTEM_USER_CREATE_FAILED",
category: "SERVER",
level: "ERROR",
message: `Failed to create system user ${body.data.username} on ${server.name}`,
meta: { username: body.data.username, groups: body.data.groups },
});
return reply.status(400).send({ success: false, error: message });
}
},
);
app.post(
"/:id/system-groups",
{ preHandler: serverSystemAccountWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ServerSystemGroupCreateSchema.safeParse(req.body ?? {});
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const server = await getAccessibleServer(
req.userId,
id,
req.organizationId,
);
if (!server) {
return reply.status(403).send({
success: false,
error: "Forbidden — you do not have access to this server",
});
}
const privileged = PRIVILEGED_SYSTEM_GROUPS.has(body.data.groupName);
try {
await ssh.createServerSystemGroup(server, body.data.groupName);
const message = `System group ${body.data.groupName} created on ${server.name}`;
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: id,
action: "SERVER_SYSTEM_GROUP_CREATE",
category: "SERVER",
level: privileged ? "WARNING" : "SUCCESS",
message,
meta: { groupName: body.data.groupName, privileged },
});
return reply.status(201).send({ success: true, message });
} catch (err: unknown) {
const message =
err instanceof SystemAccountInputError
? err.message
: "The host could not create the system group. Verify sudo access and Linux account-management tools, then try again.";
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: id,
action: "SERVER_SYSTEM_GROUP_CREATE_FAILED",
category: "SERVER",
level: "ERROR",
message: `Failed to create system group ${body.data.groupName} on ${server.name}`,
meta: { groupName: body.data.groupName },
});
return reply.status(400).send({ success: false, error: message });
}
},
);
app.patch(
"/:id/system-users/:username",
{ preHandler: serverSystemAccountWriteAccess },
async (req, reply) => {
const { id, username } = req.params as {
id: string;
username: string;
};
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
const body = ServerSystemUserUpdateSchema.safeParse(req.body ?? {});
if (!parsedUsername.success) {
return reply.status(400).send({
success: false,
error: parsedUsername.error.flatten(),
});
}
if (!body.success) {
return reply.status(400).send({
success: false,
error: body.error.flatten(),
});
}
const server = await getAccessibleServer(
req.userId,
id,
req.organizationId,
);
if (!server) {
return reply.status(403).send({
success: false,
error: "Forbidden — you do not have access to this server",
});
}
try {
const result = await ssh.updateServerSystemUser(server, {
username: parsedUsername.data,
...body.data,
});
const privilegedGroups = getPrivilegedSystemGroups(
result.addedGroups,
);
const message = `System user ${result.username} updated on ${server.name}`;
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: id,
action: "SERVER_SYSTEM_USER_UPDATE",
category: "SERVER",
level: privilegedGroups.length > 0 ? "WARNING" : "SUCCESS",
message,
meta: {
username: result.username,
isSshUser: result.isSshUser,
addedGroups: result.addedGroups,
removedGroups: result.removedGroups,
privilegedGroups,
previousShell: result.previousShell,
shell: result.shell,
},
});
return reply.send({ success: true, data: result, message });
} catch (err: unknown) {
const isConflict = err instanceof SystemAccountConflictError;
const message =
err instanceof SystemAccountInputError
? err.message
: "The host could not update the system user. Verify sudo access and Linux account-management tools, then try again.";
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: id,
action: "SERVER_SYSTEM_USER_UPDATE_FAILED",
category: "SERVER",
level: isConflict ? "WARNING" : "ERROR",
message: `Failed to update system user ${parsedUsername.data} on ${server.name}`,
meta: {
username: parsedUsername.data,
conflict: isConflict,
},
});
return reply
.status(isConflict ? 409 : 400)
.send({ success: false, error: message });
}
},
);
app.post(
"/:id/reset",
{ preHandler: serverWriteAccess },
@@ -3,3 +3,10 @@ import { Server } from "@prisma/client";
export function privilegedCommand(server: Server, command: string): string {
return server.username === "root" ? command : `sudo ${command}`;
}
export function nonInteractivePrivilegedCommand(
server: Server,
command: string,
): string {
return server.username === "root" ? command : `sudo -n ${command}`;
}
@@ -0,0 +1,148 @@
export const LINUX_ACCOUNT_NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
export const PRIVILEGED_SYSTEM_GROUPS = new Set([
"root",
"docker",
"sudo",
"wheel",
]);
export const ALLOWED_SYSTEM_USER_SHELLS = new Set([
"/bin/bash",
"/bin/sh",
"/bin/zsh",
"/usr/bin/bash",
"/usr/bin/zsh",
"/usr/sbin/nologin",
"/sbin/nologin",
]);
export class SystemAccountInputError extends Error {
constructor(message: string) {
super(message);
this.name = "SystemAccountInputError";
}
}
export class SystemAccountConflictError extends SystemAccountInputError {
constructor(message: string) {
super(message);
this.name = "SystemAccountConflictError";
}
}
export function assertLinuxAccountName(
value: string,
label: "User" | "Group",
) {
if (!LINUX_ACCOUNT_NAME_PATTERN.test(value)) {
throw new SystemAccountInputError(
`${label} name must start with a lowercase letter or underscore and contain only lowercase letters, numbers, underscores, or hyphens (max 32 characters)`,
);
}
}
export function getPrivilegedSystemGroups(groups: string[]): string[] {
return groups.filter((group) => PRIVILEGED_SYSTEM_GROUPS.has(group));
}
export function assertAllowedSystemUserShell(shell: string): void {
if (!ALLOWED_SYSTEM_USER_SHELLS.has(shell)) {
throw new SystemAccountInputError("The selected login shell is not allowed");
}
}
export function haveSameStringMembers(
left: string[],
right: string[],
): boolean {
const leftSet = new Set(left);
const rightSet = new Set(right);
return (
leftSet.size === rightSet.size &&
[...leftSet].every((value) => rightSet.has(value))
);
}
export interface SystemUserUpdatePolicyResult {
isSshUser: boolean;
addedGroups: string[];
removedGroups: string[];
shellChanged: boolean;
}
export function validateSystemUserUpdatePolicy(options: {
username: string;
uid: number;
serverUsername: string;
currentGroups: string[];
primaryGroup: string;
currentShell: string;
desiredGroups: string[];
desiredShell: string;
expectedGroups: string[];
expectedShell: string;
acknowledgePrivilegedGroups: boolean;
}): SystemUserUpdatePolicyResult {
if (options.uid === 0 || options.username === "root") {
throw new SystemAccountInputError("The root account is protected");
}
if (
options.currentShell !== options.expectedShell ||
!haveSameStringMembers(options.currentGroups, options.expectedGroups)
) {
throw new SystemAccountConflictError(
"This user changed on the host after the snapshot was loaded. Refresh Server Config before editing again.",
);
}
if (!options.desiredGroups.includes(options.primaryGroup)) {
throw new SystemAccountInputError(
`Primary group ${options.primaryGroup} cannot be removed`,
);
}
const isSshUser = options.username === options.serverUsername;
const addedGroups = options.desiredGroups.filter(
(group) => !options.currentGroups.includes(group),
);
const removedGroups = options.currentGroups.filter(
(group) => !options.desiredGroups.includes(group),
);
const shellChanged = options.desiredShell !== options.currentShell;
if (isSshUser && shellChanged) {
throw new SystemAccountInputError(
"The active SSH login shell cannot be changed from Server Config",
);
}
if (isSshUser && removedGroups.length > 0) {
throw new SystemAccountInputError(
"Existing groups cannot be removed from the active SSH login",
);
}
if (shellChanged) {
assertAllowedSystemUserShell(options.desiredShell);
}
const addedPrivilegedGroups = getPrivilegedSystemGroups(addedGroups);
if (
addedPrivilegedGroups.length > 0 &&
!options.acknowledgePrivilegedGroups
) {
throw new SystemAccountInputError(
`Explicit confirmation is required for privileged groups: ${addedPrivilegedGroups.join(", ")}`,
);
}
if (!shellChanged && addedGroups.length === 0 && removedGroups.length === 0) {
throw new SystemAccountInputError("No user changes were selected");
}
return { isSshUser, addedGroups, removedGroups, shellChanged };
}
export function hasRequiredPrivilegeAcknowledgement(
groups: string[],
acknowledged: boolean,
): boolean {
return getPrivilegedSystemGroups(groups).length === 0 || acknowledged;
}
@@ -15,7 +15,15 @@ import {
inspectWebServerCapability,
} from "./web-stack";
import { escapeShellArg } from "./internal/shell";
import { privilegedCommand } from "./internal/privilege";
import {
nonInteractivePrivilegedCommand,
privilegedCommand,
} from "./internal/privilege";
import {
assertLinuxAccountName,
SystemAccountInputError,
validateSystemUserUpdatePolicy,
} from "./internal/system-account-security";
const CONFIG_FAST_TIMEOUT_MS = 8_000;
const CONFIG_LIST_TIMEOUT_MS = 12_000;
@@ -33,6 +41,7 @@ export interface ServerSystemUser {
home: string | null;
shell: string | null;
groups: string[];
primaryGroup: string | null;
isRoot: boolean;
isSshUser: boolean;
}
@@ -63,6 +72,7 @@ export interface ServerConfigSnapshot {
users: ServerSystemUser[];
rootUser: ServerSystemUser | null;
nonRootUsers: ServerSystemUser[];
systemGroups: string[];
hasRootUser: boolean;
sudoNonInteractive: boolean;
docker: DockerRuntimeStatus;
@@ -80,10 +90,12 @@ export async function getServerConfigSnapshot(
"while IFS=: read -r user _ uid gid _ home shell; do",
' if [ "$uid" -eq 0 ] || [ "$uid" -ge 1000 ]; then',
' groups=$(id -nG "$user" 2>/dev/null || true)',
' printf \'%s|%s|%s|%s|%s|%s\\n\' "$user" "$uid" "$gid" "$home" "$shell" "$groups"',
' primary_group=$(id -gn "$user" 2>/dev/null || true)',
' printf \'%s|%s|%s|%s|%s|%s|%s\\n\' "$user" "$uid" "$gid" "$home" "$shell" "$groups" "$primary_group"',
" fi",
"done < /etc/passwd",
].join("\n");
const groupsScript = "cut -d: -f1 /etc/group 2>/dev/null | sort -u";
const platformResult = await Promise.allSettled([
detectServerPlatform(server, configCommandTimeout(CONFIG_FAST_TIMEOUT_MS)),
@@ -98,6 +110,7 @@ export async function getServerConfigSnapshot(
kernelResult,
currentUserResult,
usersResult,
groupsResult,
lastBootResult,
servicesResult,
diskMountsResult,
@@ -123,6 +136,11 @@ export async function getServerConfigSnapshot(
`bash -lc ${escapeShellArg(usersScript)}`,
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
),
exec(
server,
`bash -lc ${escapeShellArg(groupsScript)}`,
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
),
exec(
server,
"bash -lc \"uptime -s 2>/dev/null || who -b 2>/dev/null | sed 's/.*system boot[[:space:]]*//'\"",
@@ -170,7 +188,15 @@ export async function getServerConfigSnapshot(
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [username, uidRaw, gidRaw, homeRaw, shellRaw, groupsRaw] =
const [
username,
uidRaw,
gidRaw,
homeRaw,
shellRaw,
groupsRaw,
primaryGroupRaw,
] =
line.split("|");
const uid = Number(uidRaw);
const gid = Number(gidRaw);
@@ -185,6 +211,7 @@ export async function getServerConfigSnapshot(
.split(/\s+/)
.map((group) => group.trim())
.filter(Boolean),
primaryGroup: primaryGroupRaw || null,
isRoot: uid === 0 || username === "root",
isSshUser: username === server.username,
} satisfies ServerSystemUser;
@@ -197,6 +224,13 @@ export async function getServerConfigSnapshot(
const rootUser = users.find((user) => user.isRoot) ?? null;
const nonRootUsers = users.filter((user) => !user.isRoot);
const systemGroups =
groupsResult.status === "fulfilled"
? groupsResult.value.stdout
.split("\n")
.map((group) => group.trim())
.filter(Boolean)
: [];
return {
hostname:
@@ -216,6 +250,7 @@ export async function getServerConfigSnapshot(
users,
rootUser,
nonRootUsers,
systemGroups,
hasRootUser: Boolean(rootUser),
sudoNonInteractive: docker.platform.sudoNonInteractive,
docker,
@@ -230,6 +265,260 @@ export async function getServerConfigSnapshot(
};
}
async function accountExists(
server: Server,
database: "passwd" | "group",
name: string,
): Promise<boolean> {
const result = await exec(
server,
`getent ${database} ${escapeShellArg(name)} >/dev/null 2>&1`,
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
);
return result.code === 0;
}
export async function createServerSystemGroup(
server: Server,
groupName: string,
): Promise<void> {
assertLinuxAccountName(groupName, "Group");
if (await accountExists(server, "group", groupName)) {
throw new SystemAccountInputError(`Group ${groupName} already exists`);
}
await execStrict(
server,
nonInteractivePrivilegedCommand(
server,
`groupadd ${escapeShellArg(groupName)}`,
),
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
);
}
export async function createServerSystemUser(
server: Server,
options: { username: string; groups: string[] },
): Promise<void> {
assertLinuxAccountName(options.username, "User");
if (options.username === "root") {
throw new SystemAccountInputError(
"The root account cannot be created or replaced",
);
}
const groups = Array.from(new Set(options.groups));
groups.forEach((group) => assertLinuxAccountName(group, "Group"));
if (await accountExists(server, "passwd", options.username)) {
throw new SystemAccountInputError(
`User ${options.username} already exists`,
);
}
const missingGroups: string[] = [];
for (const group of groups) {
if (!(await accountExists(server, "group", group))) {
missingGroups.push(group);
}
}
if (missingGroups.length > 0) {
throw new SystemAccountInputError(
`Group not found: ${missingGroups.join(", ")}`,
);
}
const groupArgs =
groups.length > 0 ? ` -G ${escapeShellArg(groups.join(","))}` : "";
await execStrict(
server,
nonInteractivePrivilegedCommand(
server,
`useradd -m -s /bin/bash${groupArgs} ${escapeShellArg(options.username)}`,
),
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
);
}
interface CurrentServerSystemUser {
username: string;
uid: number;
gid: number;
home: string;
shell: string;
groups: string[];
primaryGroup: string;
}
export interface ServerSystemUserUpdateResult {
username: string;
isSshUser: boolean;
addedGroups: string[];
removedGroups: string[];
previousShell: string;
shell: string;
}
async function getCurrentServerSystemUser(
server: Server,
username: string,
): Promise<CurrentServerSystemUser> {
const script = [
`TARGET=${escapeShellArg(username)}`,
'entry=$(getent passwd "$TARGET") || exit 3',
'IFS=: read -r account _ uid gid _ home shell <<< "$entry"',
'groups=$(id -nG "$TARGET" 2>/dev/null) || exit 3',
'primary_group=$(id -gn "$TARGET" 2>/dev/null) || exit 3',
'printf \'%s|%s|%s|%s|%s|%s|%s\\n\' "$account" "$uid" "$gid" "$home" "$shell" "$groups" "$primary_group"',
].join("\n");
const result = await exec(
server,
`bash -lc ${escapeShellArg(script)}`,
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
);
if (result.code === 3) {
throw new SystemAccountInputError(`User ${username} was not found`);
}
if (result.code !== 0 || !result.stdout.trim()) {
throw new Error("Failed to inspect the current system user state");
}
const [
account,
uidRaw,
gidRaw,
home,
shell,
groupsRaw,
primaryGroup,
] = result.stdout.trim().split("|");
const uid = Number(uidRaw);
const gid = Number(gidRaw);
if (
!account ||
!Number.isInteger(uid) ||
!Number.isInteger(gid) ||
!shell ||
!primaryGroup
) {
throw new Error("The host returned an invalid system user record");
}
return {
username: account,
uid,
gid,
home,
shell,
groups: groupsRaw.split(/\s+/).filter(Boolean),
primaryGroup,
};
}
export async function updateServerSystemUser(
server: Server,
options: {
username: string;
groups: string[];
shell: string;
expectedGroups: string[];
expectedShell: string;
acknowledgePrivilegedGroups: boolean;
},
): Promise<ServerSystemUserUpdateResult> {
assertLinuxAccountName(options.username, "User");
const groups = Array.from(new Set(options.groups));
const expectedGroups = Array.from(new Set(options.expectedGroups));
if (groups.length > 16 || expectedGroups.length > 16) {
throw new SystemAccountInputError(
"A user can have at most 16 group memberships in one action",
);
}
groups.forEach((group) => assertLinuxAccountName(group, "Group"));
expectedGroups.forEach((group) => assertLinuxAccountName(group, "Group"));
const current = await getCurrentServerSystemUser(server, options.username);
const policy = validateSystemUserUpdatePolicy({
username: current.username,
uid: current.uid,
serverUsername: server.username,
currentGroups: current.groups,
primaryGroup: current.primaryGroup,
currentShell: current.shell,
desiredGroups: groups,
desiredShell: options.shell,
expectedGroups,
expectedShell: options.expectedShell,
acknowledgePrivilegedGroups: options.acknowledgePrivilegedGroups,
});
const { isSshUser, addedGroups, removedGroups, shellChanged } = policy;
if (shellChanged) {
const shellCheck = await exec(
server,
`test -x ${escapeShellArg(options.shell)}`,
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
);
if (shellCheck.code !== 0) {
throw new SystemAccountInputError(
`Login shell ${options.shell} is not available on the host`,
);
}
}
const missingGroups: string[] = [];
for (const group of groups) {
if (!(await accountExists(server, "group", group))) {
missingGroups.push(group);
}
}
if (missingGroups.length > 0) {
throw new SystemAccountInputError(
`Group not found: ${missingGroups.join(", ")}`,
);
}
let command: string;
if (isSshUser) {
const supplementaryAdditions = addedGroups.filter(
(group) => group !== current.primaryGroup,
);
if (supplementaryAdditions.length === 0) {
throw new SystemAccountInputError("No user changes were selected");
}
command = `usermod -a -G ${escapeShellArg(supplementaryAdditions.join(","))} ${escapeShellArg(current.username)}`;
} else {
const supplementaryGroups = groups.filter(
(group) => group !== current.primaryGroup,
);
const argumentsList = [
shellChanged ? `-s ${escapeShellArg(options.shell)}` : null,
addedGroups.length > 0 || removedGroups.length > 0
? `-G ${escapeShellArg(supplementaryGroups.join(","))}`
: null,
].filter((value): value is string => Boolean(value));
command = `usermod ${argumentsList.join(" ")} ${escapeShellArg(current.username)}`;
}
await execStrict(
server,
nonInteractivePrivilegedCommand(server, command),
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
);
return {
username: current.username,
isSshUser,
addedGroups,
removedGroups,
previousShell: current.shell,
shell: options.shell,
};
}
function createUnavailableWebCapability(
platform: ServerPlatformInfo,
reason: unknown,
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assertLinuxAccountName,
assertAllowedSystemUserShell,
getPrivilegedSystemGroups,
hasRequiredPrivilegeAcknowledgement,
SystemAccountConflictError,
SystemAccountInputError,
validateSystemUserUpdatePolicy,
} from "../../src/server/services/ssh-services/internal/system-account-security";
test("system account names accept conservative Linux-safe identifiers", () => {
for (const name of ["deploy", "www-data", "_service", "app_2026"]) {
assert.doesNotThrow(() => assertLinuxAccountName(name, "User"));
}
});
test("system account names reject shell syntax, traversal, uppercase, and oversized input", () => {
for (const name of [
"Deploy",
"-root",
"../root",
"user;reboot",
"user name",
"a".repeat(33),
]) {
assert.throws(
() => assertLinuxAccountName(name, "User"),
SystemAccountInputError,
);
}
});
test("privileged group assignments require explicit acknowledgement", () => {
assert.deepEqual(getPrivilegedSystemGroups(["www-data", "docker", "sudo"]), [
"docker",
"sudo",
]);
assert.equal(
hasRequiredPrivilegeAcknowledgement(["www-data"], false),
true,
);
assert.equal(
hasRequiredPrivilegeAcknowledgement(["docker"], false),
false,
);
assert.equal(
hasRequiredPrivilegeAcknowledgement(["docker", "root"], true),
true,
);
});
test("system user shell changes use a fixed allowlist", () => {
assert.doesNotThrow(() => assertAllowedSystemUserShell("/bin/bash"));
assert.doesNotThrow(() =>
assertAllowedSystemUserShell("/usr/sbin/nologin"),
);
assert.throws(
() => assertAllowedSystemUserShell("/tmp/custom-shell"),
SystemAccountInputError,
);
});
const regularUpdate = {
username: "deploy",
uid: 1001,
serverUsername: "operator",
currentGroups: ["deploy", "www-data"],
primaryGroup: "deploy",
currentShell: "/bin/bash",
desiredGroups: ["deploy", "www-data", "docker"],
desiredShell: "/bin/sh",
expectedGroups: ["www-data", "deploy"],
expectedShell: "/bin/bash",
acknowledgePrivilegedGroups: true,
};
test("regular system users can update shell and exact supplementary groups", () => {
assert.deepEqual(validateSystemUserUpdatePolicy(regularUpdate), {
isSshUser: false,
addedGroups: ["docker"],
removedGroups: [],
shellChanged: true,
});
});
test("system user updates reject root, primary-group removal, and stale snapshots", () => {
assert.throws(
() => validateSystemUserUpdatePolicy({ ...regularUpdate, uid: 0 }),
/root account is protected/i,
);
assert.throws(
() =>
validateSystemUserUpdatePolicy({
...regularUpdate,
desiredGroups: ["www-data"],
}),
/Primary group deploy cannot be removed/,
);
assert.throws(
() =>
validateSystemUserUpdatePolicy({
...regularUpdate,
expectedGroups: ["deploy"],
}),
SystemAccountConflictError,
);
});
test("active SSH login updates are add-only and cannot change shell", () => {
const sshUpdate = {
...regularUpdate,
username: "operator",
serverUsername: "operator",
currentGroups: ["operator", "sudo"],
primaryGroup: "operator",
desiredGroups: ["operator", "sudo", "www-data"],
desiredShell: "/bin/bash",
expectedGroups: ["operator", "sudo"],
acknowledgePrivilegedGroups: false,
};
assert.deepEqual(validateSystemUserUpdatePolicy(sshUpdate), {
isSshUser: true,
addedGroups: ["www-data"],
removedGroups: [],
shellChanged: false,
});
assert.throws(
() =>
validateSystemUserUpdatePolicy({
...sshUpdate,
desiredGroups: ["operator"],
}),
/Existing groups cannot be removed/,
);
assert.throws(
() =>
validateSystemUserUpdatePolicy({
...sshUpdate,
desiredShell: "/bin/sh",
}),
/SSH login shell cannot be changed/,
);
});
test("new privileged memberships require acknowledgement during updates", () => {
assert.throws(
() =>
validateSystemUserUpdatePolicy({
...regularUpdate,
acknowledgePrivilegedGroups: false,
}),
/Explicit confirmation is required for privileged groups: docker/,
);
});