feat(功能): 完善部分功能及1界面

This commit is contained in:
Boen_Shi 2026-05-28 13:55:08 +08:00
parent ca023c23f8
commit 884e0235b0
13 changed files with 60 additions and 53 deletions

View File

@ -70,10 +70,7 @@ class OpsClientController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name'], 'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name'],
'bastion_protocol_id' => ['required', 'integer', 'min:1'],
'description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]); ]);
$protocol = OpsProtocol::query()->create($validated); $protocol = OpsProtocol::query()->create($validated);
@ -88,10 +85,7 @@ class OpsClientController extends Controller
$protocol = OpsProtocol::query()->findOrFail($id); $protocol = OpsProtocol::query()->findOrFail($id);
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name,'.$protocol->id], 'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name,'.$protocol->id],
'bastion_protocol_id' => ['required', 'integer', 'min:1'],
'description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]); ]);
$protocol->update($validated); $protocol->update($validated);
@ -116,8 +110,6 @@ class OpsClientController extends Controller
OpsProtocol::query()->findOrFail($id); OpsProtocol::query()->findOrFail($id);
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:100'], 'name' => ['required', 'string', 'max:100'],
'client_path' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
]); ]);
@ -146,8 +138,6 @@ class OpsClientController extends Controller
$software = OpsSoftware::query()->findOrFail($id); $software = OpsSoftware::query()->findOrFail($id);
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:100'], 'name' => ['required', 'string', 'max:100'],
'client_path' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
]); ]);

View File

@ -116,8 +116,6 @@ class ServerSystemUserController extends Controller
'primary_group' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], 'primary_group' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'groups' => ['sometimes', 'array'], 'groups' => ['sometimes', 'array'],
'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], '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'], 'user_id' => ['nullable', 'integer', 'exists:users,id'],
]); ]);
@ -132,8 +130,6 @@ class ServerSystemUserController extends Controller
'password_hash' => $passwordHash, 'password_hash' => $passwordHash,
'primary_group' => $validated['primary_group'] ?? null, 'primary_group' => $validated['primary_group'] ?? null,
'groups' => array_values(array_unique($validated['groups'] ?? [])), 'groups' => array_values(array_unique($validated['groups'] ?? [])),
'shell' => $validated['shell'] ?? '/bin/bash',
'home_dir' => $validated['home_dir'] ?? null,
]; ];
$result = $this->client->createUser($server, $payload); $result = $this->client->createUser($server, $payload);

View File

@ -188,6 +188,7 @@ class UserController extends Controller
{ {
$user = User::query()->findOrFail($id); $user = User::query()->findOrFail($id);
$this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]); $this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]);
ServerUserBinding::query()->where('user_id', $user->id)->delete();
$user->delete(); $user->delete();
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
@ -556,8 +557,6 @@ class UserController extends Controller
'password_hash' => $this->linuxPasswordHash($password), 'password_hash' => $this->linuxPasswordHash($password),
'primary_group' => null, 'primary_group' => null,
'groups' => array_values(array_unique($binding['groups'] ?? [])), 'groups' => array_values(array_unique($binding['groups'] ?? [])),
'shell' => '/bin/bash',
'home_dir' => null,
]); ]);
$remoteExists = true; $remoteExists = true;
} }

View File

@ -2,6 +2,7 @@ TOKEN=replace-me
SERVER_NAME=user-manage-api SERVER_NAME=user-manage-api
HOME_BASE_DIR=/home HOME_BASE_DIR=/home
LINK_HOME_DIR= LINK_HOME_DIR=
DEFAULT_SHELL=/bin/bash
WHITELIST_USERS= WHITELIST_USERS=
WHITELIST_GROUPS= WHITELIST_GROUPS=
LOCKED_USERS= LOCKED_USERS=

View File

