From 92cb45e72de74a3627691b7cd7414e0f62861a8a Mon Sep 17 00:00:00 2001 From: Boen_Shi Date: Wed, 27 May 2026 11:04:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86):=20=E5=AE=8C=E6=88=90=E4=BA=86?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=9A=84fastapi=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../Commands/BastionRefreshTokensCommand.php | 31 + .../Commands/CheckBastionLoginCommand.php | 177 ++++++ .../Commands/InstallApplicationCommand.php | 87 +++ .../Console/Commands/UserManageCommand.php | 376 +++++++++++ user_manage_api/.env.example | 17 + user_manage_api/README.md | 36 ++ user_manage_api/api_document.md | 592 ++++++++++++++++++ user_manage_api/app/__init__.py | 1 + user_manage_api/app/api/__init__.py | 1 + user_manage_api/app/api/deps.py | 23 + user_manage_api/app/api/routes.py | 98 +++ user_manage_api/app/container.py | 3 + user_manage_api/app/core/__init__.py | 1 + user_manage_api/app/core/audit.py | 49 ++ user_manage_api/app/core/config.py | 87 +++ user_manage_api/app/core/errors.py | 22 + user_manage_api/app/core/models.py | 66 ++ user_manage_api/app/factory.py | 21 + user_manage_api/app/main.py | 32 + user_manage_api/app/providers/__init__.py | 1 + user_manage_api/app/providers/base.py | 54 ++ user_manage_api/app/providers/cli_provider.py | 122 ++++ user_manage_api/app/services/__init__.py | 1 + .../app/services/user_group_service.py | 184 ++++++ user_manage_api/app/state.py | 12 + .../examples/python_minimal_example.py | 30 + user_manage_api/requirements.txt | 6 + .../scripts/generate_python_sdk.py | 60 ++ user_manage_api/tests/hash.py | 32 + user_manage_api/tests/test_api_integration.py | 251 ++++++++ user_manage_api/tests/test_service_unit.py | 109 ++++ 32 files changed, 2583 insertions(+) create mode 100644 app/Exceptions/Console/Commands/BastionRefreshTokensCommand.php create mode 100644 app/Exceptions/Console/Commands/CheckBastionLoginCommand.php create mode 100644 app/Exceptions/Console/Commands/InstallApplicationCommand.php create mode 100644 app/Exceptions/Console/Commands/UserManageCommand.php create mode 100644 user_manage_api/.env.example create mode 100644 user_manage_api/README.md create mode 100644 user_manage_api/api_document.md create mode 100644 user_manage_api/app/__init__.py create mode 100644 user_manage_api/app/api/__init__.py create mode 100644 user_manage_api/app/api/deps.py create mode 100644 user_manage_api/app/api/routes.py create mode 100644 user_manage_api/app/container.py create mode 100644 user_manage_api/app/core/__init__.py create mode 100644 user_manage_api/app/core/audit.py create mode 100644 user_manage_api/app/core/config.py create mode 100644 user_manage_api/app/core/errors.py create mode 100644 user_manage_api/app/core/models.py create mode 100644 user_manage_api/app/factory.py create mode 100644 user_manage_api/app/main.py create mode 100644 user_manage_api/app/providers/__init__.py create mode 100644 user_manage_api/app/providers/base.py create mode 100644 user_manage_api/app/providers/cli_provider.py create mode 100644 user_manage_api/app/services/__init__.py create mode 100644 user_manage_api/app/services/user_group_service.py create mode 100644 user_manage_api/app/state.py create mode 100644 user_manage_api/examples/python_minimal_example.py create mode 100644 user_manage_api/requirements.txt create mode 100644 user_manage_api/scripts/generate_python_sdk.py create mode 100644 user_manage_api/tests/hash.py create mode 100644 user_manage_api/tests/test_api_integration.py create mode 100644 user_manage_api/tests/test_service_unit.py diff --git a/.gitignore b/.gitignore index 163e85a..c0e48fb 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ CLAUDE.md .mcp.json boost.json LOG.md +*/.venv* \ No newline at end of file diff --git a/app/Exceptions/Console/Commands/BastionRefreshTokensCommand.php b/app/Exceptions/Console/Commands/BastionRefreshTokensCommand.php new file mode 100644 index 0000000..86d944b --- /dev/null +++ b/app/Exceptions/Console/Commands/BastionRefreshTokensCommand.php @@ -0,0 +1,31 @@ +where('is_active', true)->chunkById(100, function ($accounts): void { + foreach ($accounts as $account) { + $account->update([ + 'usm_authentication' => Str::uuid()->toString(), + 'usm' => hash('sha256', Str::uuid()->toString()), + 'last_token_refreshed_at' => now(), + ]); + } + }); + + $this->info('Bastion tokens refreshed.'); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/Console/Commands/CheckBastionLoginCommand.php b/app/Exceptions/Console/Commands/CheckBastionLoginCommand.php new file mode 100644 index 0000000..8efb8f6 --- /dev/null +++ b/app/Exceptions/Console/Commands/CheckBastionLoginCommand.php @@ -0,0 +1,177 @@ +where('is_active', true) + ->whereNotNull('usm') + ->where('usm', '!=', '') + ->whereNotNull('usm_authentication') + ->where('usm_authentication', '!=', ''); + + $accountId = $this->option('account_id'); + if ($accountId !== null && $accountId !== '') { + $query->where('id', (int) $accountId); + } + + $accounts = $query->orderBy('id')->get(); + if ($accounts->isEmpty()) { + $this->line('[]'); + + return self::SUCCESS; + } + + $rows = []; + foreach ($accounts as $account) { + $status = $this->checkAccountTokenStatus((string) $account->usm_authentication, (string) $account->usm); + $refreshed = false; + if ($status === -1 && $this->option('refresh-invalid')) { + $refreshed = $this->refreshAccountToken($account); + if ($refreshed) { + $account->refresh(); + $status = $this->checkAccountTokenStatus((string) $account->usm_authentication, (string) $account->usm); + } + } + + $rows[] = [ + 'id' => $account->id, + 'username' => $account->username, + 'status' => $status, + 'refreshed' => $refreshed, + ]; + } + + $this->line(json_encode($rows, JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + private function checkAccountTokenStatus(string $usmAuthentication, string $usm): int + { + $url = 'https://172.16.254.2/index.php/extend/check_login'; + $cookie = sprintf( + 'LANG=zh; USM-AUTHENTICATION=%s; USM=%s; LOGON=1; AUTH_METHOD=oauth2', + $usmAuthentication, + $usm + ); + + try { + $response = Http::timeout(10) + ->withOptions(['verify' => false]) + ->withHeaders([ + 'Cookie' => $cookie, + 'Accept' => '*/*', + ]) + ->get($url); + } catch (ConnectionException) { + return -1; + } + + if (! $response->successful()) { + return -1; + } + + return trim((string) $response->body()) === '0' ? 0 : -1; + } + + private function refreshAccountToken(BastionAccount $account): bool + { + $baseUrl = (string) config('services.bastion_token.base_url'); + if ($baseUrl === '') { + return false; + } + + $submitEndpoint = (string) config('services.bastion_token.submit_endpoint', '/bastion_token'); + $statusEndpoint = (string) config('services.bastion_token.status_endpoint', '/bastion_token/{task_id}'); + $timeout = (int) config('services.bastion_token.timeout', 30); + $service = (string) config('services.bastion_token.service', 'https://myapp.cdu.edu.cn/index.html'); + $verifySsl = (bool) config('services.bastion_token.verify_ssl', false); + + try { + $submitResponse = Http::baseUrl($baseUrl) + ->acceptJson() + ->timeout($timeout) + ->retry(2, 300, throw: false) + ->post($submitEndpoint, [ + 'username' => $account->username, + 'password' => $account->password, + 'service' => $service, + 'verify_ssl' => $verifySsl, + ]); + } catch (ConnectionException|RequestException) { + return false; + } + + if (! $submitResponse->successful()) { + return false; + } + + $taskId = (string) data_get($submitResponse->json(), 'task_id', ''); + if ($taskId === '') { + return false; + } + + $maxPoll = 20; + for ($i = 0; $i < $maxPoll; $i++) { + usleep(500000); + $statusUrl = str_replace('{task_id}', $taskId, $statusEndpoint); + try { + $statusResponse = Http::baseUrl($baseUrl) + ->acceptJson() + ->timeout($timeout) + ->retry(2, 300, throw: false) + ->get($statusUrl); + } catch (ConnectionException|RequestException) { + continue; + } + + if (! $statusResponse->successful()) { + continue; + } + + $taskResult = $statusResponse->json(); + $status = strtolower((string) data_get($taskResult, 'status', 'pending')); + if ($status === 'pending') { + continue; + } + + if ($status === 'error') { + return false; + } + + $usmAuthentication = (string) data_get($taskResult, 'data.bastion.token_cookies.USM-AUTHENTICATION', ''); + $usm = (string) data_get($taskResult, 'data.bastion.token_cookies.USM', ''); + if ($usmAuthentication === '' || $usm === '') { + $usmAuthentication = (string) data_get($taskResult, 'data.USM-AUTHENTICATION', $usmAuthentication); + $usm = (string) data_get($taskResult, 'data.USM', $usm); + } + if ($usmAuthentication === '' || $usm === '') { + return false; + } + + $account->update([ + 'usm_authentication' => $usmAuthentication, + 'usm' => $usm, + 'last_token_refreshed_at' => now(), + ]); + + return true; + } + + return false; + } +} diff --git a/app/Exceptions/Console/Commands/InstallApplicationCommand.php b/app/Exceptions/Console/Commands/InstallApplicationCommand.php new file mode 100644 index 0000000..e74dd96 --- /dev/null +++ b/app/Exceptions/Console/Commands/InstallApplicationCommand.php @@ -0,0 +1,87 @@ +components->info('Starting application installation...'); + + $migrationExitCode = $this->call( + $this->option('fresh') ? 'migrate:fresh' : 'migrate', + ['--force' => (bool) $this->option('force')] + ); + + if ($migrationExitCode !== self::SUCCESS) { + $this->error('Database migration failed.'); + + return self::FAILURE; + } + + $rbacExitCode = $this->call('user:manage', [ + 'action' => 'init-rbac', + ]); + + if ($rbacExitCode !== self::SUCCESS) { + $this->error('Default RBAC initialization failed.'); + + return self::FAILURE; + } + + $adminEmail = trim((string) $this->option('admin-email')); + $adminPhone = trim((string) $this->option('admin-phone')); + $adminNickname = trim((string) $this->option('admin-nickname')); + $adminPassword = (string) $this->option('admin-password'); + + if ($adminEmail === '' || $adminNickname === '' || $adminPassword === '') { + $this->error('admin-email, admin-nickname and admin-password are required.'); + + return self::FAILURE; + } + + $adminUser = User::query()->updateOrCreate( + ['email' => $adminEmail], + [ + 'nickname' => $adminNickname, + 'phone' => $adminPhone !== '' ? $adminPhone : null, + 'password' => $adminPassword, + 'force_password_change' => false, + ] + ); + + $setAdminExitCode = $this->call('user:manage', [ + 'action' => 'set-admin', + '--email' => $adminUser->email, + ]); + + if ($setAdminExitCode !== self::SUCCESS) { + $this->error('Failed to set admin role and permissions.'); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info('Application installed successfully.'); + $this->line("Admin email: {$adminUser->email}"); + if ($adminUser->phone) { + $this->line("Admin phone: {$adminUser->phone}"); + } + $this->line("Admin password: {$adminPassword}"); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/Console/Commands/UserManageCommand.php b/app/Exceptions/Console/Commands/UserManageCommand.php new file mode 100644 index 0000000..d72c9a3 --- /dev/null +++ b/app/Exceptions/Console/Commands/UserManageCommand.php @@ -0,0 +1,376 @@ +argument('action')) { + 'create' => $this->createUser(), + 'reset-password' => $this->resetPassword(), + 'list' => $this->listUsers(), + 'init-rbac' => $this->initRbac(), + 'create-role' => $this->createRole(), + 'assign-role' => $this->assignRole(), + 'remove-role' => $this->removeRole(), + 'assign-permission' => $this->assignPermission(), + 'remove-permission' => $this->removePermission(), + 'grant-server' => $this->grantServerPermission(), + 'set-admin' => $this->setAdmin(), + 'unset-admin' => $this->unsetAdmin(), + default => $this->invalidAction(), + }; + } + + private function createUser(): int + { + $email = (string) $this->option('email'); + $nickname = (string) $this->option('nickname'); + $password = (string) $this->option('password'); + + if ($email === '' || $nickname === '' || $password === '') { + $this->error('email, nickname, password are required.'); + + return self::FAILURE; + } + + $user = User::query()->create([ + 'email' => $email, + 'nickname' => $nickname, + 'phone' => $this->option('phone') ?: null, + 'password' => $password, + ]); + + if ($this->option('role')) { + $role = Role::query()->firstOrCreate([ + 'name' => (string) $this->option('role'), + 'guard_name' => 'api', + ]); + $user->assignRole($role); + } + + $this->info("User created: {$user->id}"); + + return self::SUCCESS; + } + + private function resetPassword(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $password = (string) $this->option('password'); + + if ($password === '') { + $this->error('password is required.'); + + return self::FAILURE; + } + + $user->password = Hash::make($password); + $user->save(); + + $this->info('Password reset success.'); + + return self::SUCCESS; + } + + private function listUsers(): int + { + $rows = User::query()->with(['roles', 'permissions'])->latest()->get()->map(fn (User $user): array => [ + 'id' => $user->id, + 'nickname' => $user->nickname, + 'email' => $user->email, + 'roles' => $user->roles->pluck('name')->implode(','), + 'permissions' => $user->permissions->pluck('name')->implode(','), + ])->toArray(); + + $this->table(['ID', 'Nickname', 'Email', 'Roles', 'Permissions'], $rows); + + return self::SUCCESS; + } + + private function initRbac(): int + { + $permissions = [ + 'platform.users.view', + 'platform.users.manage', + 'platform.roles.view', + 'platform.roles.manage', + 'platform.permissions.view', + 'platform.permissions.manage', + 'platform.servers.view', + 'platform.servers.manage', + 'platform.accounts.view', + 'platform.accounts.manage', + 'platform.logs.view', + 'platform.logs.manage', + 'platform.oauth_clients.view', + 'platform.oauth_clients.manage', + 'platform.oauth_scopes.view', + 'platform.oauth_scopes.manage', + 'resource.servers.use', + ]; + + foreach ($permissions as $permissionName) { + Permission::query()->firstOrCreate([ + 'name' => $permissionName, + 'guard_name' => 'api', + ]); + } + + $adminRole = Role::query()->firstOrCreate([ + 'name' => 'admin', + 'guard_name' => 'api', + ]); + + $userRole = Role::query()->firstOrCreate([ + 'name' => 'user', + 'guard_name' => 'api', + ]); + + $adminRole->syncPermissions($permissions); + $userRole->syncPermissions([ + 'resource.servers.use', + ]); + + Role::query()->where('guard_name', 'api')->whereIn('name', ['operator', 'member'])->delete(); + + $this->info('RBAC initialized.'); + + return self::SUCCESS; + } + + private function createRole(): int + { + $roleName = (string) $this->option('role'); + + if ($roleName === '') { + $this->error('role is required.'); + + return self::FAILURE; + } + + Role::query()->firstOrCreate([ + 'name' => $roleName, + 'guard_name' => 'api', + ]); + + $this->info('Role created.'); + + return self::SUCCESS; + } + + private function assignRole(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $roleName = (string) $this->option('role'); + + if ($roleName === '') { + $this->error('role is required.'); + + return self::FAILURE; + } + + $user->assignRole($roleName); + $this->info('Role assigned.'); + + return self::SUCCESS; + } + + private function removeRole(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $roleName = (string) $this->option('role'); + + if ($roleName === '') { + $this->error('role is required.'); + + return self::FAILURE; + } + + $user->removeRole($roleName); + $this->info('Role removed.'); + + return self::SUCCESS; + } + + private function assignPermission(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $permission = (string) $this->option('permission'); + + if ($permission === '') { + $this->error('permission is required.'); + + return self::FAILURE; + } + + $user->givePermissionTo($permission); + $this->info('Permission assigned.'); + + return self::SUCCESS; + } + + private function removePermission(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $permission = (string) $this->option('permission'); + + if ($permission === '') { + $this->error('permission is required.'); + + return self::FAILURE; + } + + $user->revokePermissionTo($permission); + $this->info('Permission removed.'); + + return self::SUCCESS; + } + + private function grantServerPermission(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $serverId = (int) $this->option('server-id'); + + if ($serverId <= 0) { + $this->error('server-id is required and must be > 0.'); + + return self::FAILURE; + } + + UserServerPermission::query()->updateOrCreate( + ['user_id' => $user->id, 'server_resource_id' => $serverId], + [ + 'can_ssh' => (bool) ((int) $this->option('ssh')), + 'can_sftp' => (bool) ((int) $this->option('sftp')), + 'can_rdp' => (bool) ((int) $this->option('rdp')), + ] + ); + + $this->info('Server permission updated.'); + + return self::SUCCESS; + } + + private function setAdmin(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + $adminRole = Role::query()->firstOrCreate([ + 'name' => 'admin', + 'guard_name' => 'api', + ]); + + $allPermissions = Permission::query() + ->where('guard_name', 'api') + ->pluck('name') + ->all(); + + $adminRole->syncPermissions($allPermissions); + + $user->syncRoles([$adminRole->name]); + $user->syncPermissions([]); + + $this->info('User set as admin with all permissions.'); + + return self::SUCCESS; + } + + private function unsetAdmin(): int + { + $user = $this->findUserByEmail(); + if (! $user) { + return self::FAILURE; + } + + if (! $user->hasRole('admin', 'api')) { + $this->warn('User is not admin.'); + + return self::SUCCESS; + } + + $user->removeRole('admin'); + + $this->info('Admin role removed from user.'); + + return self::SUCCESS; + } + + private function findUserByEmail(): ?User + { + $email = (string) $this->option('email'); + + if ($email === '') { + $this->error('email is required.'); + + return null; + } + + $user = User::query()->where('email', $email)->first(); + + if (! $user) { + $this->error('User not found.'); + + return null; + } + + return $user; + } + + private function invalidAction(): int + { + $this->error('Invalid action.'); + + return self::FAILURE; + } +} diff --git a/user_manage_api/.env.example b/user_manage_api/.env.example new file mode 100644 index 0000000..1ba32eb --- /dev/null +++ b/user_manage_api/.env.example @@ -0,0 +1,17 @@ +TOKEN=replace-me +SERVER_NAME=user-manage-api +HOME_BASE_DIR=/home +LINK_HOME_DIR= +WHITELIST_USERS= +WHITELIST_GROUPS= +LOCKED_USERS= +HIDDEN_USERS=root,daemon,nobody +HIDDEN_GROUPS=root,daemon,nogroup +USER_UID_MIN=1000 +USER_UID_MAX=60000 +GROUP_GID_MIN=1000 +GROUP_GID_MAX=60000 +USE_LIBUSER=false +LOG_LEVEL=INFO +LOG_PATH=./logs/user_manage_api.log +SUDO_PATH=/usr/bin/sudo diff --git a/user_manage_api/README.md b/user_manage_api/README.md new file mode 100644 index 0000000..c78509e --- /dev/null +++ b/user_manage_api/README.md @@ -0,0 +1,36 @@ +# Ubuntu User Management API (FastAPI, V1) + +## Run + +```bash +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +## Required Env + +- `TOKEN` +- `HOME_BASE_DIR` (default `/home`) +- `USE_LIBUSER` (default `false`) +- `LOG_LEVEL` (default `INFO`) +- `LOG_PATH` (default `./logs/user_manage_api.log`) +- `SUDO_PATH` (default `/usr/bin/sudo`) + +## Notes + +- API process should run as non-root. +- System account commands are executed through sudo allowlist. +- Password input is pre-hashed only (no plaintext transformation in service). +- Deleting user keeps home directory by default. + +## Test + +```bash +pytest -q +``` + +## Generate Python SDK + +```bash +python scripts/generate_python_sdk.py +``` diff --git a/user_manage_api/api_document.md b/user_manage_api/api_document.md new file mode 100644 index 0000000..a432352 --- /dev/null +++ b/user_manage_api/api_document.md @@ -0,0 +1,592 @@ +# Ubuntu User Management API + +## 基本信息 + +- 服务地址示例: `http://127.0.0.1:8000` +- API 版本: `1.0.0` +- 文档地址: `/docs` +- OpenAPI 地址: `/openapi.json` +- 请求与响应格式: `application/json` + +## 鉴权 + +所有业务接口都需要 Bearer Token。 + +请求头: + +```http +Authorization: Bearer +``` + +`TOKEN` 来自 `.env`: + +```env +TOKEN=your-token +SERVER_NAME=user-manage-api +``` + +鉴权失败响应: + +```json +{ + "detail": { + "code": "unauthorized", + "message": "Invalid bearer token." + } +} +``` + +## 通用错误格式 + +业务错误: + +```json +{ + "code": "not_found", + "message": "user not found" +} +``` + +参数错误: + +```json +{ + "code": "invalid_parameter", + "message": "..." +} +``` + +常见错误码: + +| HTTP 状态码 | code | 描述 | +| --- | --- | --- | +| 400 | `invalid_parameter` | 请求参数格式或校验失败 | +| 400 | `invalid_home_dir` | `home_dir` 不在 `HOME_BASE_DIR` 下 | +| 401 | `unauthorized` | 未提供 token 或 token 不正确 | +| 404 | `not_found` | 用户或用户组不存在,或被可见性规则隐藏 | +| 409 | `resource_conflict` | 用户或用户组已存在 | +| 422 | `precondition_failed` | 前置条件不满足,例如用户组仍有成员 | +| 423 | `user_locked` | 用户已锁定,只允许查看,不允许修改 | +| 500 | `system_command_error` | 系统命令执行失败 | +| 503 | `system_timeout` | 系统命令超时 | +| 503 | `system_permission_denied` | 系统命令权限不足 | + +## 可见性规则 + +用户和用户组会受 `.env` 中的白名单、黑名单和 UID/GID 范围限制。 + +优先级: + +```text +白名单 > 黑名单 > UID/GID 范围 +``` + +相关配置: + +```env +WHITELIST_USERS= +WHITELIST_GROUPS= +HIDDEN_USERS=root,daemon,nobody +HIDDEN_GROUPS=root,daemon,nogroup +LOCKED_USERS= +USER_UID_MIN=1000 +USER_UID_MAX=60000 +GROUP_GID_MIN=1000 +GROUP_GID_MAX=60000 +``` + +说明: + +- 白名单中的用户/用户组始终可见并允许操作。 +- 黑名单中的用户/用户组会被隐藏并禁止操作,除非同时在白名单中。 +- 不在白名单和黑名单时,用户按 UID 范围判断,用户组按 GID 范围判断。 +- 被隐藏的用户或用户组对 API 表现为 `404 not_found`。 +- `LOCKED_USERS` 中的用户可以查看,但不能创建同名用户、删除、改密码、添加用户组或移除用户组。 + +## Home 目录规则 + +相关配置: + +```env +HOME_BASE_DIR=/home +LINK_HOME_DIR= +``` + +创建用户时: + +- `home_dir` 为空时,默认使用 `HOME_BASE_DIR/username`。 +- `home_dir` 不为空时,必须位于 `HOME_BASE_DIR` 下。 +- `LINK_HOME_DIR` 为空时,用户目录直接创建在 `HOME_BASE_DIR` 下。 +- `LINK_HOME_DIR` 不为空时,实际目录创建在 `LINK_HOME_DIR/username`,并在 `HOME_BASE_DIR/username` 创建软链接。 + +删除用户时: + +- 会删除用户账号。 +- 如果用户 home 是软链接,则删除该软链接。 +- 如果用户 home 是普通目录,不会删除目录内容。 + +## 数据模型 + +### UserCreateRequest + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| --- | --- | --- | --- | --- | +| `username` | string | 是 | - | 用户名。格式: `^[a-z_][a-z0-9_-]{0,31}$` | +| `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 + +| 字段 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 用户名 | +| `uid` | integer | 用户 UID | +| `gid` | integer | 用户主 GID | +| `home_dir` | string | 用户 home 路径 | +| `shell` | string | 登录 shell | + +### GroupCreateRequest + +| 字段 | 类型 | 必填 | 描述 | +| --- | --- | --- | --- | +| `groupname` | string | 是 | 用户组名。格式: `^[a-z_][a-z0-9_-]{0,31}$` | + +### GroupSummary + +| 字段 | 类型 | 描述 | +| --- | --- | --- | +| `groupname` | string | 用户组名 | +| `gid` | integer | 用户组 GID | +| `members` | string[] | 用户组成员 | + +### UserGroupsUpdateRequest + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +| --- | --- | --- | --- | --- | +| `groups` | string[] | 是 | - | 用户组列表,至少 1 个,会自动去重 | +| `mode` | string | 否 | `append` | `append` 表示追加,`replace` 表示替换全部附加组 | + +### UserPasswordUpdateRequest + +| 字段 | 类型 | 必填 | 描述 | +| --- | --- | --- | --- | +| `password_hash` | string | 是 | 新密码的 Linux hash,长度 10 到 512。API 不接收明文密码 | + +### ApiResponse + +| 字段 | 类型 | 描述 | +| --- | --- | --- | +| `status` | string | 固定为 `ok` | +| `message` | string | 操作结果描述 | + +## API 列表 + +### 健康检查 + +```http +GET /health +``` + +描述: + +返回服务器名称和在线状态。服务器名称来自 `.env` 中的 `SERVER_NAME`。 + +成功响应: + +```json +{ + "server_name": "user-manage-api", + "status": "online" +} +``` + +字段说明: + +| 字段 | 类型 | 描述 | +| --- | --- | --- | +| `server_name` | string | 服务器名称,由 `SERVER_NAME` 配置 | +| `status` | string | 固定为 `online` | + +### 创建用户 + +```http +POST /users +``` + +描述: + +创建系统用户。密码必须由调用方提前生成 hash,API 不处理明文密码。 + +请求体: + +```json +{ + "username": "alice", + "password_hash": "$6$salt$hash", + "primary_group": null, + "groups": ["dev"], + "shell": "/bin/bash", + "home_dir": "/home/alice" +} +``` + +最小请求体: + +```json +{ + "username": "alice", + "password_hash": "$6$salt$hash" +} +``` + +成功响应: + +```json +{ + "status": "ok", + "message": "User created." +} +``` + +注意: + +- `username` 在黑名单中且不在白名单中时禁止创建。 +- `primary_group` 和 `groups` 中的用户组必须存在且可见。 +- `home_dir` 必须在 `HOME_BASE_DIR` 下。 +- 启用 `LINK_HOME_DIR` 后,账号 home 仍为 `HOME_BASE_DIR/username`,实际目录位于 `LINK_HOME_DIR/username`。 + +### 删除用户 + +```http +DELETE /users/{username} +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 要删除的用户名 | + +成功响应: + +```json +{ + "status": "ok", + "message": "User deleted." +} +``` + +注意: + +- 用户不存在或被隐藏时返回 `404`。 +- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`。 +- 删除账号后,如果 home 是软链接,会删除该软链接。 +- 如果 home 是普通目录,不会删除目录内容。 + +### 修改用户密码 + +```http +PATCH /users/{username}/password +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 要修改密码的用户名 | + +请求体: + +```json +{ + "password_hash": "$6$rounds=5000$salt$hash" +} +``` + +成功响应: + +```json +{ + "status": "ok", + "message": "User password updated." +} +``` + +注意: + +- `password_hash` 必须是调用方提前生成的 Linux 密码 hash。 +- 用户不存在或被隐藏时返回 `404`。 +- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`。 + +### 获取用户列表 + +```http +GET /users +``` + +描述: + +返回可见用户列表。会应用白名单、黑名单和 UID 范围规则。 + +成功响应: + +```json +[ + { + "username": "alice", + "uid": 1000, + "gid": 1000, + "home_dir": "/home/alice", + "shell": "/bin/bash" + } +] +``` + +### 获取用户详情 + +```http +GET /users/{username} +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 用户名 | + +成功响应: + +```json +{ + "username": "alice", + "uid": 1000, + "gid": 1000, + "home_dir": "/home/alice", + "shell": "/bin/bash" +} +``` + +### 创建用户组 + +```http +POST /groups +``` + +请求体: + +```json +{ + "groupname": "dev" +} +``` + +成功响应: + +```json +{ + "status": "ok", + "message": "Group created." +} +``` + +注意: + +- `groupname` 在黑名单中且不在白名单中时禁止创建。 + +### 删除用户组 + +```http +DELETE /groups/{groupname} +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `groupname` | string | 要删除的用户组名 | + +成功响应: + +```json +{ + "status": "ok", + "message": "Group deleted." +} +``` + +注意: + +- 用户组不存在或被隐藏时返回 `404`。 +- 用户组仍有成员时返回 `422 precondition_failed`。 + +### 获取用户组列表 + +```http +GET /groups +``` + +描述: + +返回可见用户组列表。会应用白名单、黑名单和 GID 范围规则。 + +成功响应: + +```json +[ + { + "groupname": "dev", + "gid": 1000, + "members": ["alice"] + } +] +``` + +### 获取用户组详情 + +```http +GET /groups/{groupname} +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `groupname` | string | 用户组名 | + +成功响应: + +```json +{ + "groupname": "dev", + "gid": 1000, + "members": ["alice"] +} +``` + +### 添加或替换用户附加组 + +```http +POST /users/{username}/groups +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 用户名 | + +请求体: + +```json +{ + "groups": ["dev", "ops"], + "mode": "append" +} +``` + +参数说明: + +| 字段 | 类型 | 描述 | +| --- | --- | --- | +| `groups` | string[] | 要添加或替换的用户组列表 | +| `mode` | string | `append` 追加用户组,`replace` 替换全部附加组 | + +成功响应: + +```json +{ + "status": "ok", + "message": "User groups updated." +} +``` + +注意: + +- 用户不存在或被隐藏时返回 `404`。 +- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`。 +- 目标用户组不存在或被隐藏时返回 `404`。 + +### 移除用户附加组 + +```http +DELETE /users/{username}/groups +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 用户名 | + +请求体: + +```json +{ + "groups": ["dev"], + "mode": "append" +} +``` + +说明: + +`mode` 字段会被接收,但删除操作只使用 `groups`。 + +成功响应: + +```json +{ + "status": "ok", + "message": "User groups removed." +} +``` + +注意: + +- 用户不存在或被隐藏时返回 `404`。 +- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`。 + +### 获取用户所属用户组 + +```http +GET /users/{username}/groups +``` + +路径参数: + +| 参数 | 类型 | 描述 | +| --- | --- | --- | +| `username` | string | 用户名 | + +成功响应: + +```json +{ + "username": "alice", + "groups": ["dev", "ops"] +} +``` + +说明: + +返回的 `groups` 会过滤掉不可见用户组。 + +## curl 示例 + +```bash +curl -X GET 'http://127.0.0.1:8000/users' \ + -H 'Authorization: Bearer ' +``` + +```bash +curl -X POST 'http://127.0.0.1:8000/users' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "alice", + "password_hash": "$6$rounds=5000$salt$hash", + "shell": "/bin/bash" + }' +``` diff --git a/user_manage_api/app/__init__.py b/user_manage_api/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/user_manage_api/app/__init__.py @@ -0,0 +1 @@ + diff --git a/user_manage_api/app/api/__init__.py b/user_manage_api/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/user_manage_api/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/user_manage_api/app/api/deps.py b/user_manage_api/app/api/deps.py new file mode 100644 index 0000000..c1f654f --- /dev/null +++ b/user_manage_api/app/api/deps.py @@ -0,0 +1,23 @@ +from typing import Dict, Optional + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +import app.container as container + +bearer_scheme = HTTPBearer(auto_error=False) + + +def auth_dependency(credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme)) -> None: + if credentials is None: + raise HTTPException(status_code=401, detail={"code": "unauthorized", "message": "Invalid auth header."}) + + if credentials.credentials != container.app_state.settings.token: + raise HTTPException(status_code=401, detail={"code": "unauthorized", "message": "Invalid bearer token."}) + + +def caller_identity(request: Request) -> Dict[str, str]: + return { + "ip": request.client.host if request.client else "unknown", + "request_id": request.headers.get("x-request-id", ""), + } diff --git a/user_manage_api/app/api/routes.py b/user_manage_api/app/api/routes.py new file mode 100644 index 0000000..0e688bc --- /dev/null +++ b/user_manage_api/app/api/routes.py @@ -0,0 +1,98 @@ +import time +from typing import Dict, List + +from fastapi import APIRouter, Depends, Request + +import app.container as container +from app.api.deps import auth_dependency, caller_identity +from app.core.errors import ApiError +from app.core.models import ApiResponse, GroupCreateRequest, UserCreateRequest, UserGroupsUpdateRequest, UserPasswordUpdateRequest + +router = APIRouter(dependencies=[Depends(auth_dependency)]) + + +@router.get("/health") +def health() -> Dict: + return {"server_name": container.app_state.settings.server_name, "status": "online"} + + +@router.post("/users", response_model=ApiResponse) +def create_user(payload: UserCreateRequest, request: Request) -> ApiResponse: + identity = caller_identity(request) + started = time.perf_counter() + try: + container.app_state.service.create_user(payload) + container.app_state.audit.log(operation="create_user", target=payload.username, result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return ApiResponse(message="User created.") + except ApiError as exc: + container.app_state.audit.log(operation="create_user", target=payload.username, result="failed", error_code=exc.code, request_id=identity["request_id"], source_ip=identity["ip"], elapsed_ms=int((time.perf_counter() - started) * 1000)) + raise + + +@router.delete("/users/{username}", response_model=ApiResponse) +def delete_user(username: str, request: Request) -> ApiResponse: + identity = caller_identity(request) + container.app_state.service.delete_user(username) + container.app_state.audit.log(operation="delete_user", target=username, result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return ApiResponse(message="User deleted.") + + +@router.patch("/users/{username}/password", response_model=ApiResponse) +def change_user_password(username: str, payload: UserPasswordUpdateRequest, request: Request) -> ApiResponse: + identity = caller_identity(request) + container.app_state.service.change_user_password(username=username, password_hash=payload.password_hash) + container.app_state.audit.log(operation="change_user_password", target=username, result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return ApiResponse(message="User password updated.") + + +@router.get("/users") +def list_users() -> List[Dict]: + return [item.model_dump() for item in container.app_state.service.list_users()] + + +@router.get("/users/{username}") +def get_user(username: str) -> Dict: + return container.app_state.service.get_user(username).model_dump() + + +@router.post("/groups", response_model=ApiResponse) +def create_group(payload: GroupCreateRequest, request: Request) -> ApiResponse: + identity = caller_identity(request) + container.app_state.service.create_group(payload.groupname) + container.app_state.audit.log(operation="create_group", target=payload.groupname, result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return ApiResponse(message="Group created.") + + +@router.delete("/groups/{groupname}", response_model=ApiResponse) +def delete_group(groupname: str, request: Request) -> ApiResponse: + identity = caller_identity(request) + container.app_state.service.delete_group(groupname) + container.app_state.audit.log(operation="delete_group", target=groupname, result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return ApiResponse(message="Group deleted.") + + +@router.get("/groups") +def list_groups() -> List[Dict]: + return [item.model_dump() for item in container.app_state.service.list_groups()] + + +@router.get("/groups/{groupname}") +def get_group(groupname: str) -> Dict: + return container.app_state.service.get_group(groupname).model_dump() + + +@router.post("/users/{username}/groups", response_model=ApiResponse) +def add_user_groups(username: str, payload: UserGroupsUpdateRequest) -> ApiResponse: + container.app_state.service.add_user_groups(username=username, groups=payload.groups, replace=payload.mode == "replace") + return ApiResponse(message="User groups updated.") + + +@router.delete("/users/{username}/groups", response_model=ApiResponse) +def remove_user_groups(username: str, payload: UserGroupsUpdateRequest) -> ApiResponse: + container.app_state.service.remove_user_groups(username=username, groups=payload.groups) + return ApiResponse(message="User groups removed.") + + +@router.get("/users/{username}/groups") +def get_user_groups(username: str) -> Dict: + return {"username": username, "groups": container.app_state.service.get_user_groups(username)} diff --git a/user_manage_api/app/container.py b/user_manage_api/app/container.py new file mode 100644 index 0000000..c18af81 --- /dev/null +++ b/user_manage_api/app/container.py @@ -0,0 +1,3 @@ +from app.state import AppState + +app_state: AppState diff --git a/user_manage_api/app/core/__init__.py b/user_manage_api/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/user_manage_api/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/user_manage_api/app/core/audit.py b/user_manage_api/app/core/audit.py new file mode 100644 index 0000000..2f9571b --- /dev/null +++ b/user_manage_api/app/core/audit.py @@ -0,0 +1,49 @@ +import json +import logging +import time +from pathlib import Path +from typing import Any, Dict + + +class AuditLogger: + def __init__(self, log_path: str): + self.log_path = Path(log_path) + self.log_path.parent.mkdir(parents=True, exist_ok=True) + self.json_log_path = self.log_path.with_suffix(".jsonl") + + self.text_logger = logging.getLogger("audit_text") + self.text_logger.setLevel(logging.INFO) + if not self.text_logger.handlers: + handler = logging.FileHandler(self.log_path, encoding="utf-8") + formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + handler.setFormatter(formatter) + self.text_logger.addHandler(handler) + + @staticmethod + def sanitize(payload: Dict[str, Any]) -> Dict[str, Any]: + hidden_keys = {"token", "authorization", "password", "password_hash", "command"} + sanitized: Dict[str, Any] = {} + for key, value in payload.items(): + if key.lower() in hidden_keys: + sanitized[key] = "***" + else: + sanitized[key] = value + return sanitized + + def log(self, **kwargs: Any) -> None: + start = time.perf_counter() + record = self.sanitize(kwargs) + record["ts"] = time.time() + record["duration_ms"] = int((time.perf_counter() - start) * 1000) + + self.text_logger.info( + "operation=%s target=%s result=%s code=%s request_id=%s", + record.get("operation"), + record.get("target"), + record.get("result"), + record.get("error_code"), + record.get("request_id"), + ) + + with self.json_log_path.open("a", encoding="utf-8") as file: + file.write(json.dumps(record, ensure_ascii=False) + "\n") diff --git a/user_manage_api/app/core/config.py b/user_manage_api/app/core/config.py new file mode 100644 index 0000000..a1adbe3 --- /dev/null +++ b/user_manage_api/app/core/config.py @@ -0,0 +1,87 @@ +from pathlib import Path +import shutil +from typing import List, Optional + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + token: str = Field(alias="TOKEN") + 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") + whitelist_users: str = Field(default="", alias="WHITELIST_USERS") + whitelist_groups: str = Field(default="", alias="WHITELIST_GROUPS") + locked_users: str = Field(default="", alias="LOCKED_USERS") + hidden_users: str = Field(default="", alias="HIDDEN_USERS") + hidden_groups: str = Field(default="", alias="HIDDEN_GROUPS") + user_uid_min: Optional[int] = Field(default=None, alias="USER_UID_MIN") + user_uid_max: Optional[int] = Field(default=None, alias="USER_UID_MAX") + group_gid_min: Optional[int] = Field(default=None, alias="GROUP_GID_MIN") + group_gid_max: Optional[int] = Field(default=None, alias="GROUP_GID_MAX") + use_libuser: bool = Field(default=False, alias="USE_LIBUSER") + log_level: str = Field(default="INFO", alias="LOG_LEVEL") + log_path: str = Field(default="./logs/user_manage_api.log", alias="LOG_PATH") + sudo_path: str = Field(default="/usr/bin/sudo", alias="SUDO_PATH") + command_timeout_seconds: int = 10 + + @field_validator("user_uid_min", "user_uid_max", "group_gid_min", "group_gid_max", mode="before") + @classmethod + def empty_string_to_none(cls, value: object) -> object: + if value == "": + return None + + return value + + @property + def whitelist_user_list(self) -> List[str]: + return self._parse_comma_separated_list(self.whitelist_users) + + @property + def whitelist_group_list(self) -> List[str]: + return self._parse_comma_separated_list(self.whitelist_groups) + + @property + def locked_user_list(self) -> List[str]: + return self._parse_comma_separated_list(self.locked_users) + + @property + def hidden_user_list(self) -> List[str]: + return self._parse_comma_separated_list(self.hidden_users) + + @property + def hidden_group_list(self) -> List[str]: + return self._parse_comma_separated_list(self.hidden_groups) + + def _parse_comma_separated_list(self, value: str) -> List[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def validate_settings(settings: Settings) -> None: + if not settings.token.strip(): + raise ValueError("TOKEN is required and cannot be empty.") + + log_parent = Path(settings.log_path).parent + log_parent.mkdir(parents=True, exist_ok=True) + if not log_parent.exists() or not log_parent.is_dir(): + raise ValueError(f"LOG_PATH parent is invalid: {log_parent}") + + if shutil.which(settings.sudo_path) is None and not Path(settings.sudo_path).exists(): + raise ValueError(f"SUDO_PATH is not executable: {settings.sudo_path}") + + if settings.user_uid_min is not None and settings.user_uid_max is not None and settings.user_uid_min > settings.user_uid_max: + raise ValueError("USER_UID_MIN cannot be greater than USER_UID_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.") + + required_commands = ["useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent"] + if settings.link_home_dir.strip(): + required_commands.extend(["mkdir", "ln", "chown", "unlink"]) + + for command in required_commands: + if shutil.which(command) is None: + raise ValueError(f"Required command not found in PATH: {command}") diff --git a/user_manage_api/app/core/errors.py b/user_manage_api/app/core/errors.py new file mode 100644 index 0000000..4a78fec --- /dev/null +++ b/user_manage_api/app/core/errors.py @@ -0,0 +1,22 @@ +class ApiError(Exception): + def __init__(self, status_code: int, code: str, message: str): + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + + +def map_command_error(stderr: str, exit_code: int) -> ApiError: + normalized = (stderr or "").lower() + + if "already exists" in normalized: + return ApiError(409, "resource_conflict", stderr.strip()) + if "does not exist" in normalized or "not found" in normalized: + return ApiError(404, "not_found", stderr.strip()) + if "is currently used" in normalized or "cannot remove the primary group" in normalized: + return ApiError(422, "precondition_failed", stderr.strip()) + if "permission denied" in normalized: + return ApiError(503, "system_permission_denied", stderr.strip()) + if exit_code == 124: + return ApiError(503, "system_timeout", "System command timed out.") + return ApiError(500, "system_command_error", stderr.strip() or "Unknown command error.") diff --git a/user_manage_api/app/core/models.py b/user_manage_api/app/core/models.py new file mode 100644 index 0000000..56f1995 --- /dev/null +++ b/user_manage_api/app/core/models.py @@ -0,0 +1,66 @@ +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field, field_validator + + +USERNAME_PATTERN = r"^[a-z_][a-z0-9_-]{0,31}$" +GROUPNAME_PATTERN = r"^[a-z_][a-z0-9_-]{0,31}$" + + +class UserCreateRequest(BaseModel): + 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 + def validate_groups(cls, value: List[str]) -> List[str]: + deduped = list(dict.fromkeys(value)) + for group in deduped: + if not __import__("re").match(GROUPNAME_PATTERN, group): + raise ValueError(f"Invalid group name: {group}") + return deduped + + +class UserSummary(BaseModel): + username: str + uid: int + gid: int + home_dir: str + shell: str + + +class GroupCreateRequest(BaseModel): + groupname: str = Field(pattern=GROUPNAME_PATTERN) + + +class GroupSummary(BaseModel): + groupname: str + gid: int + members: List[str] + + +class UserGroupsUpdateRequest(BaseModel): + groups: List[str] = Field(min_length=1) + mode: Literal["append", "replace"] = "append" + + @field_validator("groups") + @classmethod + def validate_groups(cls, value: List[str]) -> List[str]: + deduped = list(dict.fromkeys(value)) + for group in deduped: + if not __import__("re").match(GROUPNAME_PATTERN, group): + raise ValueError(f"Invalid group name: {group}") + return deduped + + +class UserPasswordUpdateRequest(BaseModel): + password_hash: str = Field(min_length=10, max_length=512) + + +class ApiResponse(BaseModel): + status: str = "ok" + message: str diff --git a/user_manage_api/app/factory.py b/user_manage_api/app/factory.py new file mode 100644 index 0000000..1d34869 --- /dev/null +++ b/user_manage_api/app/factory.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from app.api.routes import router +from app.core.errors import ApiError + + +def create_app() -> FastAPI: + app = FastAPI(title="Ubuntu User Manage API", version="1.0.0") + app.include_router(router) + + @app.exception_handler(ApiError) + async def api_error_handler(_: Request, exc: ApiError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"code": exc.code, "message": exc.message}) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse(status_code=400, content={"code": "invalid_parameter", "message": str(exc.errors())}) + + return app diff --git a/user_manage_api/app/main.py b/user_manage_api/app/main.py new file mode 100644 index 0000000..18bf350 --- /dev/null +++ b/user_manage_api/app/main.py @@ -0,0 +1,32 @@ +import app.container as container +from app.core.audit import AuditLogger +from app.core.config import Settings, validate_settings +from app.factory import create_app +from app.providers.cli_provider import CliSystemProvider, CommandExecutor +from app.services.user_group_service import UserGroupService +from app.state import AppState + +settings = Settings() +validate_settings(settings) + +executor = CommandExecutor(sudo_path=settings.sudo_path, timeout_seconds=settings.command_timeout_seconds) +provider = CliSystemProvider(executor=executor) +service = UserGroupService( + provider=provider, + home_base_dir=settings.home_base_dir, + link_home_dir=settings.link_home_dir or None, + hidden_users=settings.hidden_user_list, + hidden_groups=settings.hidden_group_list, + whitelist_users=settings.whitelist_user_list, + whitelist_groups=settings.whitelist_group_list, + locked_users=settings.locked_user_list, + user_uid_min=settings.user_uid_min, + user_uid_max=settings.user_uid_max, + group_gid_min=settings.group_gid_min, + group_gid_max=settings.group_gid_max, +) +audit = AuditLogger(log_path=settings.log_path) +container.app_state = AppState(settings=settings, service=service, audit=audit) +app_state = container.app_state + +app = create_app() diff --git a/user_manage_api/app/providers/__init__.py b/user_manage_api/app/providers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/user_manage_api/app/providers/__init__.py @@ -0,0 +1 @@ + diff --git a/user_manage_api/app/providers/base.py b/user_manage_api/app/providers/base.py new file mode 100644 index 0000000..72bc686 --- /dev/null +++ b/user_manage_api/app/providers/base.py @@ -0,0 +1,54 @@ +from abc import ABC, abstractmethod +from typing import List, Optional + +from app.core.models import GroupSummary, UserSummary + + +class SystemProvider(ABC): + @abstractmethod + 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: + raise NotImplementedError + + @abstractmethod + def delete_user(self, username: str) -> None: + raise NotImplementedError + + @abstractmethod + def change_user_password(self, username: str, password_hash: str) -> None: + raise NotImplementedError + + @abstractmethod + def list_users(self) -> List[UserSummary]: + raise NotImplementedError + + @abstractmethod + def get_user(self, username: str) -> UserSummary: + raise NotImplementedError + + @abstractmethod + def create_group(self, groupname: str) -> None: + raise NotImplementedError + + @abstractmethod + def delete_group(self, groupname: str) -> None: + raise NotImplementedError + + @abstractmethod + def list_groups(self) -> List[GroupSummary]: + raise NotImplementedError + + @abstractmethod + def get_group(self, groupname: str) -> GroupSummary: + raise NotImplementedError + + @abstractmethod + def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: + raise NotImplementedError + + @abstractmethod + def remove_user_groups(self, username: str, groups: List[str]) -> None: + raise NotImplementedError + + @abstractmethod + def get_user_groups(self, username: str) -> List[str]: + raise NotImplementedError diff --git a/user_manage_api/app/providers/cli_provider.py b/user_manage_api/app/providers/cli_provider.py new file mode 100644 index 0000000..ff09b6a --- /dev/null +++ b/user_manage_api/app/providers/cli_provider.py @@ -0,0 +1,122 @@ +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] diff --git a/user_manage_api/app/services/__init__.py b/user_manage_api/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/user_manage_api/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/user_manage_api/app/services/user_group_service.py b/user_manage_api/app/services/user_group_service.py new file mode 100644 index 0000000..69b9fa4 --- /dev/null +++ b/user_manage_api/app/services/user_group_service.py @@ -0,0 +1,184 @@ +from pathlib import PurePosixPath +from typing import List, Optional + +from app.core.errors import ApiError +from app.core.models import GroupSummary, UserCreateRequest, UserSummary +from app.providers.base import SystemProvider + + +class UserGroupService: + def __init__( + self, + provider: SystemProvider, + home_base_dir: str, + link_home_dir: Optional[str] = None, + hidden_users: Optional[List[str]] = None, + hidden_groups: Optional[List[str]] = None, + whitelist_users: Optional[List[str]] = None, + whitelist_groups: Optional[List[str]] = None, + locked_users: Optional[List[str]] = None, + user_uid_min: Optional[int] = None, + user_uid_max: Optional[int] = None, + group_gid_min: Optional[int] = None, + group_gid_max: Optional[int] = None, + ): + 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.hidden_users = set(hidden_users or []) + self.hidden_groups = set(hidden_groups or []) + self.whitelist_users = set(whitelist_users or []) + self.whitelist_groups = set(whitelist_groups or []) + self.locked_users = set(locked_users or []) + self.user_uid_min = user_uid_min + self.user_uid_max = user_uid_max + self.group_gid_min = group_gid_min + self.group_gid_max = group_gid_max + + def _ensure_user_visible(self, username: str) -> None: + user = self.provider.get_user(username) + if not self._is_user_visible(user): + raise ApiError(404, "not_found", "user not found") + + def _ensure_groups_visible(self, groups: List[str]) -> None: + for groupname in groups: + self._ensure_group_visible(groupname) + + def _ensure_group_visible(self, groupname: str) -> None: + group = self.provider.get_group(groupname) + if not self._is_group_visible(group): + raise ApiError(404, "not_found", "group not found") + + def _ensure_user_name_allowed(self, username: str) -> None: + if username not in self.whitelist_users and username in self.hidden_users: + raise ApiError(404, "not_found", "user not found") + + def _ensure_user_unlocked(self, username: str) -> None: + if username in self.locked_users: + raise ApiError(423, "user_locked", "user is locked and cannot be modified") + + def _ensure_group_name_allowed(self, groupname: str) -> None: + if groupname not in self.whitelist_groups and groupname in self.hidden_groups: + raise ApiError(404, "not_found", "group not found") + + def _is_uid_in_range(self, uid: int) -> bool: + if self.user_uid_min is not None and uid < self.user_uid_min: + return False + if self.user_uid_max is not None and uid > self.user_uid_max: + return False + return True + + def _is_gid_in_range(self, gid: int) -> bool: + if self.group_gid_min is not None and gid < self.group_gid_min: + return False + if self.group_gid_max is not None and gid > self.group_gid_max: + return False + return True + + def _is_user_visible(self, user: UserSummary) -> bool: + if user.username in self.whitelist_users: + return True + if user.username in self.hidden_users: + return False + return self._is_uid_in_range(user.uid) + + def _is_group_visible(self, group: GroupSummary) -> bool: + if group.groupname in self.whitelist_groups: + return True + if group.groupname in self.hidden_groups: + 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_linked_home_dir(self, username: str) -> Optional[str]: + if self.link_home_base_dir is None: + return None + + 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) + 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, + primary_group=payload.primary_group, + groups=payload.groups, + ) + + def delete_user(self, username: str) -> None: + self._ensure_user_visible(username) + self._ensure_user_unlocked(username) + self.provider.delete_user(username) + + def change_user_password(self, username: str, password_hash: str) -> None: + self._ensure_user_visible(username) + self._ensure_user_unlocked(username) + self.provider.change_user_password(username, password_hash) + + def list_users(self) -> List[UserSummary]: + return [user for user in self.provider.list_users() if self._is_user_visible(user)] + + def get_user(self, username: str) -> UserSummary: + user = self.provider.get_user(username) + if not self._is_user_visible(user): + raise ApiError(404, "not_found", "user not found") + return user + + def create_group(self, groupname: str) -> None: + self._ensure_group_name_allowed(groupname) + self.provider.create_group(groupname) + + def delete_group(self, groupname: str) -> None: + self._ensure_group_visible(groupname) + group = self.provider.get_group(groupname) + if group.members: + raise ApiError(422, "precondition_failed", "Group has members and cannot be deleted.") + self.provider.delete_group(groupname) + + def list_groups(self) -> List[GroupSummary]: + return [group for group in self.provider.list_groups() if self._is_group_visible(group)] + + def get_group(self, groupname: str) -> GroupSummary: + group = self.provider.get_group(groupname) + if not self._is_group_visible(group): + raise ApiError(404, "not_found", "group not found") + return group + + def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: + self._ensure_user_visible(username) + self._ensure_user_unlocked(username) + self._ensure_groups_visible(groups) + self.provider.add_user_groups(username, groups, replace) + + def remove_user_groups(self, username: str, groups: List[str]) -> None: + self._ensure_user_visible(username) + self._ensure_user_unlocked(username) + self._ensure_groups_visible(groups) + self.provider.remove_user_groups(username, groups) + + def get_user_groups(self, username: str) -> List[str]: + self._ensure_user_visible(username) + return [group for group in self.provider.get_user_groups(username) if self._is_group_visible(self.provider.get_group(group))] diff --git a/user_manage_api/app/state.py b/user_manage_api/app/state.py new file mode 100644 index 0000000..4d7db1f --- /dev/null +++ b/user_manage_api/app/state.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from app.core.audit import AuditLogger +from app.core.config import Settings +from app.services.user_group_service import UserGroupService + + +@dataclass +class AppState: + settings: Settings + service: UserGroupService + audit: AuditLogger diff --git a/user_manage_api/examples/python_minimal_example.py b/user_manage_api/examples/python_minimal_example.py new file mode 100644 index 0000000..2052931 --- /dev/null +++ b/user_manage_api/examples/python_minimal_example.py @@ -0,0 +1,30 @@ +import requests + +BASE_URL = "http://127.0.0.1:8000" +TOKEN = "replace-with-token" +HEADERS = {"Authorization": f"Bearer {TOKEN}"} + + +def main() -> None: + create_user = requests.post( + f"{BASE_URL}/users", + headers=HEADERS, + json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": ["dev"]}, + timeout=10, + ) + print("create_user:", create_user.status_code, create_user.text) + + add_group = requests.post( + f"{BASE_URL}/users/alice/groups", + headers=HEADERS, + json={"groups": ["dev"], "mode": "append"}, + timeout=10, + ) + print("add_group:", add_group.status_code, add_group.text) + + get_user = requests.get(f"{BASE_URL}/users/alice", headers=HEADERS, timeout=10) + print("get_user:", get_user.status_code, get_user.text) + + +if __name__ == "__main__": + main() diff --git a/user_manage_api/requirements.txt b/user_manage_api/requirements.txt new file mode 100644 index 0000000..a61fcfa --- /dev/null +++ b/user_manage_api/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +pydantic==2.9.2 +pydantic-settings==2.5.2 +httpx==0.27.2 +pytest==8.3.3 diff --git a/user_manage_api/scripts/generate_python_sdk.py b/user_manage_api/scripts/generate_python_sdk.py new file mode 100644 index 0000000..5cd85fb --- /dev/null +++ b/user_manage_api/scripts/generate_python_sdk.py @@ -0,0 +1,60 @@ +from pathlib import Path +import json +from typing import List, Optional + +from fastapi.openapi.utils import get_openapi + +import app.container as container +from app.core.audit import AuditLogger +from app.core.config import Settings +from app.factory import create_app +from app.providers.base import SystemProvider +from app.services.user_group_service import UserGroupService +from app.state import AppState + + +class DummyProvider(SystemProvider): + 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: + return None + def delete_user(self, username: str) -> None: + return None + def change_user_password(self, username: str, password_hash: str) -> None: + return None + def list_users(self): + return [] + def get_user(self, username: str): + raise NotImplementedError + def create_group(self, groupname: str) -> None: + return None + def delete_group(self, groupname: str) -> None: + return None + def list_groups(self): + return [] + def get_group(self, groupname: str): + raise NotImplementedError + def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: + return None + def remove_user_groups(self, username: str, groups: List[str]) -> None: + return None + def get_user_groups(self, username: str): + return [] + + +def main() -> None: + settings = Settings(TOKEN="sdk-token") + container.app_state = AppState( + settings=settings, + service=UserGroupService(provider=DummyProvider(), home_base_dir="/home"), + audit=AuditLogger("./logs/sdk_audit.log"), + ) + app = create_app() + schema = get_openapi(title=app.title, version=app.version, routes=app.routes) + output = Path("sdk/openapi.json") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8") + print("OpenAPI exported to sdk/openapi.json") + print("Run: openapi-python-client generate --path sdk/openapi.json --output-path sdk/python_client") + + +if __name__ == "__main__": + main() diff --git a/user_manage_api/tests/hash.py b/user_manage_api/tests/hash.py new file mode 100644 index 0000000..7a994eb --- /dev/null +++ b/user_manage_api/tests/hash.py @@ -0,0 +1,32 @@ +from passlib.hash import sha512_crypt +import random +import string +import time + +username = "test" +password = "test" + +# 生成16位随机salt +salt = ''.join( + random.choices( + string.ascii_letters + string.digits, + k=16 + ) +) + +# salt = 'uu2JGUu15xlX4eWn' + +# Ubuntu SHA512 shadow格式 +hashed = sha512_crypt.hash( + password, + salt=salt, + rounds=5000 +) + +# Linux shadow日期 +days = int(time.time() / 86400) + +# 拼接shadow内容 +shadow = f"{username}:{hashed}:{days}:0:99999:7:::" + +print(shadow) \ No newline at end of file diff --git a/user_manage_api/tests/test_api_integration.py b/user_manage_api/tests/test_api_integration.py new file mode 100644 index 0000000..8ff7e56 --- /dev/null +++ b/user_manage_api/tests/test_api_integration.py @@ -0,0 +1,251 @@ +from fastapi.testclient import TestClient +from typing import Dict, List, Optional + +import app.container as container +from app.core.audit import AuditLogger +from app.core.config import Settings +from app.core.errors import ApiError +from app.factory import create_app +from app.core.models import GroupSummary, UserSummary +from app.providers.base import SystemProvider +from app.services.user_group_service import UserGroupService +from app.state import AppState + + +class MockProvider(SystemProvider): + def __init__(self): + self.users: Dict[str, UserSummary] = {} + self.groups: Dict[str, GroupSummary] = {} + self.user_group_map: Dict[str, List[str]] = {} + + 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: + if username in self.users: + raise ApiError(409, "resource_conflict", "user exists") + self.users[username] = UserSummary(username=username, uid=1000, gid=1000, home_dir=home_dir or f"/home/{username}", shell=shell) + self.user_group_map[username] = groups + + def delete_user(self, username: str) -> None: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + del self.users[username] + self.user_group_map.pop(username, None) + + def change_user_password(self, username: str, password_hash: str) -> None: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + + def list_users(self) -> List[UserSummary]: + return list(self.users.values()) + + def get_user(self, username: str) -> UserSummary: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + return self.users[username] + + def create_group(self, groupname: str) -> None: + if groupname in self.groups: + raise ApiError(409, "resource_conflict", "group exists") + self.groups[groupname] = GroupSummary(groupname=groupname, gid=1000, members=[]) + + def delete_group(self, groupname: str) -> None: + if groupname not in self.groups: + raise ApiError(404, "not_found", "group not found") + del self.groups[groupname] + + def list_groups(self) -> List[GroupSummary]: + return list(self.groups.values()) + + def get_group(self, groupname: str) -> GroupSummary: + if groupname not in self.groups: + raise ApiError(404, "not_found", "group not found") + return self.groups[groupname] + + def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + if replace: + self.user_group_map[username] = groups + else: + self.user_group_map[username] = list(dict.fromkeys(self.user_group_map.get(username, []) + groups)) + + def remove_user_groups(self, username: str, groups: List[str]) -> None: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + self.user_group_map[username] = [item for item in self.user_group_map.get(username, []) if item not in set(groups)] + + def get_user_groups(self, username: str) -> List[str]: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + return self.user_group_map.get(username, []) + + +def build_client( + link_home_dir: Optional[str] = None, + hidden_users: Optional[List[str]] = None, + hidden_groups: Optional[List[str]] = None, + whitelist_users: Optional[List[str]] = None, + whitelist_groups: Optional[List[str]] = None, + locked_users: Optional[List[str]] = None, + user_uid_min: Optional[int] = None, + user_uid_max: Optional[int] = None, + group_gid_min: Optional[int] = None, + group_gid_max: Optional[int] = None, +) -> TestClient: + settings = Settings(TOKEN="test-token") + service = UserGroupService( + provider=MockProvider(), + home_base_dir="/home", + link_home_dir=link_home_dir, + hidden_users=hidden_users, + hidden_groups=hidden_groups, + whitelist_users=whitelist_users, + whitelist_groups=whitelist_groups, + locked_users=locked_users, + user_uid_min=user_uid_min, + user_uid_max=user_uid_max, + group_gid_min=group_gid_min, + group_gid_max=group_gid_max, + ) + audit = AuditLogger("./logs/test_audit.log") + container.app_state = AppState(settings=settings, service=service, audit=audit) + return TestClient(create_app()) + + +def test_user_group_happy_path() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + + response = client.post("/groups", json={"groupname": "dev"}, headers=headers) + assert response.status_code == 200 + + response = client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": ["dev"]}, headers=headers) + assert response.status_code == 200 + + response = client.post("/users/alice/groups", json={"groups": ["dev"], "mode": "append"}, headers=headers) + assert response.status_code == 200 + + response = client.get("/users/alice/groups", headers=headers) + assert response.status_code == 200 + assert "dev" in response.json()["groups"] + + +def test_health_returns_server_name_and_online_status() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + + response = client.get("/health", headers=headers) + + assert response.status_code == 200 + assert response.json() == {"server_name": "user-manage-api", "status": "online"} + + +def test_change_user_password() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + + client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) + response = client.patch("/users/alice/password", json={"password_hash": "$6$rounds=5000$newhashvalue"}, headers=headers) + + assert response.status_code == 200 + assert response.json()["message"] == "User password updated." + + +def test_locked_user_can_be_read_but_not_modified() -> None: + client = build_client(locked_users=["alice"]) + headers = {"Authorization": "Bearer test-token"} + provider = container.app_state.service.provider + provider.users["alice"] = UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash") + + response = client.get("/users/alice", headers=headers) + assert response.status_code == 200 + + response = client.patch("/users/alice/password", json={"password_hash": "$6$rounds=5000$newhashvalue"}, headers=headers) + assert response.status_code == 423 + + response = client.delete("/users/alice", headers=headers) + assert response.status_code == 423 + + response = client.post("/users/alice/groups", json={"groups": ["dev"], "mode": "append"}, headers=headers) + assert response.status_code == 423 + + +def test_conflict_and_not_found_codes() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + payload = {"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []} + + response = client.post("/users", json=payload, headers=headers) + assert response.status_code == 200 + + response = client.post("/users", json=payload, headers=headers) + assert response.status_code == 409 + + response = client.delete("/users/bob", headers=headers) + assert response.status_code == 404 + + +def test_hidden_users_and_groups_are_filtered_and_blocked() -> None: + client = build_client(hidden_users=["root"], hidden_groups=["sudo"]) + headers = {"Authorization": "Bearer test-token"} + + client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) + client.post("/groups", json={"groupname": "dev"}, headers=headers) + + provider = container.app_state.service.provider + provider.users["root"] = UserSummary(username="root", uid=0, gid=0, home_dir="/root", shell="/bin/bash") + provider.groups["sudo"] = GroupSummary(groupname="sudo", gid=27, members=["root"]) + provider.user_group_map["alice"] = ["dev", "sudo"] + + response = client.get("/users", headers=headers) + assert response.status_code == 200 + assert [user["username"] for user in response.json()] == ["alice"] + + response = client.get("/groups", headers=headers) + assert response.status_code == 200 + assert [group["groupname"] for group in response.json()] == ["dev"] + + response = client.get("/users/root", headers=headers) + assert response.status_code == 404 + + response = client.delete("/groups/sudo", headers=headers) + assert response.status_code == 404 + + response = client.get("/users/alice/groups", headers=headers) + assert response.status_code == 200 + assert response.json()["groups"] == ["dev"] + + +def test_whitelist_overrides_hidden_and_id_ranges() -> None: + client = build_client( + hidden_users=["root"], + hidden_groups=["sudo"], + whitelist_users=["root"], + whitelist_groups=["sudo"], + user_uid_min=1000, + user_uid_max=2000, + group_gid_min=1000, + group_gid_max=2000, + ) + headers = {"Authorization": "Bearer test-token"} + + provider = container.app_state.service.provider + provider.users["root"] = UserSummary(username="root", uid=0, gid=0, home_dir="/root", shell="/bin/bash") + provider.users["alice"] = UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash") + provider.users["system"] = UserSummary(username="system", uid=500, gid=500, home_dir="/home/system", shell="/bin/bash") + provider.groups["sudo"] = GroupSummary(groupname="sudo", gid=27, members=["root"]) + provider.groups["dev"] = GroupSummary(groupname="dev", gid=1000, members=[]) + provider.groups["system"] = GroupSummary(groupname="system", gid=500, members=[]) + + response = client.get("/users", headers=headers) + assert response.status_code == 200 + assert [user["username"] for user in response.json()] == ["root", "alice"] + + response = client.get("/groups", headers=headers) + assert response.status_code == 200 + assert [group["groupname"] for group in response.json()] == ["sudo", "dev"] + + response = client.get("/users/root", headers=headers) + assert response.status_code == 200 + + response = client.get("/users/system", headers=headers) + assert response.status_code == 404 diff --git a/user_manage_api/tests/test_service_unit.py b/user_manage_api/tests/test_service_unit.py new file mode 100644 index 0000000..2b35017 --- /dev/null +++ b/user_manage_api/tests/test_service_unit.py @@ -0,0 +1,109 @@ +import pytest +from typing import List, Optional + +from app.core.errors import ApiError, map_command_error +from app.core.models import UserCreateRequest +import app.providers.cli_provider as cli_provider +from app.providers.cli_provider import CliSystemProvider +from app.providers.base import SystemProvider +from app.services.user_group_service import UserGroupService + + +class NoopProvider(SystemProvider): + def __init__(self) -> None: + self.created_home_dir: Optional[str] = None + self.created_linked_home_dir: 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 + return None + def delete_user(self, username: str) -> None: + return None + def change_user_password(self, username: str, password_hash: str) -> None: + return None + def list_users(self): + return [] + def get_user(self, username: str): + raise ApiError(404, "not_found", "not found") + def create_group(self, groupname: str) -> None: + return None + def delete_group(self, groupname: str) -> None: + return None + def list_groups(self): + return [] + def get_group(self, groupname: str): + raise ApiError(404, "not_found", "not found") + def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None: + return None + def remove_user_groups(self, username: str, groups: List[str]) -> None: + return None + def get_user_groups(self, username: str): + 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_link_home_dir_uses_home_base_symlink_path_and_external_storage() -> None: + provider = NoopProvider() + service = UserGroupService(provider=provider, home_base_dir="/home", link_home_dir="/data/home") + payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", groups=[]) + + service.create_user(payload) + + assert provider.created_home_dir == "/home/alice" + assert provider.created_linked_home_dir == "/data/home/alice" + + +def test_command_error_mapping_conflict() -> None: + error = map_command_error("user already exists", 9) + assert error.status_code == 409 + + +class RecordingExecutor: + def __init__(self, home_dir: str) -> None: + self.home_dir = home_dir + self.commands: List[List[str]] = [] + + def run(self, args: List[str], use_sudo: bool = True) -> str: + self.commands.append(args) + if args == ["getent", "passwd", "alice"]: + return f"alice:x:1000:1000::%s:/bin/bash" % self.home_dir + + return "" + + +class FakePath: + def __init__(self, path: str) -> None: + self.path = path + + def is_symlink(self) -> bool: + return self.path == "/home/alice" + + +def test_delete_user_unlinks_home_when_it_is_symlink(monkeypatch) -> None: + monkeypatch.setattr(cli_provider, "Path", FakePath) + executor = RecordingExecutor("/home/alice") + provider = CliSystemProvider(executor=executor) + + provider.delete_user("alice") + + assert ["userdel", "alice"] in executor.commands + assert ["unlink", "/home/alice"] in executor.commands + + +def test_delete_user_does_not_unlink_home_when_it_is_directory(monkeypatch) -> None: + monkeypatch.setattr(cli_provider, "Path", FakePath) + executor = RecordingExecutor("/home/old-alice") + provider = CliSystemProvider(executor=executor) + + provider.delete_user("alice") + + assert ["userdel", "alice"] in executor.commands + assert ["unlink", "/home/old-alice"] not in executor.commands