feat(功能): 完善部分功能及1界面
This commit is contained in:
parent
ca023c23f8
commit
884e0235b0
@ -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'],
|
||||
]);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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"
|
||||
}'
|
||||
```
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user