from pathlib import Path import subprocess from typing import List, Optional from app.core.errors import ApiError, map_command_error from app.core.models import GroupSummary, UserSummary from app.providers.base import SystemProvider class CommandExecutor: def __init__(self, sudo_path: str, timeout_seconds: int): self.sudo_path = sudo_path self.timeout_seconds = timeout_seconds self.allowlist = {"useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent", "mkdir", "ln", "chown", "unlink"} def run(self, args: List[str], use_sudo: bool = True) -> str: if not args: raise ApiError(500, "invalid_command", "Empty command.") command = args[0] if command not in self.allowlist: raise ApiError(500, "forbidden_command", f"Command not allowlisted: {command}") full = [command] + args[1:] if use_sudo: full = [self.sudo_path, "-n"] + full try: result = subprocess.run(full, capture_output=True, text=True, timeout=self.timeout_seconds, check=False) except subprocess.TimeoutExpired as exception: raise ApiError(503, "system_timeout", "System command timed out.") from exception if result.returncode != 0: raise map_command_error(result.stderr, result.returncode) return result.stdout.strip() class CliSystemProvider(SystemProvider): def __init__(self, executor: CommandExecutor): self.executor = executor def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None: command = ["useradd", "-s", shell, "-p", password_hash] if linked_home_dir: command.append("-M") else: command.append("-m") if home_dir: command.extend(["-d", home_dir]) if primary_group: command.extend(["-g", primary_group]) if groups: command.extend(["-G", ",".join(groups)]) command.append(username) self.executor.run(command) if linked_home_dir and home_dir: self.executor.run(["mkdir", "-p", linked_home_dir]) self.executor.run(["chown", "-R", username, linked_home_dir]) self.executor.run(["ln", "-s", linked_home_dir, home_dir]) def delete_user(self, username: str) -> None: home_dir = self.get_user(username).home_dir self.executor.run(["userdel", username]) if Path(home_dir).is_symlink(): self.executor.run(["unlink", home_dir]) def change_user_password(self, username: str, password_hash: str) -> None: self.executor.run(["usermod", "-p", password_hash, username]) def list_users(self) -> List[UserSummary]: output = self.executor.run(["getent", "passwd"], use_sudo=False) users: List[UserSummary] = [] for line in output.splitlines(): parts = line.split(":") if len(parts) < 7: continue username, _, uid, gid, _, home_dir, shell = parts[:7] users.append(UserSummary(username=username, uid=int(uid), gid=int(gid), home_dir=home_dir, shell=shell)) return users def get_user(self, username: str) -> UserSummary: output = self.executor.run(["getent", "passwd", username], use_sudo=False) if not output: raise ApiError(404, "not_found", f"User not found: {username}") parts = output.split(":") return UserSummary(username=parts[0], uid=int(parts[2]), gid=int(parts[3]), home_dir=parts[5], shell=parts[6]) def create_group(self, groupname: str) -> None: self.executor.run(["groupadd", groupname]) def delete_group(self, groupname: str) -> None: self.executor.run(["groupdel", groupname]) def list_groups(self) -> List[GroupSummary]: output = self.executor.run(["getent", "group"], use_sudo=False) groups: List[GroupSummary] = [] for line in output.splitlines(): parts = line.split(":") if len(parts) < 4: continue name, _, gid, members = parts[:4] member_list = [member for member in members.split(",") if member] groups.append(GroupSummary(groupname=name, gid=int(gid), members=member_list)) return groups def get_group(self, groupname: str) -> GroupSummary: output = self.executor.run(["getent", "group", groupname], use_sudo=False) if not output: raise ApiError(404, "not_found", f"Group not found: {groupname}") parts = output.split(":") members = [member for member in parts[3].split(",") if member] return GroupSummary(groupname=parts[0], gid=int(parts[2]), members=members) def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: mode_flag = "-G" if replace else "-aG" self.executor.run(["usermod", mode_flag, ",".join(groups), username]) def remove_user_groups(self, username: str, groups: List[str]) -> None: current = self.get_user_groups(username) remaining = [group for group in current if group not in set(groups)] self.executor.run(["usermod", "-G", ",".join(remaining), username]) def get_user_groups(self, username: str) -> List[str]: output = self.executor.run(["id", "-nG", username], use_sudo=False) return [group for group in output.split() if group]