@ -61,7 +61,6 @@ SERVER_NAME=user-manage-api
| HTTP 状态码 | code | 描述 | | HTTP 状态码 | code | 描述 |
| --- | --- | --- | | --- | --- | --- |
| 400 | `invalid_parameter` | 请求参数格式或校验失败 | | 400 | `invalid_parameter` | 请求参数格式或校验失败 |
| 400 | `invalid_home_dir` | `home_dir` 不在 `HOME_BASE_DIR` 下 |
| 401 | `unauthorized` | 未提供 token 或 token 不正确 | | 401 | `unauthorized` | 未提供 token 或 token 不正确 |
| 404 | `not_found` | 用户或用户组不存在,或被可见性规则隐藏 | | 404 | `not_found` | 用户或用户组不存在,或被可见性规则隐藏 |
| 409 | `resource_conflict` | 用户或用户组已存在 | | 409 | `resource_conflict` | 用户或用户组已存在 |
@ -110,12 +109,14 @@ GROUP_GID_MAX=60000
```env ```env
HOME_BASE_DIR=/home HOME_BASE_DIR=/home
LINK_HOME_DIR= LINK_HOME_DIR=
DEFAULT_SHELL=/bin/bash
``` ```
创建用户时: 创建用户时:
- `home_dir` 为空时,默认使用 `HOME_BASE_DIR/username` - API 不接收客户端传入的 `home_dir``shell`
- `home_dir` 不为空时,必须位于 `HOME_BASE_DIR` 下。 - 用户 home 固定使用 `HOME_BASE_DIR/username`
- 登录 shell 固定使用 `DEFAULT_SHELL`
- `LINK_HOME_DIR` 为空时,用户目录直接创建在 `HOME_BASE_DIR` 下。 - `LINK_HOME_DIR` 为空时,用户目录直接创建在 `HOME_BASE_DIR` 下。
- `LINK_HOME_DIR` 不为空时,实际目录创建在 `LINK_HOME_DIR/username`,并在 `HOME_BASE_DIR/username` 创建软链接。 - `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 | | `password_hash` | string | 是 | - | 预先生成的 Linux 密码 hash长度 10 到 512 |
| `primary_group` | string/null | 否 | `null` | 主用户组。格式同用户组名 | | `primary_group` | string/null | 否 | `null` | 主用户组。格式同用户组名 |
| `groups` | string[] | 否 | `[]` | 附加用户组,会自动去重 | | `groups` | string[] | 否 | `[]` | 附加用户组,会自动去重 |
| `shell` | string | 否 | `/bin/bash` | 登录 shell |
| `home_dir` | string/null | 否 | `null` | 用户 home 路径,必须在 `HOME_BASE_DIR` 下 |
### UserSummary ### UserSummary
@ -227,9 +226,7 @@ POST /users
"username": "alice", "username": "alice",
"password_hash": "$6$salt$hash", "password_hash": "$6$salt$hash",
"primary_group": null, "primary_group": null,
"groups": ["dev"], "groups": ["dev"]
"shell": "/bin/bash",
"home_dir": "/home/alice"
} }
``` ```
@ -255,7 +252,7 @@ POST /users
- `username` 在黑名单中且不在白名单中时禁止创建。 - `username` 在黑名单中且不在白名单中时禁止创建。
- `primary_group``groups` 中的用户组必须存在且可见。 - `primary_group``groups` 中的用户组必须存在且可见。
- `home_dir` 必须在 `HOME_BASE_DIR` - 请求体不允许传入 `shell``home_dir`,两者由服务端配置决定
- 启用 `LINK_HOME_DIR` 后,账号 home 仍为 `HOME_BASE_DIR/username`,实际目录位于 `LINK_HOME_DIR/username` - 启用 `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' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"username": "alice", "username": "alice",
"password_hash": "$6$rounds=5000$salt$hash", "password_hash": "$6$rounds=5000$salt$hash"
"shell": "/bin/bash"
}' }'
``` ```

View File

@ -13,6 +13,7 @@ class Settings(BaseSettings):
server_name: str = Field(default="user-manage-api", alias="SERVER_NAME") server_name: str = Field(default="user-manage-api", alias="SERVER_NAME")
home_base_dir: str = Field(default="/home", alias="HOME_BASE_DIR") home_base_dir: str = Field(default="/home", alias="HOME_BASE_DIR")
link_home_dir: str = Field(default="", alias="LINK_HOME_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_users: str = Field(default="", alias="WHITELIST_USERS")
whitelist_groups: str = Field(default="", alias="WHITELIST_GROUPS") whitelist_groups: str = Field(default="", alias="WHITELIST_GROUPS")
locked_users: str = Field(default="", alias="LOCKED_USERS") 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: 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.") 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"] required_commands = ["useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent"]
if settings.link_home_dir.strip(): if settings.link_home_dir.strip():
required_commands.extend(["mkdir", "ln", "chown", "unlink"]) required_commands.extend(["mkdir", "ln", "chown", "unlink"])

View File

@ -1,6 +1,6 @@
from typing import List, Literal, Optional 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}$" 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): class UserCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(pattern=USERNAME_PATTERN) username: str = Field(pattern=USERNAME_PATTERN)
password_hash: str = Field(min_length=10, max_length=512) password_hash: str = Field(min_length=10, max_length=512)
primary_group: Optional[str] = Field(default=None, pattern=GROUPNAME_PATTERN) primary_group: Optional[str] = Field(default=None, pattern=GROUPNAME_PATTERN)
groups: List[str] = Field(default_factory=list) groups: List[str] = Field(default_factory=list)
shell: str = "/bin/bash"
home_dir: Optional[str] = None
@field_validator("groups") @field_validator("groups")
@classmethod @classmethod

View File

@ -15,6 +15,7 @@ service = UserGroupService(
provider=provider, provider=provider,
home_base_dir=settings.home_base_dir, home_base_dir=settings.home_base_dir,
link_home_dir=settings.link_home_dir or None, link_home_dir=settings.link_home_dir or None,
default_shell=settings.default_shell,
hidden_users=settings.hidden_user_list, hidden_users=settings.hidden_user_list,
hidden_groups=settings.hidden_group_list, hidden_groups=settings.hidden_group_list,
whitelist_users=settings.whitelist_user_list, whitelist_users=settings.whitelist_user_list,

View File

@ -12,6 +12,7 @@ class UserGroupService:
provider: SystemProvider, provider: SystemProvider,
home_base_dir: str, home_base_dir: str,
link_home_dir: Optional[str] = None, link_home_dir: Optional[str] = None,
default_shell: str = "/bin/bash",
hidden_users: Optional[List[str]] = None, hidden_users: Optional[List[str]] = None,
hidden_groups: Optional[List[str]] = None, hidden_groups: Optional[List[str]] = None,
whitelist_users: Optional[List[str]] = None, whitelist_users: Optional[List[str]] = None,
@ -25,6 +26,7 @@ class UserGroupService:
self.provider = provider self.provider = provider
self.home_base_dir = PurePosixPath(home_base_dir) self.home_base_dir = PurePosixPath(home_base_dir)
self.link_home_base_dir = PurePosixPath(link_home_dir) if link_home_dir else None 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_users = set(hidden_users or [])
self.hidden_groups = set(hidden_groups or []) self.hidden_groups = set(hidden_groups or [])
self.whitelist_users = set(whitelist_users or []) self.whitelist_users = set(whitelist_users or [])
@ -89,11 +91,8 @@ class UserGroupService:
return False return False
return self._is_gid_in_range(group.gid) return self._is_gid_in_range(group.gid)
def _resolve_home_dir(self, home_dir: Optional[str], username: str) -> str: def _resolve_home_dir(self, username: str) -> str:
if home_dir is None: return str(self.home_base_dir / username)
return str(self.home_base_dir / username)
return self._validate_home_dir(home_dir, username)
def _resolve_linked_home_dir(self, username: str) -> Optional[str]: def _resolve_linked_home_dir(self, username: str) -> Optional[str]:
if self.link_home_base_dir is None: if self.link_home_base_dir is None:
@ -101,29 +100,20 @@ class UserGroupService:
return str(self.link_home_base_dir / username) 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: def create_user(self, payload: UserCreateRequest) -> None:
self._ensure_user_name_allowed(payload.username) self._ensure_user_name_allowed(payload.username)
self._ensure_user_unlocked(payload.username) self._ensure_user_unlocked(payload.username)
if payload.primary_group is not None: if payload.primary_group is not None:
self._ensure_group_visible(payload.primary_group) self._ensure_group_visible(payload.primary_group)
self._ensure_groups_visible(payload.groups) 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) linked_home_dir = self._resolve_linked_home_dir(payload.username)
self.provider.create_user( self.provider.create_user(
username=payload.username, username=payload.username,
password_hash=payload.password_hash, password_hash=payload.password_hash,
home_dir=home_dir, home_dir=home_dir,
linked_home_dir=linked_home_dir, linked_home_dir=linked_home_dir,
shell=payload.shell, shell=self.default_shell,
primary_group=payload.primary_group, primary_group=payload.primary_group,
groups=payload.groups, groups=payload.groups,
) )

View File

@ -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": "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": "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": "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}

View File

@ -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: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 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-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=

View File

@ -150,6 +150,22 @@ def test_change_user_password() -> None:
assert response.json()["message"] == "User password updated." 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: def test_locked_user_can_be_read_but_not_modified() -> None:
client = build_client(locked_users=["alice"]) client = build_client(locked_users=["alice"])
headers = {"Authorization": "Bearer test-token"} headers = {"Authorization": "Bearer test-token"}

View File

@ -1,4 +1,3 @@
import pytest
from typing import List, Optional from typing import List, Optional
from app.core.errors import ApiError, map_command_error from app.core.errors import ApiError, map_command_error
@ -13,10 +12,12 @@ class NoopProvider(SystemProvider):
def __init__(self) -> None: def __init__(self) -> None:
self.created_home_dir: Optional[str] = None self.created_home_dir: Optional[str] = None
self.created_linked_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: 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_home_dir = home_dir
self.created_linked_home_dir = linked_home_dir self.created_linked_home_dir = linked_home_dir
self.created_shell = shell
return None return None
def delete_user(self, username: str) -> None: def delete_user(self, username: str) -> None:
return None return None
@ -42,12 +43,15 @@ class NoopProvider(SystemProvider):
return [] return []
def test_home_dir_out_of_base_rejected() -> None: def test_create_user_uses_configured_shell_and_home_dir() -> None:
service = UserGroupService(provider=NoopProvider(), home_base_dir="/home") provider = NoopProvider()
payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", home_dir="/tmp/alice", groups=[]) service = UserGroupService(provider=provider, home_base_dir="/srv/home", default_shell="/usr/sbin/nologin")
with pytest.raises(ApiError) as raised: payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", groups=[])
service.create_user(payload)
assert raised.value.status_code == 400 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: def test_link_home_dir_uses_home_base_symlink_path_and_external_storage() -> None: