From 884e0235b0b62c9933afe1a0b87139fd6cbc7671 Mon Sep 17 00:00:00 2001 From: Boen_Shi Date: Thu, 28 May 2026 13:55:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8A=9F=E8=83=BD):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E9=83=A8=E5=88=86=E5=8A=9F=E8=83=BD=E5=8F=8A1=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/OpsClientController.php | 10 --------- .../Api/ServerSystemUserController.php | 4 ---- app/Http/Controllers/Api/UserController.php | 3 +-- user_manage_api/.env.example | 1 + user_manage_api/api_document.md | 18 ++++++--------- user_manage_api/app/core/config.py | 4 ++++ user_manage_api/app/core/models.py | 6 ++--- user_manage_api/app/main.py | 1 + .../app/services/user_group_service.py | 22 +++++-------------- user_manage_api/logs/user_manage_api.jsonl | 5 +++++ user_manage_api/logs/user_manage_api.log | 5 +++++ user_manage_api/tests/test_api_integration.py | 16 ++++++++++++++ user_manage_api/tests/test_service_unit.py | 18 +++++++++------ 13 files changed, 60 insertions(+), 53 deletions(-) diff --git a/app/Http/Controllers/Api/OpsClientController.php b/app/Http/Controllers/Api/OpsClientController.php index c9cb861..b244bed 100644 --- a/app/Http/Controllers/Api/OpsClientController.php +++ b/app/Http/Controllers/Api/OpsClientController.php @@ -70,10 +70,7 @@ class OpsClientController extends Controller { $validated = $request->validate([ 'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name'], - 'bastion_protocol_id' => ['required', 'integer', 'min:1'], 'description' => ['nullable', 'string', 'max:255'], - 'sort' => ['sometimes', 'integer', 'min:0'], - 'is_active' => ['sometimes', 'boolean'], ]); $protocol = OpsProtocol::query()->create($validated); @@ -88,10 +85,7 @@ class OpsClientController extends Controller $protocol = OpsProtocol::query()->findOrFail($id); $validated = $request->validate([ 'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name,'.$protocol->id], - 'bastion_protocol_id' => ['required', 'integer', 'min:1'], 'description' => ['nullable', 'string', 'max:255'], - 'sort' => ['sometimes', 'integer', 'min:0'], - 'is_active' => ['sometimes', 'boolean'], ]); $protocol->update($validated); @@ -116,8 +110,6 @@ class OpsClientController extends Controller OpsProtocol::query()->findOrFail($id); $validated = $request->validate([ 'name' => ['required', 'string', 'max:100'], - 'client_path' => ['nullable', 'string', 'max:255'], - 'sort' => ['sometimes', 'integer', 'min:0'], 'is_active' => ['sometimes', 'boolean'], ]); @@ -146,8 +138,6 @@ class OpsClientController extends Controller $software = OpsSoftware::query()->findOrFail($id); $validated = $request->validate([ 'name' => ['required', 'string', 'max:100'], - 'client_path' => ['nullable', 'string', 'max:255'], - 'sort' => ['sometimes', 'integer', 'min:0'], 'is_active' => ['sometimes', 'boolean'], ]); diff --git a/app/Http/Controllers/Api/ServerSystemUserController.php b/app/Http/Controllers/Api/ServerSystemUserController.php index 1e95c29..a4fc056 100644 --- a/app/Http/Controllers/Api/ServerSystemUserController.php +++ b/app/Http/Controllers/Api/ServerSystemUserController.php @@ -116,8 +116,6 @@ class ServerSystemUserController extends Controller 'primary_group' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], 'groups' => ['sometimes', 'array'], 'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], - 'shell' => ['nullable', 'string', 'max:128'], - 'home_dir' => ['nullable', 'string', 'max:255'], 'user_id' => ['nullable', 'integer', 'exists:users,id'], ]); @@ -132,8 +130,6 @@ class ServerSystemUserController extends Controller 'password_hash' => $passwordHash, 'primary_group' => $validated['primary_group'] ?? null, 'groups' => array_values(array_unique($validated['groups'] ?? [])), - 'shell' => $validated['shell'] ?? '/bin/bash', - 'home_dir' => $validated['home_dir'] ?? null, ]; $result = $this->client->createUser($server, $payload); diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index d9a1aa9..e01997d 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -188,6 +188,7 @@ class UserController extends Controller { $user = User::query()->findOrFail($id); $this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]); + ServerUserBinding::query()->where('user_id', $user->id)->delete(); $user->delete(); return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]); @@ -556,8 +557,6 @@ class UserController extends Controller 'password_hash' => $this->linuxPasswordHash($password), 'primary_group' => null, 'groups' => array_values(array_unique($binding['groups'] ?? [])), - 'shell' => '/bin/bash', - 'home_dir' => null, ]); $remoteExists = true; } diff --git a/user_manage_api/.env.example b/user_manage_api/.env.example index 1ba32eb..4d46480 100644 --- a/user_manage_api/.env.example +++ b/user_manage_api/.env.example @@ -2,6 +2,7 @@ TOKEN=replace-me SERVER_NAME=user-manage-api HOME_BASE_DIR=/home LINK_HOME_DIR= +DEFAULT_SHELL=/bin/bash WHITELIST_USERS= WHITELIST_GROUPS= LOCKED_USERS= diff --git a/user_manage_api/api_document.md b/user_manage_api/api_document.md index a432352..98c3430 100644 --- a/user_manage_api/api_document.md +++ b/user_manage_api/api_document.md @@ -61,7 +61,6 @@ SERVER_NAME=user-manage-api | HTTP 状态码 | code | 描述 | | --- | --- | --- | | 400 | `invalid_parameter` | 请求参数格式或校验失败 | -| 400 | `invalid_home_dir` | `home_dir` 不在 `HOME_BASE_DIR` 下 | | 401 | `unauthorized` | 未提供 token 或 token 不正确 | | 404 | `not_found` | 用户或用户组不存在,或被可见性规则隐藏 | | 409 | `resource_conflict` | 用户或用户组已存在 | @@ -110,12 +109,14 @@ GROUP_GID_MAX=60000 ```env HOME_BASE_DIR=/home LINK_HOME_DIR= +DEFAULT_SHELL=/bin/bash ``` 创建用户时: -- `home_dir` 为空时,默认使用 `HOME_BASE_DIR/username`。 -- `home_dir` 不为空时,必须位于 `HOME_BASE_DIR` 下。 +- API 不接收客户端传入的 `home_dir` 和 `shell`。 +- 用户 home 固定使用 `HOME_BASE_DIR/username`。 +- 登录 shell 固定使用 `DEFAULT_SHELL`。 - `LINK_HOME_DIR` 为空时,用户目录直接创建在 `HOME_BASE_DIR` 下。 - `LINK_HOME_DIR` 不为空时,实际目录创建在 `LINK_HOME_DIR/username`,并在 `HOME_BASE_DIR/username` 创建软链接。 @@ -135,8 +136,6 @@ LINK_HOME_DIR= | `password_hash` | string | 是 | - | 预先生成的 Linux 密码 hash,长度 10 到 512 | | `primary_group` | string/null | 否 | `null` | 主用户组。格式同用户组名 | | `groups` | string[] | 否 | `[]` | 附加用户组,会自动去重 | -| `shell` | string | 否 | `/bin/bash` | 登录 shell | -| `home_dir` | string/null | 否 | `null` | 用户 home 路径,必须在 `HOME_BASE_DIR` 下 | ### UserSummary @@ -227,9 +226,7 @@ POST /users "username": "alice", "password_hash": "$6$salt$hash", "primary_group": null, - "groups": ["dev"], - "shell": "/bin/bash", - "home_dir": "/home/alice" + "groups": ["dev"] } ``` @@ -255,7 +252,7 @@ POST /users - `username` 在黑名单中且不在白名单中时禁止创建。 - `primary_group` 和 `groups` 中的用户组必须存在且可见。 -- `home_dir` 必须在 `HOME_BASE_DIR` 下。 +- 请求体不允许传入 `shell` 或 `home_dir`,两者由服务端配置决定。 - 启用 `LINK_HOME_DIR` 后,账号 home 仍为 `HOME_BASE_DIR/username`,实际目录位于 `LINK_HOME_DIR/username`。 ### 删除用户 @@ -586,7 +583,6 @@ curl -X POST 'http://127.0.0.1:8000/users' \ -H 'Content-Type: application/json' \ -d '{ "username": "alice", - "password_hash": "$6$rounds=5000$salt$hash", - "shell": "/bin/bash" + "password_hash": "$6$rounds=5000$salt$hash" }' ``` diff --git a/user_manage_api/app/core/config.py b/user_manage_api/app/core/config.py index a1adbe3..bcad48e 100644 --- a/user_manage_api/app/core/config.py +++ b/user_manage_api/app/core/config.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): server_name: str = Field(default="user-manage-api", alias="SERVER_NAME") home_base_dir: str = Field(default="/home", alias="HOME_BASE_DIR") link_home_dir: str = Field(default="", alias="LINK_HOME_DIR") + default_shell: str = Field(default="/bin/bash", alias="DEFAULT_SHELL") whitelist_users: str = Field(default="", alias="WHITELIST_USERS") whitelist_groups: str = Field(default="", alias="WHITELIST_GROUPS") locked_users: str = Field(default="", alias="LOCKED_USERS") @@ -78,6 +79,9 @@ def validate_settings(settings: Settings) -> None: if settings.group_gid_min is not None and settings.group_gid_max is not None and settings.group_gid_min > settings.group_gid_max: raise ValueError("GROUP_GID_MIN cannot be greater than GROUP_GID_MAX.") + if not settings.default_shell.startswith("/"): + raise ValueError("DEFAULT_SHELL must be an absolute path.") + required_commands = ["useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent"] if settings.link_home_dir.strip(): required_commands.extend(["mkdir", "ln", "chown", "unlink"]) diff --git a/user_manage_api/app/core/models.py b/user_manage_api/app/core/models.py index 56f1995..86d7f43 100644 --- a/user_manage_api/app/core/models.py +++ b/user_manage_api/app/core/models.py @@ -1,6 +1,6 @@ from typing import List, Literal, Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator USERNAME_PATTERN = r"^[a-z_][a-z0-9_-]{0,31}$" @@ -8,12 +8,12 @@ GROUPNAME_PATTERN = r"^[a-z_][a-z0-9_-]{0,31}$" class UserCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + username: str = Field(pattern=USERNAME_PATTERN) password_hash: str = Field(min_length=10, max_length=512) primary_group: Optional[str] = Field(default=None, pattern=GROUPNAME_PATTERN) groups: List[str] = Field(default_factory=list) - shell: str = "/bin/bash" - home_dir: Optional[str] = None @field_validator("groups") @classmethod diff --git a/user_manage_api/app/main.py b/user_manage_api/app/main.py index 18bf350..9add330 100644 --- a/user_manage_api/app/main.py +++ b/user_manage_api/app/main.py @@ -15,6 +15,7 @@ service = UserGroupService( provider=provider, home_base_dir=settings.home_base_dir, link_home_dir=settings.link_home_dir or None, + default_shell=settings.default_shell, hidden_users=settings.hidden_user_list, hidden_groups=settings.hidden_group_list, whitelist_users=settings.whitelist_user_list, diff --git a/user_manage_api/app/services/user_group_service.py b/user_manage_api/app/services/user_group_service.py index 69b9fa4..738d7e5 100644 --- a/user_manage_api/app/services/user_group_service.py +++ b/user_manage_api/app/services/user_group_service.py @@ -12,6 +12,7 @@ class UserGroupService: provider: SystemProvider, home_base_dir: str, link_home_dir: Optional[str] = None, + default_shell: str = "/bin/bash", hidden_users: Optional[List[str]] = None, hidden_groups: Optional[List[str]] = None, whitelist_users: Optional[List[str]] = None, @@ -25,6 +26,7 @@ class UserGroupService: self.provider = provider self.home_base_dir = PurePosixPath(home_base_dir) self.link_home_base_dir = PurePosixPath(link_home_dir) if link_home_dir else None + self.default_shell = default_shell self.hidden_users = set(hidden_users or []) self.hidden_groups = set(hidden_groups or []) self.whitelist_users = set(whitelist_users or []) @@ -89,11 +91,8 @@ class UserGroupService: return False return self._is_gid_in_range(group.gid) - def _resolve_home_dir(self, home_dir: Optional[str], username: str) -> str: - if home_dir is None: - return str(self.home_base_dir / username) - - return self._validate_home_dir(home_dir, username) + def _resolve_home_dir(self, username: str) -> str: + return str(self.home_base_dir / username) def _resolve_linked_home_dir(self, username: str) -> Optional[str]: if self.link_home_base_dir is None: @@ -101,29 +100,20 @@ class UserGroupService: return str(self.link_home_base_dir / username) - def _validate_home_dir(self, home_dir: str, username: str) -> str: - if home_dir is None: - return str(self.home_base_dir / username) - path = PurePosixPath(home_dir) - base = self.home_base_dir - if not str(path).startswith(str(base) + "/") and path != base / username: - raise ApiError(400, "invalid_home_dir", "home_dir must be inside HOME_BASE_DIR.") - return str(path) - def create_user(self, payload: UserCreateRequest) -> None: self._ensure_user_name_allowed(payload.username) self._ensure_user_unlocked(payload.username) if payload.primary_group is not None: self._ensure_group_visible(payload.primary_group) self._ensure_groups_visible(payload.groups) - home_dir = self._resolve_home_dir(payload.home_dir, payload.username) + home_dir = self._resolve_home_dir(payload.username) linked_home_dir = self._resolve_linked_home_dir(payload.username) self.provider.create_user( username=payload.username, password_hash=payload.password_hash, home_dir=home_dir, linked_home_dir=linked_home_dir, - shell=payload.shell, + shell=self.default_shell, primary_group=payload.primary_group, groups=payload.groups, ) diff --git a/user_manage_api/logs/user_manage_api.jsonl b/user_manage_api/logs/user_manage_api.jsonl index 4e10e08..12dca23 100644 --- a/user_manage_api/logs/user_manage_api.jsonl +++ b/user_manage_api/logs/user_manage_api.jsonl @@ -11,3 +11,8 @@ {"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864656.8571804, "duration_ms": 0} {"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865162.184787, "duration_ms": 0} {"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865345.4267325, "duration_ms": 0} +{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943112.1322336, "duration_ms": 0} +{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943132.5661097, "duration_ms": 0} +{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943176.5257142, "duration_ms": 0} +{"operation": "change_user_password", "target": "boenadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943323.021569, "duration_ms": 0} +{"operation": "change_user_password", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943479.098227, "duration_ms": 0} diff --git a/user_manage_api/logs/user_manage_api.log b/user_manage_api/logs/user_manage_api.log index 7be5229..1382970 100644 --- a/user_manage_api/logs/user_manage_api.log +++ b/user_manage_api/logs/user_manage_api.log @@ -11,3 +11,8 @@ 2026-05-27 14:50:56,857 INFO operation=delete_user target=testtest result=success code=None request_id= 2026-05-27 14:59:22,184 INFO operation=create_user target=testtest result=success code=None request_id= 2026-05-27 15:02:25,426 INFO operation=delete_user target=testtest result=success code=None request_id= +2026-05-28 12:38:32,132 INFO operation=create_user target=testtest result=success code=None request_id= +2026-05-28 12:38:52,566 INFO operation=delete_user target=testtest result=success code=None request_id= +2026-05-28 12:39:36,525 INFO operation=create_user target=testtest result=success code=None request_id= +2026-05-28 12:42:03,021 INFO operation=change_user_password target=boenadmin result=success code=None request_id= +2026-05-28 12:44:39,098 INFO operation=change_user_password target=testtest result=success code=None request_id= diff --git a/user_manage_api/tests/test_api_integration.py b/user_manage_api/tests/test_api_integration.py index 8ff7e56..ff77f2c 100644 --- a/user_manage_api/tests/test_api_integration.py +++ b/user_manage_api/tests/test_api_integration.py @@ -150,6 +150,22 @@ def test_change_user_password() -> None: assert response.json()["message"] == "User password updated." +def test_create_user_rejects_client_shell_and_home_dir() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + payload = { + "username": "alice", + "password_hash": "$6$rounds=5000$abcdefghij", + "shell": "/bin/sh", + "home_dir": "/tmp/alice", + } + + response = client.post("/users", json=payload, headers=headers) + + assert response.status_code == 400 + assert response.json()["code"] == "invalid_parameter" + + def test_locked_user_can_be_read_but_not_modified() -> None: client = build_client(locked_users=["alice"]) headers = {"Authorization": "Bearer test-token"} diff --git a/user_manage_api/tests/test_service_unit.py b/user_manage_api/tests/test_service_unit.py index 2b35017..172a944 100644 --- a/user_manage_api/tests/test_service_unit.py +++ b/user_manage_api/tests/test_service_unit.py @@ -1,4 +1,3 @@ -import pytest from typing import List, Optional from app.core.errors import ApiError, map_command_error @@ -13,10 +12,12 @@ class NoopProvider(SystemProvider): def __init__(self) -> None: self.created_home_dir: Optional[str] = None self.created_linked_home_dir: Optional[str] = None + self.created_shell: Optional[str] = None 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: self.created_home_dir = home_dir self.created_linked_home_dir = linked_home_dir + self.created_shell = shell return None def delete_user(self, username: str) -> None: return None @@ -42,12 +43,15 @@ class NoopProvider(SystemProvider): return [] -def test_home_dir_out_of_base_rejected() -> None: - service = UserGroupService(provider=NoopProvider(), home_base_dir="/home") - payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", home_dir="/tmp/alice", groups=[]) - with pytest.raises(ApiError) as raised: - service.create_user(payload) - assert raised.value.status_code == 400 +def test_create_user_uses_configured_shell_and_home_dir() -> None: + provider = NoopProvider() + service = UserGroupService(provider=provider, home_base_dir="/srv/home", default_shell="/usr/sbin/nologin") + payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", groups=[]) + + service.create_user(payload) + + assert provider.created_home_dir == "/srv/home/alice" + assert provider.created_shell == "/usr/sbin/nologin" def test_link_home_dir_uses_home_base_symlink_path_and_external_storage() -> None: