From e1d09fdf46efc27699c25f2eff8f5a22a7569c64 Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Mon, 20 Jul 2026 01:50:54 +0700 Subject: [PATCH] feat(servers): Added features for adding host users and secure group management. --- .../servers/components/ServerConfigModal.tsx | 1305 ++++++++++------- .../servers/components/server-config-utils.ts | 33 + .../server-config/ServerConfigPrimitives.tsx | 75 +- .../server-config/ServerConfigUsersPanel.tsx | 721 ++++++++- src/lib/api.ts | 56 + src/server/routes/servers.ts | 297 +++- .../ssh-services/internal/privilege.ts | 7 + .../internal/system-account-security.ts | 148 ++ .../services/ssh-services/server-config.ts | 295 +++- tests/backend/system-account-security.test.ts | 158 ++ 10 files changed, 2549 insertions(+), 546 deletions(-) create mode 100644 src/server/services/ssh-services/internal/system-account-security.ts create mode 100644 tests/backend/system-account-security.test.ts diff --git a/src/app/servers/components/ServerConfigModal.tsx b/src/app/servers/components/ServerConfigModal.tsx index dacf96a..21c3db5 100644 --- a/src/app/servers/components/ServerConfigModal.tsx +++ b/src/app/servers/components/ServerConfigModal.tsx @@ -12,8 +12,10 @@ import { } from "lucide-react"; import { type DockerPruneOptions, + getUser, type Server as ServerType, type ServerConfigSnapshot, + type ServerSystemUser, servers as serversApi, type WebStackAction, type WebStackComponentKey, @@ -120,6 +122,18 @@ export default function ServerConfigModal({ `web-stack:${component}:${action}`, [], ); + const canManageSystemAccounts = ["OPERATOR", "SUPER_ADMIN"].includes( + getUser()?.role ?? "VIEWER", + ); + + const getSystemAccountActionKey = useCallback( + (kind: "user" | "group") => `system-${kind}:create`, + [], + ); + const getSystemUserUpdateActionKey = useCallback( + (username: string) => `system-user:update:${username}`, + [], + ); const loadSnapshot = useCallback(async () => { setLoading(true); @@ -534,6 +548,228 @@ export default function ServerConfigModal({ }); }; + const handleSystemUserCreate = async ( + username: string, + groups: string[], + privileged: boolean, + ) => { + const actionKey = getSystemAccountActionKey("user"); + setActiveActionKey(actionKey); + setError(""); + setNotice(null); + try { + const res = await serversApi.createSystemUser(server.id, { + username, + groups, + acknowledgePrivilegedGroups: privileged, + }); + publishNotice({ + tab: "users", + tone: "success", + title: "System User Created", + summary: res.message, + details: [ + `User: ${username}`, + `Groups: ${groups.length > 0 ? groups.join(", ") : "default group only"}`, + "No password was configured for the account.", + ], + }); + onActionComplete(res.message); + await loadSnapshot(); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to create system user"; + publishNotice({ + tab: "users", + tone: "error", + title: "System User Creation Failed", + summary: message, + details: [`User: ${username}`, `Server: ${server.name} (${server.ip})`], + }); + onActionComplete(message, "error"); + } finally { + setActiveActionKey(null); + } + }; + + const handleSystemGroupCreate = async ( + groupName: string, + privileged: boolean, + ) => { + const actionKey = getSystemAccountActionKey("group"); + setActiveActionKey(actionKey); + setError(""); + setNotice(null); + try { + const res = await serversApi.createSystemGroup(server.id, { + groupName, + acknowledgePrivilegedGroup: privileged, + }); + publishNotice({ + tab: "users", + tone: "success", + title: "System Group Created", + summary: res.message, + details: [`Group: ${groupName}`, `Server: ${server.name} (${server.ip})`], + }); + onActionComplete(res.message); + await loadSnapshot(); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to create system group"; + publishNotice({ + tab: "users", + tone: "error", + title: "System Group Creation Failed", + summary: message, + details: [`Group: ${groupName}`, `Server: ${server.name} (${server.ip})`], + }); + onActionComplete(message, "error"); + } finally { + setActiveActionKey(null); + } + }; + + const handleSystemUserUpdate = async ( + username: string, + groups: string[], + shell: string, + expectedGroups: string[], + expectedShell: string, + privileged: boolean, + ) => { + const actionKey = getSystemUserUpdateActionKey(username); + setActiveActionKey(actionKey); + setError(""); + setNotice(null); + try { + const res = await serversApi.updateSystemUser(server.id, username, { + groups, + shell, + expectedGroups, + expectedShell, + acknowledgePrivilegedGroups: privileged, + }); + publishNotice({ + tab: "users", + tone: "success", + title: "System User Updated", + summary: res.message, + details: [ + `User: ${username}`, + res.data.addedGroups.length > 0 + ? `Groups added: ${res.data.addedGroups.join(", ")}` + : "Groups added: none", + res.data.removedGroups.length > 0 + ? `Groups removed: ${res.data.removedGroups.join(", ")}` + : "Groups removed: none", + res.data.previousShell !== res.data.shell + ? `Shell: ${res.data.previousShell} → ${res.data.shell}` + : "Login shell unchanged", + res.data.isSshUser + ? "New group access applies to new SSH sessions." + : null, + ].filter((value): value is string => Boolean(value)), + }); + onActionComplete(res.message); + await loadSnapshot(); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to update system user"; + publishNotice({ + tab: "users", + tone: "error", + title: "System User Update Failed", + summary: message, + details: [ + `User: ${username}`, + `Server: ${server.name} (${server.ip})`, + "Refresh Server Config before retrying if the host user changed outside Doktainer.", + ], + }); + onActionComplete(message, "error"); + } finally { + setActiveActionKey(null); + } + }; + + const requestSystemUserCreateConfirm = ( + username: string, + groups: string[], + ) => { + const privilegedGroups = groups.filter((group) => + ["root", "docker", "sudo", "wheel"].includes(group), + ); + const privileged = privilegedGroups.length > 0; + setPendingConfirm({ + kind: "system-user", + username, + groups, + privileged, + title: `Create system user ${username}`, + description: privileged + ? `This account will join privileged group(s): ${privilegedGroups.join(", ")}. This may grant administrative or root-equivalent host access.` + : `This creates a passwordless host account with a home directory and Bash shell${groups.length > 0 ? `, assigned to: ${groups.join(", ")}` : ""}.`, + confirmLabel: "Create System User", + tone: privileged ? "danger" : "info", + }); + }; + + const requestSystemGroupCreateConfirm = (groupName: string) => { + const privileged = ["root", "docker", "sudo", "wheel"].includes( + groupName, + ); + setPendingConfirm({ + kind: "system-group", + groupName, + privileged, + title: `Create system group ${groupName}`, + description: privileged + ? `The name ${groupName} is associated with privileged host access. Confirm only if this group is intentionally required.` + : `This creates the group ${groupName} on the host. It does not add any users automatically.`, + confirmLabel: "Create System Group", + tone: privileged ? "danger" : "info", + }); + }; + + const requestSystemUserUpdateConfirm = ( + user: ServerSystemUser, + groups: string[], + shell: string, + ) => { + const addedGroups = groups.filter((group) => !user.groups.includes(group)); + const removedGroups = user.groups.filter((group) => !groups.includes(group)); + const privilegedGroups = addedGroups.filter((group) => + ["root", "docker", "sudo", "wheel"].includes(group), + ); + const privileged = privilegedGroups.length > 0; + const shellChanged = shell !== user.shell; + setPendingConfirm({ + kind: "system-user-update", + username: user.username, + groups, + shell, + expectedGroups: user.groups, + expectedShell: user.shell ?? "", + privileged, + title: `Update system user ${user.username}`, + description: [ + addedGroups.length > 0 ? `Add groups: ${addedGroups.join(", ")}.` : null, + removedGroups.length > 0 + ? `Remove groups: ${removedGroups.join(", ")}.` + : null, + shellChanged ? `Change shell from ${user.shell} to ${shell}.` : null, + privileged + ? `This grants privileged access through: ${privilegedGroups.join(", ")}.` + : null, + ] + .filter(Boolean) + .join(" "), + confirmLabel: "Save User Changes", + tone: privileged || shell === "/usr/sbin/nologin" ? "danger" : "warning", + }); + }; + const renderActiveTab = () => { if (!snapshot) { return null; @@ -553,6 +789,12 @@ export default function ServerConfigModal({ ); case "services": @@ -616,8 +858,10 @@ export default function ServerConfigModal({ padding: "5px 12px", borderRadius: 4, boxSizing: "border-box", - borderColor: activeTab === id ? "rgba(59,130,246,0.5)" : "transparent", - background: activeTab === id ? "rgba(59,130,246,0.16)" : "transparent", + borderColor: + activeTab === id ? "rgba(59,130,246,0.5)" : "transparent", + background: + activeTab === id ? "rgba(59,130,246,0.16)" : "transparent", color: activeTab === id ? "var(--accent-blue)" : "var(--text-secondary)", flex: "0 0 auto", @@ -639,569 +883,576 @@ export default function ServerConfigModal({ > -
- {pendingConfirm && typeof document !== "undefined" - ? createPortal( -
-
-
- - {pendingConfirm.title} - -

- {pendingConfirm.description} -

-
- {pendingConfirm.kind === "server" && - pendingConfirm.action === "prune-docker" ? ( -
- - Select unused Docker artifacts to clean up - - {[ - { - key: "images", - label: "Unused images", - description: "Equivalent to docker image prune -a.", - }, - { - key: "containers", - label: "Stopped containers", - description: - "Equivalent to docker container prune.", - }, - { - key: "networks", - label: "Unused networks", - description: "Equivalent to docker network prune.", - }, - { - key: "volumes", - label: "Unused volumes", - description: "Equivalent to docker volume prune.", - }, - { - key: "buildCache", - label: "Build cache", - description: "Equivalent to docker builder prune.", - }, - ].map((option) => { - const checked = - dockerPruneOptions[ - option.key as keyof typeof dockerPruneOptions - ]; - - return ( - - ); - })} -
- ) : null} -
- Confirm this action only if you expect a brief service - interruption or cleanup change on the host. -
-
- - -
-
-
, - document.body, - ) - : null} - -
-
+
+ {pendingConfirm && typeof document !== "undefined" + ? createPortal(
-

+
+ + {pendingConfirm.title} + +

- Server Config -

- + {pendingConfirm.description} +

-

- {server.name} • {server.ip}:{server.sshPort} -

-

- Snapshot refresh runs only when this modal opens or when you - click Refresh. -

-
-
- -
-
- - - -
- {notice && notice.tab === activeTab ? ( -
+ {pendingConfirm.kind === "server" && + pendingConfirm.action === "prune-docker" ? (
-
- {notice.title} -
- {notice.summary} -
-
- -
- {notice.details?.length || notice.detailText ? ( -
- - {noticeExpanded ? ( -
+ {[ + { + key: "images", + label: "Unused images", + description: "Equivalent to docker image prune -a.", + }, + { + key: "containers", + label: "Stopped containers", + description: "Equivalent to docker container prune.", + }, + { + key: "networks", + label: "Unused networks", + description: "Equivalent to docker network prune.", + }, + { + key: "volumes", + label: "Unused volumes", + description: "Equivalent to docker volume prune.", + }, + { + key: "buildCache", + label: "Build cache", + description: "Equivalent to docker builder prune.", + }, + ].map((option) => { + const checked = + dockerPruneOptions[ + option.key as keyof typeof dockerPruneOptions + ]; + + return ( +
+ {option.description} + + + + ); + })} +
+ ) : null} +
+ {pendingConfirm.kind === "system-user" || + pendingConfirm.kind === "system-group" || + pendingConfirm.kind === "system-user-update" + ? "This changes host access control. Review the exact account and group names before continuing; the action will be recorded in the audit log." + : "Confirm this action only if you expect a brief service interruption or cleanup change on the host."} +
+
+ + +
+
+
, + document.body, + ) + : null} + +
+
+
+

+ Server Config +

+ +
+

+ {server.name} • {server.ip}:{server.sshPort} +

+

+ Snapshot refresh runs only when this modal opens or when you click + Refresh. +

+
+
+ +
+
+ + + +
+ {notice && notice.tab === activeTab ? ( +
+
+
+ {notice.title} +
+ {notice.summary} +
+
+ +
+ {notice.details?.length || notice.detailText ? ( +
+ + {noticeExpanded ? ( +
+ {notice.details?.length ? ( +
    + {notice.details.map((detail) => ( +
  • {detail}
  • + ))} +
+ ) : null} + {notice.detailText ? ( +
+                        {notice.detailText}
+                      
) : null}
) : null}
) : null} +
+ ) : null} - {error ? ( - - ) : null} + {error ? ( + + ) : null} - {snapshotLoadError ? ( -
+
+ + Live configuration snapshot unavailable + +

-

- - Live configuration snapshot unavailable - -

- The server did not return a full config snapshot, so this - modal is showing fallback information. Recovery actions - remain available, especially in the Actions tab. -

-
- -
- ) : null} - - {loading ? ( -
- -

- Loading configuration snapshot... -

-
- ) : ( - renderActiveTab() - )} + The server did not return a full config snapshot, so this modal + is showing fallback information. Recovery actions remain + available, especially in the Actions tab. +

+
+
+ ) : null} + + {loading ? ( +
+ +

+ Loading configuration snapshot... +

+
+ ) : ( + renderActiveTab() + )} +
diff --git a/src/app/servers/components/server-config-utils.ts b/src/app/servers/components/server-config-utils.ts index 139e6b7..bf42ea8 100644 --- a/src/app/servers/components/server-config-utils.ts +++ b/src/app/servers/components/server-config-utils.ts @@ -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: { diff --git a/src/app/servers/components/server-config/ServerConfigPrimitives.tsx b/src/app/servers/components/server-config/ServerConfigPrimitives.tsx index 137bb09..32632ba 100644 --- a/src/app/servers/components/server-config/ServerConfigPrimitives.tsx +++ b/src/app/servers/components/server-config/ServerConfigPrimitives.tsx @@ -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 ; } -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 ?? "—"}

- + {user.isRoot ? ( + + Protected + + ) : ( + + )}
+ 0 ? user.groups.join(", ") : "—"} />
+ {children} ); } diff --git a/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx b/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx index ddb09e2..d98929c 100644 --- a/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx +++ b/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx @@ -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(() => + snapshot.systemGroups.includes("docker") ? ["docker"] : [], + ); + const [editingUsername, setEditingUsername] = useState(null); + const [editDraftGroups, setEditDraftGroups] = useState([]); + 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 || ""}`, + ] + .filter(Boolean) + .join(" && ") + : `groupadd ${previewGroupName || ""}`; + + 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 (
+
+
+
+ + User & Group Management + +

+ Create passwordless host accounts, create groups, and assign + supplementary group access without opening a terminal. +

+
+ +
+ +
+
+
+ {[ + { 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 ( + + ); + })} +
+ + {previewMode === "user" ? ( + <> + +
+ + Initial groups ({selectedGroups.length}/16) + +
+ {quickGroupOptions.map((group) => { + const active = selectedGroups.includes(group); + const sensitive = sensitiveGroups.has(group); + return ( + + ); + })} +
+ {groupOptions.some( + (group) => !selectedGroups.includes(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} +
+ + ) : ( + + )} +
+ +
+
+
+ + Planned host change + + {privilegedSelection ? ( + + ) : ( + + )} +
+ + {previewCommand} + + {previewMode === "user" ? ( +

+ No password is set. Configure an SSH public key separately + before using this account for remote login. +

+ ) : null} +
+ + {privilegedSelection ? ( +
+ + + {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. + +
+ ) : null} + + + {!canManageSystemAccounts ? ( +

+ Operator or Super Admin role is required for host account + changes. +

+ ) : null} +
+
+
{users.length > 0 ? (
- {users.map((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 ( + beginEdit(sourceUser)} + > + {isEditing ? ( +
+
+ + {sourceUser.isSshUser + ? "Edit SSH user access" + : "Edit user access"} + + +
+ + {sourceUser.isSshUser ? ( +
+ The active SSH login keeps its current shell and groups. + You can only add supplementary groups. Changes apply to + new SSH sessions after confirmation. +
+ ) : null} + + + +
+ + Group memberships ({editDraftGroups.length}/16) + +
+ {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 ( + + ); + })} +
+ {groupOptions.some( + (group) => !editDraftGroups.includes(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} +
+ + {selectedPrivilegedEditGroups.length > 0 ? ( +
+ New privileged access selected:{" "} + {selectedPrivilegedEditGroups.join(", ")}. The live + action requires explicit confirmation and is audited. +
+ ) : null} + +
+ + +
+
+ ) : null} +
+ ); + })}
) : (
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`, diff --git a/src/server/routes/servers.ts b/src/server/routes/servers.ts index f158a5e..7a7cf84 100644 --- a/src/server/routes/servers.ts +++ b/src/server/routes/servers.ts @@ -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 }, diff --git a/src/server/services/ssh-services/internal/privilege.ts b/src/server/services/ssh-services/internal/privilege.ts index 4dfc1aa..c07a146 100644 --- a/src/server/services/ssh-services/internal/privilege.ts +++ b/src/server/services/ssh-services/internal/privilege.ts @@ -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}`; +} diff --git a/src/server/services/ssh-services/internal/system-account-security.ts b/src/server/services/ssh-services/internal/system-account-security.ts new file mode 100644 index 0000000..dd693e7 --- /dev/null +++ b/src/server/services/ssh-services/internal/system-account-security.ts @@ -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; +} diff --git a/src/server/services/ssh-services/server-config.ts b/src/server/services/ssh-services/server-config.ts index b2cf7d4..e0df20e 100644 --- a/src/server/services/ssh-services/server-config.ts +++ b/src/server/services/ssh-services/server-config.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, diff --git a/tests/backend/system-account-security.test.ts b/tests/backend/system-account-security.test.ts new file mode 100644 index 0000000..6ed41ae --- /dev/null +++ b/tests/backend/system-account-security.test.ts @@ -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/, + ); +});