feat(servers): Added features for adding host users and secure group management.
This commit is contained in:
1 parent
9486f68022
commit
e1d09fdf46
10 files changed
+2061
-58
No files matched your search
@@ -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({
|
||||
<ServerConfigUsersPanel
|
||||
snapshot={snapshot}
|
||||
snapshotLoadError={snapshotLoadError}
|
||||
canManageSystemAccounts={canManageSystemAccounts}
|
||||
isActionRunning={isActionRunning}
|
||||
getSystemUserUpdateActionKey={getSystemUserUpdateActionKey}
|
||||
onRequestCreateUserConfirm={requestSystemUserCreateConfirm}
|
||||
onRequestCreateGroupConfirm={requestSystemGroupCreateConfirm}
|
||||
onRequestUpdateUserConfirm={requestSystemUserUpdateConfirm}
|
||||
/>
|
||||
);
|
||||
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",
|
||||
@@ -679,9 +923,7 @@ export default function ServerConfigModal({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 16 }}
|
||||
>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 16 }}>
|
||||
{pendingConfirm.title}
|
||||
</strong>
|
||||
<p
|
||||
@@ -721,8 +963,7 @@ export default function ServerConfigModal({
|
||||
{
|
||||
key: "containers",
|
||||
label: "Stopped containers",
|
||||
description:
|
||||
"Equivalent to docker container prune.",
|
||||
description: "Equivalent to docker container prune.",
|
||||
},
|
||||
{
|
||||
key: "networks",
|
||||
@@ -815,9 +1056,7 @@ export default function ServerConfigModal({
|
||||
padding: "12px 14px",
|
||||
fontSize: 12,
|
||||
color:
|
||||
pendingConfirm.tone === "danger"
|
||||
? "#ef4444"
|
||||
: "#b45309",
|
||||
pendingConfirm.tone === "danger" ? "#ef4444" : "#b45309",
|
||||
background:
|
||||
pendingConfirm.tone === "danger"
|
||||
? "rgba(239,68,68,0.08)"
|
||||
@@ -828,15 +1067,14 @@ export default function ServerConfigModal({
|
||||
: "1px solid rgba(245,158,11,0.24)",
|
||||
}}
|
||||
>
|
||||
Confirm this action only if you expect a brief service
|
||||
interruption or cleanup change on the host.
|
||||
{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."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}
|
||||
>
|
||||
<button
|
||||
className="btn"
|
||||
@@ -877,8 +1115,35 @@ export default function ServerConfigModal({
|
||||
}
|
||||
|
||||
if (currentConfirm.kind === "service") {
|
||||
void handleServiceRestart(
|
||||
currentConfirm.serviceName,
|
||||
void handleServiceRestart(currentConfirm.serviceName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentConfirm.kind === "system-user") {
|
||||
void handleSystemUserCreate(
|
||||
currentConfirm.username,
|
||||
currentConfirm.groups,
|
||||
currentConfirm.privileged,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentConfirm.kind === "system-group") {
|
||||
void handleSystemGroupCreate(
|
||||
currentConfirm.groupName,
|
||||
currentConfirm.privileged,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentConfirm.kind === "system-user-update") {
|
||||
void handleSystemUserUpdate(
|
||||
currentConfirm.username,
|
||||
currentConfirm.groups,
|
||||
currentConfirm.shell,
|
||||
currentConfirm.expectedGroups,
|
||||
currentConfirm.expectedShell,
|
||||
currentConfirm.privileged,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -900,9 +1165,7 @@ export default function ServerConfigModal({
|
||||
? "rgba(239,68,68,0.12)"
|
||||
: "rgba(245,158,11,0.12)",
|
||||
color:
|
||||
pendingConfirm.tone === "danger"
|
||||
? "#ef4444"
|
||||
: "#f59e0b",
|
||||
pendingConfirm.tone === "danger" ? "#ef4444" : "#f59e0b",
|
||||
border:
|
||||
pendingConfirm.tone === "danger"
|
||||
? "1px solid rgba(239,68,68,0.22)"
|
||||
@@ -961,23 +1224,15 @@ export default function ServerConfigModal({
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
marginTop: 6,
|
||||
}}
|
||||
style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 6 }}
|
||||
>
|
||||
{server.name} • {server.ip}:{server.sshPort}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
marginTop: 6,
|
||||
}}
|
||||
style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 6 }}
|
||||
>
|
||||
Snapshot refresh runs only when this modal opens or when you
|
||||
click Refresh.
|
||||
Snapshot refresh runs only when this modal opens or when you click
|
||||
Refresh.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, paddingRight: 36 }}>
|
||||
@@ -1091,11 +1346,7 @@ export default function ServerConfigModal({
|
||||
onClick={() => setNoticeExpanded((current) => !current)}
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
{noticeExpanded ? (
|
||||
<EyeOff size={12} />
|
||||
) : (
|
||||
<Eye size={12} />
|
||||
)}
|
||||
{noticeExpanded ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
{noticeExpanded ? "Hide detail" : "More detail"}
|
||||
</button>
|
||||
{noticeExpanded ? (
|
||||
@@ -1174,9 +1425,9 @@ export default function ServerConfigModal({
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<IssueDetailsSummary
|
||||
|
||||
@@ -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 = [
|
||||
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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user