From 9b17f764cbfad3fa2e151fbd9396592e1d91f366 Mon Sep 17 00:00:00 2001 From: Boen_Shi Date: Wed, 17 Jun 2026 23:18:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BB=91=E5=AE=9A):=20=E5=A2=9E=E6=B7=BB=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/ServerResourceController.php | 102 ++++++- .../Api/ServerSystemUserController.php | 54 +++- app/Http/Controllers/Api/UserController.php | 86 +++++- .../Requests/StoreServerResourceRequest.php | 4 + app/Http/Requests/StoreUserRequest.php | 4 + .../Requests/UpdateServerResourceRequest.php | 4 + app/Http/Requests/UpdateUserRequest.php | 4 + app/Models/ServerResource.php | 4 + app/Models/ServerUserBinding.php | 2 + app/Services/ServerUserManagementClient.php | 101 ++++++- ...environment_variables_to_server_tables.php | 42 +++ ...nt_variables_to_server_resources_table.php | 31 +++ ..._user_groups_to_server_resources_table.php | 31 +++ .../ServerSystemUserManagementTest.php | 250 ++++++++++++++++++ .../app/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 155 bytes .../app/__pycache__/container.cpython-310.pyc | Bin 0 -> 238 bytes .../app/__pycache__/factory.cpython-310.pyc | Bin 0 -> 1216 bytes .../app/__pycache__/main.cpython-310.pyc | Bin 0 -> 1243 bytes .../app/__pycache__/state.cpython-310.pyc | Bin 0 -> 618 bytes .../api/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 159 bytes .../app/api/__pycache__/deps.cpython-310.pyc | Bin 0 -> 1075 bytes .../api/__pycache__/routes.cpython-310.pyc | Bin 0 -> 5297 bytes user_manage_api/app/api/routes.py | 23 +- .../core/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 160 bytes .../core/__pycache__/audit.cpython-310.pyc | Bin 0 -> 2001 bytes .../core/__pycache__/config.cpython-310.pyc | Bin 0 -> 4325 bytes .../core/__pycache__/errors.cpython-310.pyc | Bin 0 -> 1349 bytes .../core/__pycache__/models.cpython-310.pyc | Bin 0 -> 3706 bytes user_manage_api/app/core/errors.py | 4 +- user_manage_api/app/core/models.py | 20 ++ .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 165 bytes .../__pycache__/base.cpython-310.pyc | Bin 0 -> 3119 bytes .../__pycache__/cli_provider.cpython-310.pyc | Bin 0 -> 8697 bytes user_manage_api/app/providers/base.py | 12 + user_manage_api/app/providers/cli_provider.py | 74 +++++- .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 164 bytes .../user_group_service.cpython-310.pyc | Bin 0 -> 8506 bytes .../app/services/user_group_service.py | 33 ++- user_manage_api/logs/user_manage_api.jsonl | 7 + user_manage_api/logs/user_manage_api.log | 7 + ...i_integration.cpython-312-pytest-7.4.4.pyc | Bin 0 -> 37676 bytes ..._service_unit.cpython-312-pytest-7.4.4.pyc | Bin 0 -> 25164 bytes user_manage_api/tests/test_api_integration.py | 71 ++++- user_manage_api/tests/test_service_unit.py | 111 +++++++- 44 files changed, 1043 insertions(+), 38 deletions(-) create mode 100644 database/migrations/2026_06_16_063017_add_password_change_and_environment_variables_to_server_tables.php create mode 100644 database/migrations/2026_06_17_135300_add_all_user_environment_variables_to_server_resources_table.php create mode 100644 database/migrations/2026_06_17_141450_add_default_user_groups_to_server_resources_table.php create mode 100644 user_manage_api/app/__pycache__/__init__.cpython-310.pyc create mode 100644 user_manage_api/app/__pycache__/container.cpython-310.pyc create mode 100644 user_manage_api/app/__pycache__/factory.cpython-310.pyc create mode 100644 user_manage_api/app/__pycache__/main.cpython-310.pyc create mode 100644 user_manage_api/app/__pycache__/state.cpython-310.pyc create mode 100644 user_manage_api/app/api/__pycache__/__init__.cpython-310.pyc create mode 100644 user_manage_api/app/api/__pycache__/deps.cpython-310.pyc create mode 100644 user_manage_api/app/api/__pycache__/routes.cpython-310.pyc create mode 100644 user_manage_api/app/core/__pycache__/__init__.cpython-310.pyc create mode 100644 user_manage_api/app/core/__pycache__/audit.cpython-310.pyc create mode 100644 user_manage_api/app/core/__pycache__/config.cpython-310.pyc create mode 100644 user_manage_api/app/core/__pycache__/errors.cpython-310.pyc create mode 100644 user_manage_api/app/core/__pycache__/models.cpython-310.pyc create mode 100644 user_manage_api/app/providers/__pycache__/__init__.cpython-310.pyc create mode 100644 user_manage_api/app/providers/__pycache__/base.cpython-310.pyc create mode 100644 user_manage_api/app/providers/__pycache__/cli_provider.cpython-310.pyc create mode 100644 user_manage_api/app/services/__pycache__/__init__.cpython-310.pyc create mode 100644 user_manage_api/app/services/__pycache__/user_group_service.cpython-310.pyc create mode 100644 user_manage_api/tests/__pycache__/test_api_integration.cpython-312-pytest-7.4.4.pyc create mode 100644 user_manage_api/tests/__pycache__/test_service_unit.cpython-312-pytest-7.4.4.pyc diff --git a/app/Http/Controllers/Api/ServerResourceController.php b/app/Http/Controllers/Api/ServerResourceController.php index d5bf05a..5301be1 100644 --- a/app/Http/Controllers/Api/ServerResourceController.php +++ b/app/Http/Controllers/Api/ServerResourceController.php @@ -12,6 +12,7 @@ use App\Models\ServerResource; use App\Models\ServerUserBinding; use App\Models\User; use App\Models\UserServerPermission; +use App\Services\ServerUserManagementClient; use hg\apidoc\annotation as Apidoc; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; @@ -25,11 +26,11 @@ use Spatie\Permission\Models\Permission; #[Apidoc\Title('服务器资源管理')] class ServerResourceController extends Controller { - public function __construct() + public function __construct(private ServerUserManagementClient $serverUserClient) { $this->middleware('auth:api'); $this->middleware('permission:platform.servers.view|resource.servers.use,api')->only(['index', 'show']); - $this->middleware('permission:platform.servers.manage,api')->only(['store', 'update', 'destroy', 'syncUserPermissions', 'userPermissions']); + $this->middleware('permission:platform.servers.manage,api')->only(['store', 'update', 'destroy', 'syncUserPermissions', 'userPermissions', 'updateDefaultEnvironment', 'updateAllUserEnvironments', 'updateDefaultUserGroups']); } #[Apidoc\Title('资源列表'), Apidoc\Method('GET'), Apidoc\Url('/servers')] @@ -131,6 +132,9 @@ class ServerResourceController extends Controller $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; $data['user_api_base_url'] = null; $data['user_api_token'] = null; + $data['default_environment_variables'] = null; + $data['all_user_environment_variables'] = null; + $data['default_user_groups'] = null; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)]; $data['display_name'] = $data['display_name'] ?? $data['name']; $data['asset_id'] = $parent->asset_id; @@ -150,6 +154,7 @@ class ServerResourceController extends Controller $data['protocols'] = []; $data['account_id'] = null; $data['display_name'] = $data['display_name'] ?? $data['name']; + $data['default_user_groups'] = array_values(array_unique($data['default_user_groups'] ?? [])); if (trim((string) ($data['user_api_token'] ?? '')) === '') { $data['user_api_token'] = null; } @@ -189,6 +194,9 @@ class ServerResourceController extends Controller $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; $data['user_api_base_url'] = null; $data['user_api_token'] = null; + $data['default_environment_variables'] = null; + $data['all_user_environment_variables'] = null; + $data['default_user_groups'] = null; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))]; $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); $data['asset_id'] = $parent->asset_id; @@ -208,6 +216,7 @@ class ServerResourceController extends Controller $data['protocols'] = []; $data['account_id'] = null; $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); + $data['default_user_groups'] = array_values(array_unique($data['default_user_groups'] ?? [])); if (trim((string) ($data['user_api_token'] ?? '')) === '') { unset($data['user_api_token']); } @@ -324,6 +333,73 @@ class ServerResourceController extends Controller return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]); } + #[Apidoc\Title('保存服务器默认环境变量'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/default-environment')] + public function updateDefaultEnvironment(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'content' => ['nullable', 'string', 'max:20000'], + ]); + + $server = ServerResource::query()->with('parent')->findOrFail($id); + $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; + $targetServer->update([ + 'default_environment_variables' => (string) ($validated['content'] ?? ''), + ]); + + $this->auditLog($request, 'server_default_environment_update', [ + 'server_resource_id' => $targetServer->id, + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $targetServer]); + } + + #[Apidoc\Title('设置所有服务器用户环境变量'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/system-users/environment')] + public function updateAllUserEnvironments(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'content' => ['nullable', 'string', 'max:20000'], + ]); + + $server = ServerResource::query()->with('parent')->findOrFail($id); + $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; + $content = (string) ($validated['content'] ?? ''); + $targetServer->update([ + 'all_user_environment_variables' => $content, + ]); + $result = $this->serverUserClient->updateAllUserEnvironments($targetServer, $content); + + $this->auditLog($request, 'server_all_user_environment_update', [ + 'server_resource_id' => $targetServer->id, + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => array_merge($result, [ + 'all_user_environment_variables' => $content, + ])]); + } + + #[Apidoc\Title('保存新建服务器用户默认用户组'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/default-user-groups')] + public function updateDefaultUserGroups(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'groups' => ['sometimes', 'array'], + 'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + ]); + + $server = ServerResource::query()->with('parent')->findOrFail($id); + $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; + $groups = array_values(array_unique($validated['groups'] ?? [])); + $targetServer->update([ + 'default_user_groups' => $groups, + ]); + + $this->auditLog($request, 'server_default_user_groups_update', [ + 'server_resource_id' => $targetServer->id, + 'metadata' => ['groups' => $groups], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $targetServer]); + } + #[Apidoc\Title('使用服务器资源'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/use')] public function useResource(Request $request, int $id): JsonResponse { @@ -381,6 +457,20 @@ class ServerResourceController extends Controller ], 403); } + $binding = $this->boundServerUserBinding($user, $resource); + $accountName = (string) ($validated['account_name'] ?? ''); + if ($binding && $binding->force_password_change && $accountName === (string) $binding->username) { + return response()->json([ + 'code' => 423, + 'message' => '首次使用该服务器账号前,请先修改服务器账号密码。', + 'data' => [ + 'reason' => 'server_password_change_required', + 'server_resource_id' => (int) ($resource->parent_id ?: $resource->id), + 'username' => $binding->username, + ], + ], 423); + } + $bastionAccount = BastionAccount::query() ->where('is_active', true) ->whereNotNull('usm') @@ -403,7 +493,6 @@ class ServerResourceController extends Controller $timeout = (int) config('services.bastion_access.timeout', 30); $verifySsl = (bool) config('services.bastion_access.verify_ssl', false); $protocolId = $this->resolveProtocolId($protocol); - $accountName = (string) ($validated['account_name'] ?? ''); $password = (string) ($validated['password'] ?? ''); try { @@ -546,13 +635,18 @@ class ServerResourceController extends Controller } private function boundServerUsername(User $user, ServerResource $resource): ?string + { + return $this->boundServerUserBinding($user, $resource)?->username; + } + + private function boundServerUserBinding(User $user, ServerResource $resource): ?ServerUserBinding { $serverId = (int) ($resource->parent_id ?: $resource->id); return ServerUserBinding::query() ->where('user_id', $user->id) ->where('server_resource_id', $serverId) - ->value('username'); + ->first(); } private function extractSsoTokenFromUrl(string $ssoUrl): ?string diff --git a/app/Http/Controllers/Api/ServerSystemUserController.php b/app/Http/Controllers/Api/ServerSystemUserController.php index a4fc056..c6eeb43 100644 --- a/app/Http/Controllers/Api/ServerSystemUserController.php +++ b/app/Http/Controllers/Api/ServerSystemUserController.php @@ -27,6 +27,7 @@ class ServerSystemUserController extends Controller $validated = $request->validate([ 'password' => ['required', 'string', 'min:6', 'max:255'], ]); + $this->ensureStrongPassword((string) $validated['password']); $server = $this->server($id); $resource = ServerResource::query()->with('parent')->findOrFail($id); @@ -55,6 +56,7 @@ class ServerSystemUserController extends Controller $result = $this->client->updatePassword($server, (string) $binding->username, $this->linuxPasswordHash((string) $validated['password'])); $binding->update([ 'remote_exists' => true, + 'force_password_change' => false, 'last_synced_at' => now(), ]); @@ -129,7 +131,8 @@ class ServerSystemUserController extends Controller 'username' => $validated['username'], 'password_hash' => $passwordHash, 'primary_group' => $validated['primary_group'] ?? null, - 'groups' => array_values(array_unique($validated['groups'] ?? [])), + 'groups' => array_values(array_unique(array_merge($server->default_user_groups ?? [], $validated['groups'] ?? []))), + 'default_environment_variables' => (string) ($server->default_environment_variables ?? ''), ]; $result = $this->client->createUser($server, $payload); @@ -174,6 +177,14 @@ class ServerSystemUserController extends Controller } $result = $this->client->updatePassword($server, $username, $passwordHash); + ServerUserBinding::query() + ->where('server_resource_id', $server->id) + ->where('username', $username) + ->update([ + 'remote_exists' => true, + 'force_password_change' => false, + 'last_synced_at' => now(), + ]); $this->auditLog($request, 'server_system_user_password_update', [ 'server_resource_id' => $server->id, 'metadata' => ['username' => $username], @@ -182,6 +193,32 @@ class ServerSystemUserController extends Controller return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); } + #[Apidoc\Title('读取服务器用户环境变量'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}/system-users/{username}/environment')] + public function userEnvironment(int $id, string $username): JsonResponse + { + $server = $this->server($id); + $result = $this->client->userEnvironment($server, $username); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + + #[Apidoc\Title('修改服务器用户环境变量'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/system-users/{username}/environment')] + public function updateUserEnvironment(Request $request, int $id, string $username): JsonResponse + { + $validated = $request->validate([ + 'content' => ['nullable', 'string', 'max:20000'], + ]); + + $server = $this->server($id); + $result = $this->client->updateUserEnvironment($server, $username, (string) ($validated['content'] ?? '')); + $this->auditLog($request, 'server_system_user_environment_update', [ + 'server_resource_id' => $server->id, + 'metadata' => ['username' => $username], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + #[Apidoc\Title('创建服务器用户组'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/system-groups')] public function storeGroup(Request $request, int $id): JsonResponse { @@ -304,4 +341,19 @@ class ServerSystemUserController extends Controller return $hash; } + + private function ensureStrongPassword(string $password): void + { + $types = 0; + $types += preg_match('/[a-z]/', $password) === 1 ? 1 : 0; + $types += preg_match('/[A-Z]/', $password) === 1 ? 1 : 0; + $types += preg_match('/\d/', $password) === 1 ? 1 : 0; + $types += preg_match('/[^a-zA-Z\d]/', $password) === 1 ? 1 : 0; + + if ($types < 3) { + throw ValidationException::withMessages([ + 'password' => ['密码不能过于简单,需包含大小写字母、数字、特殊字符中的至少三类。'], + ]); + } + } } diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index e01997d..4c575b8 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -26,7 +26,7 @@ class UserController extends Controller { $this->middleware('auth:api'); $this->middleware('permission:platform.users.view,api')->only(['index', 'show']); - $this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'syncServerBindings', 'import', 'importTemplate']); + $this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'syncServerBindings', 'checkServerUser', 'import', 'importTemplate']); } #[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')] @@ -53,7 +53,7 @@ class UserController extends Controller #[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')] public function store(StoreUserRequest $request): JsonResponse { - $data = $request->safe()->except(['role_ids', 'server_bindings']); + $data = $request->safe()->except(['role_ids', 'server_bindings', 'server_unbindings']); $user = User::query()->create($data); if ($request->filled('role_ids')) { @@ -64,6 +64,10 @@ class UserController extends Controller $this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) $request->validated('password')); } + if ($request->filled('server_unbindings')) { + $this->deleteServerBindingsPayload($user, $request->validated('server_unbindings')); + } + $this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])], 201); @@ -85,7 +89,7 @@ class UserController extends Controller } $user = User::query()->findOrFail($id); - $user->fill($request->safe()->except(['role_ids', 'server_bindings'])); + $user->fill($request->safe()->except(['role_ids', 'server_bindings', 'server_unbindings'])); if ($request->filled('password')) { $user->password = $request->validated('password'); @@ -101,6 +105,10 @@ class UserController extends Controller $this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) ($request->validated('password') ?? '')); } + if ($request->has('server_unbindings')) { + $this->deleteServerBindingsPayload($user, $request->validated('server_unbindings')); + } + $this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]); @@ -117,15 +125,45 @@ class UserController extends Controller 'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'], 'server_bindings.*.groups' => ['sometimes', 'array'], 'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_unbindings' => ['sometimes', 'array'], + 'server_unbindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'server_unbindings.*.username' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_unbindings.*.delete_remote' => ['sometimes', 'boolean'], ]); $user = User::query()->findOrFail($id); $this->syncServerBindingsPayload($user, $validated['server_bindings'], ''); + if (! empty($validated['server_unbindings'])) { + $this->deleteServerBindingsPayload($user, $validated['server_unbindings']); + } $this->auditLog($request, 'user_server_bindings_update', ['metadata' => ['target_user_id' => $user->id]]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]); } + #[Apidoc\Title('检查服务器用户是否存在'), Apidoc\Method('GET'), Apidoc\Url('/users/server-bindings/check')] + public function checkServerUser(Request $request): JsonResponse + { + $validated = $request->validate([ + 'server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + ]); + + $server = ServerResource::query()->with('parent')->findOrFail((int) $validated['server_resource_id']); + $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; + $exists = $this->serverUserClient->userExists($targetServer, (string) $validated['username']); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => [ + 'exists' => $exists, + 'server_resource_id' => $targetServer->id, + 'username' => $validated['username'], + ], + ]); + } + #[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')] public function syncPermissions(Request $request, int $id): JsonResponse { @@ -536,27 +574,30 @@ class UserController extends Controller private function syncServerBindingsPayload(User $user, array $bindings, string $defaultPassword): void { - $seenServerIds = []; - foreach ($bindings as $binding) { $server = ServerResource::query()->with('parent')->findOrFail((int) $binding['server_resource_id']); $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; $serverId = (int) $targetServer->id; $username = (string) $binding['username']; - $seenServerIds[] = $serverId; $existingBinding = ServerUserBinding::query() ->where('user_id', $user->id) ->where('server_resource_id', $serverId) ->first(); $remoteExists = (bool) ($existingBinding?->remote_exists ?? false); - if ((bool) ($binding['create_remote'] ?? false)) { - $password = (string) ($binding['password'] ?? $defaultPassword); + if ($this->serverUserClient->userExists($targetServer, $username)) { + $remoteExists = true; + } else { + $password = (string) ($binding['password'] ?? ''); + if ($password === '') { + $password = $defaultPassword !== '' ? $defaultPassword : '123456'; + } $this->serverUserClient->createUser($targetServer, [ 'username' => $username, 'password_hash' => $this->linuxPasswordHash($password), 'primary_group' => null, - 'groups' => array_values(array_unique($binding['groups'] ?? [])), + 'groups' => array_values(array_unique(array_merge($targetServer->default_user_groups ?? [], $binding['groups'] ?? []))), + 'default_environment_variables' => (string) ($targetServer->default_environment_variables ?? ''), ]); $remoteExists = true; } @@ -569,6 +610,7 @@ class UserController extends Controller [ 'username' => $username, 'remote_exists' => $remoteExists, + 'force_password_change' => true, 'last_synced_at' => $remoteExists ? now() : null, 'metadata' => [ 'groups' => array_values(array_unique($binding['groups'] ?? [])), @@ -576,12 +618,28 @@ class UserController extends Controller ], ); } + } - ServerUserBinding::query() - ->where('user_id', $user->id) - ->when(! empty($seenServerIds), fn ($query) => $query->whereNotIn('server_resource_id', array_unique($seenServerIds))) - ->when(empty($seenServerIds), fn ($query) => $query) - ->delete(); + private function deleteServerBindingsPayload(User $user, array $unbindings): void + { + foreach ($unbindings as $unbinding) { + $server = ServerResource::query()->with('parent')->findOrFail((int) $unbinding['server_resource_id']); + $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; + $binding = ServerUserBinding::query() + ->where('user_id', $user->id) + ->where('server_resource_id', $targetServer->id) + ->first(); + + $username = (string) ($unbinding['username'] ?? $binding?->username ?? ''); + if ((bool) ($unbinding['delete_remote'] ?? false) && $username !== '') { + $this->serverUserClient->deleteUser($targetServer, $username); + } + + ServerUserBinding::query() + ->where('user_id', $user->id) + ->where('server_resource_id', $targetServer->id) + ->delete(); + } } private function linuxPasswordHash(string $password): string diff --git a/app/Http/Requests/StoreServerResourceRequest.php b/app/Http/Requests/StoreServerResourceRequest.php index 8970ac6..013a73e 100644 --- a/app/Http/Requests/StoreServerResourceRequest.php +++ b/app/Http/Requests/StoreServerResourceRequest.php @@ -20,6 +20,10 @@ class StoreServerResourceRequest extends FormRequest 'internal_ip' => ['nullable', 'ip'], 'user_api_base_url' => ['nullable', 'url', 'max:255'], 'user_api_token' => ['nullable', 'string', 'max:2000'], + 'default_environment_variables' => ['nullable', 'string', 'max:20000'], + 'all_user_environment_variables' => ['nullable', 'string', 'max:20000'], + 'default_user_groups' => ['sometimes', 'array'], + 'default_user_groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], 'asset_id' => ['nullable', 'integer', 'min:1'], 'account_id' => ['nullable', 'integer', 'min:1'], 'protocol' => ['nullable', 'string', 'max:64'], diff --git a/app/Http/Requests/StoreUserRequest.php b/app/Http/Requests/StoreUserRequest.php index 7afa294..bebc043 100644 --- a/app/Http/Requests/StoreUserRequest.php +++ b/app/Http/Requests/StoreUserRequest.php @@ -28,6 +28,10 @@ class StoreUserRequest extends FormRequest 'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'], 'server_bindings.*.groups' => ['sometimes', 'array'], 'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_unbindings' => ['sometimes', 'array'], + 'server_unbindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'server_unbindings.*.username' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_unbindings.*.delete_remote' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Http/Requests/UpdateServerResourceRequest.php b/app/Http/Requests/UpdateServerResourceRequest.php index 7220958..fb92228 100644 --- a/app/Http/Requests/UpdateServerResourceRequest.php +++ b/app/Http/Requests/UpdateServerResourceRequest.php @@ -20,6 +20,10 @@ class UpdateServerResourceRequest extends FormRequest 'internal_ip' => ['nullable', 'ip'], 'user_api_base_url' => ['nullable', 'url', 'max:255'], 'user_api_token' => ['nullable', 'string', 'max:2000'], + 'default_environment_variables' => ['nullable', 'string', 'max:20000'], + 'all_user_environment_variables' => ['nullable', 'string', 'max:20000'], + 'default_user_groups' => ['sometimes', 'array'], + 'default_user_groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], 'asset_id' => ['nullable', 'integer', 'min:1'], 'account_id' => ['nullable', 'integer', 'min:1'], 'protocol' => ['nullable', 'string', 'max:64'], diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index ad9df1d..a97c55d 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -31,6 +31,10 @@ class UpdateUserRequest extends FormRequest 'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'], 'server_bindings.*.groups' => ['sometimes', 'array'], 'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_unbindings' => ['sometimes', 'array'], + 'server_unbindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'server_unbindings.*.username' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_unbindings.*.delete_remote' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Models/ServerResource.php b/app/Models/ServerResource.php index dc35592..e3e5f6f 100644 --- a/app/Models/ServerResource.php +++ b/app/Models/ServerResource.php @@ -19,6 +19,9 @@ class ServerResource extends Model 'internal_ip', 'user_api_base_url', 'user_api_token', + 'default_environment_variables', + 'all_user_environment_variables', + 'default_user_groups', 'asset_id', 'account_id', 'protocols', @@ -62,6 +65,7 @@ class ServerResource extends Model { return [ 'protocols' => 'array', + 'default_user_groups' => 'array', 'user_api_token' => 'encrypted', 'allow_copy_temp_password' => 'boolean', 'is_active' => 'boolean', diff --git a/app/Models/ServerUserBinding.php b/app/Models/ServerUserBinding.php index 5e4f8ed..72fe8fc 100644 --- a/app/Models/ServerUserBinding.php +++ b/app/Models/ServerUserBinding.php @@ -15,6 +15,7 @@ class ServerUserBinding extends Model 'server_resource_id', 'username', 'remote_exists', + 'force_password_change', 'last_synced_at', 'metadata', ]; @@ -33,6 +34,7 @@ class ServerUserBinding extends Model { return [ 'remote_exists' => 'boolean', + 'force_password_change' => 'boolean', 'last_synced_at' => 'datetime', 'metadata' => 'array', ]; diff --git a/app/Services/ServerUserManagementClient.php b/app/Services/ServerUserManagementClient.php index ceff982..6f241de 100644 --- a/app/Services/ServerUserManagementClient.php +++ b/app/Services/ServerUserManagementClient.php @@ -20,6 +20,23 @@ class ServerUserManagementClient return $this->request($server, 'get', '/users/'.$this->encodePath($username))->json(); } + public function userExists(ServerResource $server, string $username): bool + { + try { + $this->user($server, $username); + + return true; + } catch (ValidationException $exception) { + $errors = $exception->errors(); + $messages = collect($errors)->flatten()->map(fn ($message): string => (string) $message); + if ($messages->contains(fn (string $message): bool => str_contains(mb_strtolower($message), 'not found') || str_contains($message, '不存在'))) { + return false; + } + + throw $exception; + } + } + public function createUser(ServerResource $server, array $payload): array { return $this->request($server, 'post', '/users', $payload)->json(); @@ -37,6 +54,25 @@ class ServerUserManagementClient ])->json(); } + public function userEnvironment(ServerResource $server, string $username): array + { + return $this->request($server, 'get', '/users/'.$this->encodePath($username).'/environment')->json(); + } + + public function updateUserEnvironment(ServerResource $server, string $username, string $content): array + { + return $this->request($server, 'put', '/users/'.$this->encodePath($username).'/environment', [ + 'content' => $content, + ])->json(); + } + + public function updateAllUserEnvironments(ServerResource $server, string $content): array + { + return $this->request($server, 'put', '/users/environment', [ + 'content' => $content, + ])->json(); + } + public function groups(ServerResource $server): array { return $this->request($server, 'get', '/groups')->json(); @@ -99,6 +135,7 @@ class ServerUserManagementClient $response = match ($method) { 'post' => $pending->post($path, $payload), + 'put' => $pending->put($path, $payload), 'patch' => $pending->patch($path, $payload), 'delete' => empty($payload) ? $pending->delete($path) : $pending->send('DELETE', $path, ['json' => $payload]), default => $pending->get($path), @@ -110,10 +147,7 @@ class ServerUserManagementClient } if (! $response->successful()) { - $message = (string) (data_get($response->json(), 'message') - ?: data_get($response->json(), 'detail.message') - ?: data_get($response->json(), 'detail') - ?: '服务器用户管理 API 返回异常'); + $message = $this->errorMessage($response->json()); throw ValidationException::withMessages([ 'server' => [$message], @@ -136,4 +170,63 @@ class ServerUserManagementClient { return rawurlencode($value); } + + private function errorMessage(mixed $payload): string + { + if (! is_array($payload)) { + return '服务器用户管理 API 返回异常'; + } + + $message = data_get($payload, 'message') + ?: data_get($payload, 'detail.message') + ?: data_get($payload, 'detail'); + + if (is_string($message) && $message !== '') { + return $message; + } + + if (is_array($message)) { + $messages = collect($message) + ->map(function (mixed $item): string { + if (is_string($item)) { + return $item; + } + + if (is_array($item)) { + return (string) (data_get($item, 'message') + ?: data_get($item, 'msg') + ?: data_get($item, 'detail') + ?: json_encode($item, JSON_UNESCAPED_UNICODE)); + } + + return ''; + }) + ->filter() + ->values(); + + if ($messages->isNotEmpty()) { + return $messages->join(';'); + } + } + + $failedUsers = collect(data_get($payload, 'failed_users', [])) + ->map(function (mixed $item): string { + if (! is_array($item)) { + return ''; + } + + $username = (string) data_get($item, 'username', ''); + $error = (string) (data_get($item, 'message') ?: data_get($item, 'code', '')); + + return trim($username.($error !== '' ? ': '.$error : '')); + }) + ->filter() + ->values(); + + if ($failedUsers->isNotEmpty()) { + return '部分服务器用户环境变量设置失败:'.$failedUsers->join(';'); + } + + return '服务器用户管理 API 返回异常'; + } } diff --git a/database/migrations/2026_06_16_063017_add_password_change_and_environment_variables_to_server_tables.php b/database/migrations/2026_06_16_063017_add_password_change_and_environment_variables_to_server_tables.php new file mode 100644 index 0000000..33e54ee --- /dev/null +++ b/database/migrations/2026_06_16_063017_add_password_change_and_environment_variables_to_server_tables.php @@ -0,0 +1,42 @@ +boolean('force_password_change') + ->default(true) + ->after('remote_exists') + ->comment('是否要求首次使用服务器资源前修改服务器账号密码'); + }); + + Schema::table('server_resources', function (Blueprint $table) { + $table->text('default_environment_variables') + ->nullable() + ->after('user_api_token') + ->comment('新建服务器用户时写入.bashrc的默认环境变量'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_resources', function (Blueprint $table) { + $table->dropColumn('default_environment_variables'); + }); + + Schema::table('server_user_bindings', function (Blueprint $table) { + $table->dropColumn('force_password_change'); + }); + } +}; diff --git a/database/migrations/2026_06_17_135300_add_all_user_environment_variables_to_server_resources_table.php b/database/migrations/2026_06_17_135300_add_all_user_environment_variables_to_server_resources_table.php new file mode 100644 index 0000000..4b78c0a --- /dev/null +++ b/database/migrations/2026_06_17_135300_add_all_user_environment_variables_to_server_resources_table.php @@ -0,0 +1,31 @@ +text('all_user_environment_variables') + ->nullable() + ->after('default_environment_variables') + ->comment('批量设置所有服务器用户环境变量时保存的上次编辑内容'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_resources', function (Blueprint $table) { + $table->dropColumn('all_user_environment_variables'); + }); + } +}; diff --git a/database/migrations/2026_06_17_141450_add_default_user_groups_to_server_resources_table.php b/database/migrations/2026_06_17_141450_add_default_user_groups_to_server_resources_table.php new file mode 100644 index 0000000..3ba27bd --- /dev/null +++ b/database/migrations/2026_06_17_141450_add_default_user_groups_to_server_resources_table.php @@ -0,0 +1,31 @@ +json('default_user_groups') + ->nullable() + ->after('all_user_environment_variables') + ->comment('新建服务器用户默认加入的用户组'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_resources', function (Blueprint $table) { + $table->dropColumn('default_user_groups'); + }); + } +}; diff --git a/tests/Feature/ServerSystemUserManagementTest.php b/tests/Feature/ServerSystemUserManagementTest.php index ebf90a8..71bcb91 100644 --- a/tests/Feature/ServerSystemUserManagementTest.php +++ b/tests/Feature/ServerSystemUserManagementTest.php @@ -51,6 +51,7 @@ class ServerSystemUserManagementTest extends TestCase { Http::preventStrayRequests(); Http::fake([ + 'http://node.test/users/alice' => Http::response(['detail' => 'not found'], 404), 'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201), ]); @@ -78,17 +79,83 @@ class ServerSystemUserManagementTest extends TestCase 'server_resource_id' => $server->id, 'username' => 'alice', 'remote_exists' => true, + 'force_password_change' => true, ]); Http::assertSent(function (Request $request): bool { return $request->method() === 'POST' && $request->url() === 'http://node.test/users' && $request['username'] === 'alice' + && $request['groups'] === ['dev'] + && $request['default_environment_variables'] === 'export A=1' && is_string($request['password_hash']) && str_starts_with($request['password_hash'], '$6$'); }); } + public function test_binding_existing_remote_user_marks_password_change_required(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users/alice' => Http::response([ + 'username' => 'alice', + 'uid' => 1000, + 'gid' => 1000, + 'home_dir' => '/home/alice', + 'shell' => '/bin/bash', + ]), + ]); + + $admin = $this->admin(); + $server = $this->server(); + + $response = $this->actingAs($admin, 'api')->putJson('/users/'.$admin->id, [ + 'server_bindings' => [[ + 'server_resource_id' => $server->id, + 'username' => 'alice', + ]], + ]); + + $response->assertOk()->assertJsonPath('code', 0); + $this->assertDatabaseHas('server_user_bindings', [ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'remote_exists' => true, + 'force_password_change' => true, + ]); + } + + public function test_unbinding_can_delete_remote_user(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users/alice' => Http::response(['status' => 'ok']), + ]); + + $admin = $this->admin(); + $server = $this->server(); + ServerUserBinding::query()->create([ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'remote_exists' => true, + 'force_password_change' => true, + ]); + + $response = $this->actingAs($admin, 'api')->putJson('/users/'.$admin->id, [ + 'server_unbindings' => [[ + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'delete_remote' => true, + ]], + ]); + + $response->assertOk()->assertJsonPath('code', 0); + $this->assertDatabaseMissing('server_user_bindings', ['user_id' => $admin->id, 'server_resource_id' => $server->id]); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'http://node.test/users/alice'); + } + public function test_deleting_sso_user_does_not_delete_remote_server_user(): void { Http::fake(); @@ -141,6 +208,187 @@ class ServerSystemUserManagementTest extends TestCase $this->assertSame('admin', $resourcePayload['server_username'] ?? null); } + public function test_resource_use_requires_password_change_for_bound_account(): void + { + Http::fake(); + $admin = $this->admin(); + $server = $this->server(); + $resource = ServerResource::query()->create([ + 'parent_id' => $server->id, + 'name' => 'ssh', + 'display_name' => 'SSH', + 'internal_ip' => '10.0.0.10', + 'asset_id' => 1, + 'account_id' => 2, + 'protocols' => ['SSH'], + 'is_active' => true, + ]); + ServerUserBinding::query()->create([ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'remote_exists' => true, + 'force_password_change' => true, + ]); + + $response = $this->actingAs($admin, 'api')->postJson('/servers/'.$resource->id.'/use', [ + 'account_name' => 'alice', + 'password' => 'secret', + 'protocol' => 'SSH', + ]); + + $response + ->assertStatus(423) + ->assertJsonPath('data.reason', 'server_password_change_required'); + } + + public function test_bound_password_update_requires_strong_password_and_clears_flag(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users/alice/password' => Http::response(['status' => 'ok']), + ]); + + $admin = $this->admin(); + $server = $this->server(); + ServerUserBinding::query()->create([ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'remote_exists' => true, + 'force_password_change' => true, + ]); + + $weak = $this->actingAs($admin, 'api')->patchJson('/servers/'.$server->id.'/bound-system-user/password', [ + 'password' => 'abcdef', + ]); + $weak->assertStatus(422); + + $strong = $this->actingAs($admin, 'api')->patchJson('/servers/'.$server->id.'/bound-system-user/password', [ + 'password' => 'Abc123!', + ]); + $strong->assertOk()->assertJsonPath('code', 0); + + $this->assertDatabaseHas('server_user_bindings', [ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'force_password_change' => false, + ]); + } + + public function test_server_environment_endpoints_proxy_to_user_management_api(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users/environment' => Http::response(['status' => 'ok', 'updated_count' => 2]), + ]); + + $admin = $this->admin(); + $server = $this->server(); + + $default = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/default-environment', [ + 'content' => 'export DEFAULT=1', + ]); + $default->assertOk()->assertJsonPath('data.default_environment_variables', 'export DEFAULT=1'); + + $all = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/system-users/environment', [ + 'content' => 'export ALL=1', + ]); + $all + ->assertOk() + ->assertJsonPath('data.updated_count', 2) + ->assertJsonPath('data.all_user_environment_variables', 'export ALL=1'); + + $this->assertDatabaseHas('server_resources', [ + 'id' => $server->id, + 'all_user_environment_variables' => 'export ALL=1', + ]); + } + + public function test_user_management_api_error_message_is_extracted_from_detail_array(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users/alice/environment' => Http::response([ + 'detail' => [ + ['message' => 'permission denied'], + ], + ], 422), + ]); + + $admin = $this->admin(); + $server = $this->server(); + + $response = $this->actingAs($admin, 'api')->getJson('/servers/'.$server->id.'/system-users/alice/environment'); + + $response + ->assertStatus(422) + ->assertJsonPath('errors.server.0', 'permission denied'); + } + + public function test_server_environment_endpoint_returns_partial_failures(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users/environment' => Http::response([ + 'status' => 'ok', + 'message' => 'User environments updated.', + 'updated_users' => ['alice'], + 'failed_users' => [ + ['username' => 'bob', 'code' => 'system_permission_denied', 'message' => 'permission denied'], + ], + 'updated_count' => 1, + 'failed_count' => 1, + ]), + ]); + + $admin = $this->admin(); + $server = $this->server(); + + $response = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/system-users/environment', [ + 'content' => 'export ALL=1', + ]); + + $response + ->assertOk() + ->assertJsonPath('data.updated_count', 1) + ->assertJsonPath('data.failed_count', 1) + ->assertJsonPath('data.failed_users.0.username', 'bob'); + } + + public function test_default_user_groups_are_saved_and_used_when_creating_system_user(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201), + ]); + + $admin = $this->admin(); + $server = $this->server(); + + $save = $this->actingAs($admin, 'api')->putJson('/servers/'.$server->id.'/default-user-groups', [ + 'groups' => ['dev', 'ops', 'dev'], + ]); + $save + ->assertOk() + ->assertJsonPath('data.default_user_groups.0', 'dev') + ->assertJsonPath('data.default_user_groups.1', 'ops'); + + $create = $this->actingAs($admin, 'api')->postJson('/servers/'.$server->id.'/system-users', [ + 'username' => 'alice', + 'password' => 'secret123', + 'groups' => ['extra'], + ]); + $create->assertCreated(); + + Http::assertSent(function (Request $request): bool { + return $request->method() === 'POST' + && $request->url() === 'http://node.test/users' + && $request['username'] === 'alice' + && $request['groups'] === ['dev', 'ops', 'extra']; + }); + } + private function admin(): User { $user = User::factory()->create(); @@ -158,6 +406,8 @@ class ServerSystemUserManagementTest extends TestCase 'internal_ip' => '10.0.0.10', 'user_api_base_url' => 'http://node.test', 'user_api_token' => 'secret-token', + 'default_environment_variables' => 'export A=1', + 'default_user_groups' => ['dev'], 'asset_id' => 1, 'account_id' => null, 'protocols' => [], diff --git a/user_manage_api/app/__pycache__/__init__.cpython-310.pyc b/user_manage_api/app/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..acf55e0200687f2d6367ee40317d644270a7eb7d GIT binary patch literal 155 zcmd1j<>g`k0xw5_EJh&x7{oyaAVCKpE@lA|DGb33nv8xc8Hzx{2;!HWer{fgeu{oT zQGQlxa!Ij%fJcD7Q(|#RW`169u)lt3acWU~Zem_ydTM-PL8g9UL4kgJd}dx|NqoFs YLFFwDo80`A(wtN~kh#T7K!Sw<07pk6!~g&Q literal 0 HcmV?d00001 diff --git a/user_manage_api/app/__pycache__/container.cpython-310.pyc b/user_manage_api/app/__pycache__/container.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d66b1384fe96f357c10bd217903ded3890ce4c26 GIT binary patch literal 238 zcmYjKyAHxI40KY20P&jngDzxYtbj5xL8Xd?C5oB|6}3r}qywMC7a?_J;un~3J8_cF zKHteUv)Kd?uXK6eV1IYw9}$W@mR})VK!PL{)Z|JCD59vO3CJjic<@0VV$zOMYx5w2 zYRAG_?z{06N4;A2%27c?YjJ;@BJ|B%ZXWQV#~^YS z6hjQBD8oaXks-n6PEwjVL&w@Qt!3`ewYHObnLqTcT}$g(W7q)O<#$o?K*IDDtfjBuv0Ik_OTHbq8<*HiUlU%I6jPv*{Uj>XuwTIza zxCW?eYOGSOy#p>Jz|%O=lz&Ks53MBx)a)3$2J#g|u7LW46*|WI7=(aOhQAO-&IuHz zy(^-fq+pOZ<5EIZa5SZgRVAZ|KPhs>a}_wkWEE|r=8akabwxze*ezotaT~sVJnCk- z>auQM6#G0;vfF>w?{2}&pzXn6r&|HUD61yNEDv=Xd=WR(i{8aK zOSzbXtkMBRK6c2_{Y3Csad=vW>uHh1soV(vdh-`0F1)vt+y%o>Vy3BplnZi+PBEiE ziepKs^~RquLb^T956n76Wh~;1!_wGJ#3CdJsAWS_sf2cIw^H1KfTduf?!Z-dYzn)P zzk@P7PC>1ZqX+*2^}nZqFU*kj{5Vh2it%W&(!xO0ZCDCk8m?#b3A4t+?DJ%ThRJ|x z3IvK}<26h2XWxxq4b>qm&BR-{fql}!KJ8$KihJPd1ugB3V74#-+mx-Nm!_`px)~`< zS>2um#PlC2Cp^xZX+nfgo5cWDv>LhmUzAdzrZz!*nOLBjO5FbJygrHKy#TUzXr^%LG+uF7Jwr9@$ zA>8;qd*#H13kM`l%xn@GL9FM=&NH*)nH_HxMFS6iKR)lB>~}ry3mNOb7>&mW^9_l6 zoJmiylrdWS(pR0dHOvwJdBQ5w1Rrrc6- z8Z!^NAH6hz9wZ+zI}9N?*+%@sg9|_1fdTCB{sl{SA%b1huOP10uQ8kyh#`TyaG!@49bnlZu#>~|5CW{@0cTTh6n({> zy*DFfhfi8An%C8A272V%U0CGSGipXw zGuP>;V}sSP+D%MI=VH4#&0_SgjqrTOcmho>N<7s5<#KD`(J*$g+Q%{4__v6G$6}^C zsOk-J4as$!7dVP@jkmzF2c)bH(u@l!vX!NKv~QE5Zd25zYci-<0vPLOP}Hl>W_VYk;OwUBl$hvRB;al4n|qDH4C zYg2QZ^;LS;9??G0HKH3t2ShiKTtk_mhHM9W`{d~{`G|i0H&JCX;S>DUPoQYb8qv45s|6T%25nX3kdNmh%JTP~R{ z)=qteOeO6@8UiFgQWE)*kU2YB0m|?F#p5Ld_(Q>ai7>c9bzjgZP^^WHm5dP%+Mo-| zaK>R9b#WQbIBJtFEmH=X-GYi$@(Wb*96YmOP{;P%;H&@Blj0kzza0G5P>~ zf>ex1Cwkv~+RIE#nY@|HCY3O`Z5|+07^XN8mCv8Dv3*^n&mM`v-zl Bnxg;! literal 0 HcmV?d00001 diff --git a/user_manage_api/app/api/__pycache__/__init__.cpython-310.pyc b/user_manage_api/app/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..887b3092ff070ded315345c92f55202a9d806ba1 GIT binary patch literal 159 zcmd1j<>g`kg1JrtS&TsXF^Gc2CIV1ND6;?$z}+{C=Z^wjvof=vCyf&w7U)Q^wP%*!l^ bkJl@xyv1RYo1apelWGStyO;?`urL4s6S^dY literal 0 HcmV?d00001 diff --git a/user_manage_api/app/api/__pycache__/deps.cpython-310.pyc b/user_manage_api/app/api/__pycache__/deps.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b1f0bae0feeeb63ee3dd43ee5ce7afaec729622 GIT binary patch literal 1075 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 List[Dict]: return [item.model_dump() for item in container.app_state.service.list_users()] +@router.put("/users/environment", response_model=UserEnvironmentBatchResult) +def update_all_user_environments(payload: UserEnvironmentUpdateRequest, request: Request) -> UserEnvironmentBatchResult: + identity = caller_identity(request) + result = container.app_state.service.set_all_user_environments(payload.content) + container.app_state.audit.log(operation="update_all_user_environments", target="*", result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return result + + @router.get("/users/{username}") def get_user(username: str) -> Dict: return container.app_state.service.get_user(username).model_dump() +@router.get("/users/{username}/environment") +def get_user_environment(username: str) -> Dict: + return {"username": username, "content": container.app_state.service.get_user_environment(username)} + + +@router.put("/users/{username}/environment", response_model=ApiResponse) +def update_user_environment(username: str, payload: UserEnvironmentUpdateRequest, request: Request) -> ApiResponse: + identity = caller_identity(request) + container.app_state.service.set_user_environment(username=username, content=payload.content) + container.app_state.audit.log(operation="update_user_environment", target=username, result="success", request_id=identity["request_id"], source_ip=identity["ip"]) + return ApiResponse(message="User environment updated.") + + @router.post("/groups", response_model=ApiResponse) def create_group(payload: GroupCreateRequest, request: Request) -> ApiResponse: identity = caller_identity(request) diff --git a/user_manage_api/app/core/__pycache__/__init__.cpython-310.pyc b/user_manage_api/app/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58cb96a0e175411dcdb3c195fb59111feabc3dce GIT binary patch literal 160 zcmd1j<>g`kg2he(S&TsXF^Gc2CIV1ND6;?$z}+{C=Z^wjvof=vCyf&%^I{GwF-`1s7c d%#!$cy@JYH95%W6DWy57b|AxxnScZf0{}uHB>MmW literal 0 HcmV?d00001 diff --git a/user_manage_api/app/core/__pycache__/audit.cpython-310.pyc b/user_manage_api/app/core/__pycache__/audit.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6320be5df83f25253f6c74d0227ce91f2d010c92 GIT binary patch literal 2001 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 a+fVVgA*W~o{te1%CagLSYtF}xef=A;w15`? literal 0 HcmV?d00001 diff --git a/user_manage_api/app/core/__pycache__/errors.cpython-310.pyc b/user_manage_api/app/core/__pycache__/errors.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e9cad0c2ecac98e6528faa59bee2a7841daadaff GIT binary patch literal 1349 zcmZ8hy>Hw$6elI!S$94=KHK@~Qo)@rgH>b>(xyewOs;_gGTcH4dc1^*zDiO_PEe*8 z$Uo4fC<@q1kZk>XxMu3osX%~Cc~3egm-4`$9^a3AUwpiBXJ^cC{QLIylOF=c-qT`p zjBxQIPV+s&FANy)Gn|sW@)sP&;7bnz>B9iOL&=vRY(aQ7SOhSdv)Oivr@k7)^~&5r zK{B+#79QI3!IJ@63zoyzY{?;j!P#Unc+CQ~7^0=23-49xzSgC7+r}ogHXSSw_&ou?{xaRsNaPvU-T`9 zy{Ddj(CMeJPrgt-fWv-I^|1B+WN@|Rh}ZrKh@V|Qaje-*GA$EWMVPj+M*V@Ma#Jl$ zS?g3{9g1b9Qrql8DNR(Aw%^!{NL-e60qS=;LTk~jo2fEUT5Bx}o2?>j5oo?mlY$Ot znU~LGWFO0@(kf5%Dte^Lx>9e*#zCbeIs`@SC6+8R^_s+ZF6(})pbXthX|Qt^&go#T$@{8x#$|WxgPaVM9-CdOqpBk z7m$(T$1~pzvhq3Z!{ZF!o7JkC1^NJ2`jFr%>BmK>^CVM^gpKZ`#&pv>sj$FZ`}O|l zpUT`onDEfsrI1f}#PxSbp3J!3$1lF}(ft>xtf*XF6nYQU`U(MU+;G@EjLvI# Qr+-;@DeUe|?6D~P1bH@PlK=n! literal 0 HcmV?d00001 diff --git a/user_manage_api/app/core/__pycache__/models.cpython-310.pyc b/user_manage_api/app/core/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8dc033987861a1d602b6496578b8f9cc0422e41b GIT binary patch literal 3706 zcmai0OLH5?5#HH1UIa)&6sgz=?buEsi457!%Z`#FCDV!%31UdmNsv;uR+s^~_CBE5 zg<~?*CG^$5Amu8%Qk5$Hqtsk;;+eNxlCKAl0D^=K)b>vI%8j}8_Wit{NEtl;4C&0)@6y=H503AF_M|Foj6@5al3Bfb-l#z`jA`Q5}xus zgWKHs+~AIIPONSPyvsfCp609IeO>}z(tHhknODG9G`|48%4^_jny-Uj;C1kI%{Ra| z_#*g4%`bvq;>+NdHNOPD$*+OGruk*?EBre6>zZ%!)m@{tb_VN=mZj|bQC=wTepCn< z#>#&qy6`zUZI>1A`x>{@6#*zG>ju27MVO+{mY-j zn@7Q8qPK3o8{B;Sw_87c=dEY2_2{VSp9f|GPyR0uVRRWcy5^oCEY7(31?$?v5iYkd z@`Nu++`erHhdTu07iPDtWxke`M1_|jtBRWN*9@$!kX$VADt6aJV=S%d)qs&VuzFE{ zVG*(=UOQo8nJ;e5l2E}UcQYcs!5xZFIp{$YG+p0K~Or=??a*EqF0%9P@$`C+PK zW?k#MP6)lXe(EVZra-hgg_z%$S@MZE%vCuEqGXWCA_$a|ghlT_E#BLCxVPQee6SU4 zZ$5gowbN1MkH~^8DKmL$E2|gh^1D!$FyzC5;K~`nX2>?g|J`gSY0>8GZJB*6dPUye z{>^q9!A_CCyZf+Bo(qyN4fjP54x)BA7_@tt6zznPA%AmlsOsam7bAz{g!6e7WWn-T zgDo)gf3|t_%B7Hbb7o)bn(~8yK!AZT7AFj28drnh=`f5(Gtwn%s`~h#r&Cc<5~;@C zF6^IzLN1Wl$_uILK@g^CR)l0$9t5(9DcK^jLWF!PUn80$^eF25tKp$aDb^HfK74 zZElX=7Ty-*R0Qbl+(tb`LVBt~= zCq#ZqNVa#1!!%3G$q2buDu7{p;uD4Wt)*C+Ws zOw6sfa-BZ)3X$&<`4N!`PC7s-F?CqKjlt0m=IAzCbULlttWR`MP3pkJMIX|s77_A- zq#iK89Bg5hG#uy43M%2a4A9IauTe~H;5mO4WY%lw|K|86fYj#>(0A1uS-3rJl^5`r zb$mlMh%6GJf^Uzr%Kl=Be!v5oRXFyt!ODSo_qSn=y2R3FuWoDl!H%C()=JNMYN?r$1wV|#BJQ%|NxvKU7ml$)BD#K(&FB_)CixrydwCb}6 zKB7-gVANr&d-Wbom_$AzLa50<5ZNH|M)_Jl z7Kei<-5()AA+E18${!r^FfF2 ApiError: +def map_command_error(stderr: str, exit_code: int, command: str = "system command") -> ApiError: normalized = (stderr or "").lower() if "already exists" in normalized: @@ -19,4 +19,4 @@ def map_command_error(stderr: str, exit_code: int) -> ApiError: 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.") + return ApiError(500, "system_command_error", stderr.strip() or f"{command} failed with exit code {exit_code}.") diff --git a/user_manage_api/app/core/models.py b/user_manage_api/app/core/models.py index 86d7f43..ab46aed 100644 --- a/user_manage_api/app/core/models.py +++ b/user_manage_api/app/core/models.py @@ -14,6 +14,7 @@ class UserCreateRequest(BaseModel): 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) + default_environment_variables: str = Field(default="", max_length=20000) @field_validator("groups") @classmethod @@ -61,6 +62,25 @@ class UserPasswordUpdateRequest(BaseModel): password_hash: str = Field(min_length=10, max_length=512) +class UserEnvironmentUpdateRequest(BaseModel): + content: str = Field(default="", max_length=20000) + + +class UserEnvironmentFailure(BaseModel): + username: str + code: str + message: str + + +class UserEnvironmentBatchResult(BaseModel): + status: str = "ok" + message: str + updated_users: List[str] = Field(default_factory=list) + failed_users: List[UserEnvironmentFailure] = Field(default_factory=list) + updated_count: int = 0 + failed_count: int = 0 + + class ApiResponse(BaseModel): status: str = "ok" message: str diff --git a/user_manage_api/app/providers/__pycache__/__init__.cpython-310.pyc b/user_manage_api/app/providers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48c8fe266ea23a8bf7cce40940509feb86db4c47 GIT binary patch literal 165 zcmd1j<>g`kg5^#ES&TsXF^Gc2CIV1ND6;?$z}+{C=Z^wjvof=vCyf&%@5qWrSVl+>bP i{rLFIyv&mLc)fzkTO2mI`6;D2sdgY6ikW}}3j+Wn9Va;e literal 0 HcmV?d00001 diff --git a/user_manage_api/app/providers/__pycache__/base.cpython-310.pyc b/user_manage_api/app/providers/__pycache__/base.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc7429edeecebfff46b3b6150f7b5f997594b737 GIT binary patch literal 3119 zcmbtWOK;mo5avr1^|EAJe#mwlJ9Zw*!^)wD9*n?EQlte6$AHm`E(Fb8TT1y*mXrhc z*!Y+9roXh;o_gsex1KsPBo&61TcKHi!`a#4d^5ANvkV)JssiKsujlVfOHuy7Nq!hG zIf5s84TLF7^%P(2tG?FPRGe#`?i+o>FZD|_*FDoO_shQ3w^Zeg!VFfrR#=IvAN78P znWsv({3lpax|-C#d-+N(IA>8T95?oPJO~+xjh}{5EUmYrco+tb2a@_55spWvW8Zhg zm8|_7agk13G?gEVFgb!J`dw8NuJl!=^fj(Cl^aZhN9QGObil3j4Krq@e3_fHX0gh7 zi&a_e+W4sU%jwrBHCUZBNNVM5;)=-@Sd&&NuwsFwYDYQOSc@%^w3b=nRj{(emPuL% zX&XF9Y_G6Yk~Y4OuCaBJE~IvwWVgdMNZQJDc@ykzvMrJ>f)pdpBD%wu2*YCA>@I06 z7c`O>cGxbhw$oLN1F{8{@3B2vS(&aFAl+y8NxGU!AEdFE>>+zZD{D7ay6iEntg{Lf z!xOo1dKJaoKM~>OkZ~ccF>G((_*^zdP859z1+xcEG?3OH^tsK3LN>i&@CyfVCMZP% z?s@Qm7-EUo7g#USq*o+O!Q-(Aj$Nql=|CPzM0o|zCoJ+<`Awau#}id9ABXXe{>bA# z4`R-~7a|m0OBxaP&P4@O1s)kuLsCc5K(c_O31p&(Re1gVy5|RRkM$t3cifGm-pLOq zy_ZgeWqo@3wuf=rz7seX+;&F8o--QtMrjtJ-kB5egAr7_E4UML8@18rPZ|(I`*q{X zv>oKsTvW*h@>!e$Pls4SvJ7ORi5282By^el7Hh!C8si>MVJXHANN<4Vlw2JYvVr_9 zpj|x&N_i|Gx7~pgKuRb*Sp~Gfpdc6aigC}-b`d)p$lv12L`{4tcu@7A0TF)GhL026 zP679`&b;Xs8)&S6WnJ(DYJG~dRzS)$WQFP(kH)-^*$(f_K(411Lq@lz=$i%fyw<$1 zVs{o^S}cS|4$*!V@$y>p?xVOf1FuFql(c~TX?VqBlT-St=qk8N1omi(wO&Z&R7I8r zA9;?;U2HtrK>n6O?Haj5gFQw(KvJlsro&irY?7#-q7Ef_yWmMd@qe9Na--nElHh)L znV#LmiRV)%b_-72)SY_=W}T#Fc21IQh`yNu7o#sIeiiBpbmuhbJh&W+Fu+UW+bQ<` zji6_!%d#87O%ufPZp0+tM4up7VI#Ibh#_=u#?PIxmoykV=ilMfzh?#i{{6do{S#Dr zvwcc#yqh^Y`VXIO`>xxOmThBKv~5|nZ9imV5Ba)nzaKkZvLf(Gk~;iqiV_0hVH4DI zH<6?MS4M6d$z3G(knABbkx&o051cg4!q5{BQAmfqi`-)*&yXA-`5ZfOc&F0!jTq4j zAeN%3hpMKx;aN}L$QPd-kZt93Pmk|JnGsiYL4eA`K~8Mq5-x+pL;`zRi|Z zwfJpUZM?0P({`(FJ5$ZHy{gA$>{hm&tLEDIYM#HHR-rvpooN@VMgDeMv+cR+oG`8% zG9$eQhV=H$>O8)(GKa66KeumHOVYe)RPs+S4x?gf`$j$5)-yMH%D>SGn|JARy<%zm z!)6$1@8fRN>;(0ewr=_nihJ*Mo7a@;C_Uesw2hpP!LgJUQR%KB9cxzqTb+r|04?WdL7`hNfs@bhU65U>; zOFRi5nspZqH#*V}wX6K7r-Fuq4u-!h5@dNSAdN&ULN9_~q$#a^>%NGEgkB7s*oloD zaYv|S&UK_4J3ll2#^f^27M06L;|J!FancyLv8mobc~g2@X6zEDKNDFycI?8IvdD2{ zPUasvgUoFsSg;M`x%WO2#sHSu&!7kBpDgZMv3DQJG4Q_h_QakMXLd5u-nCSNQnB6r zH1hThjHwZ2!O1b#;SpJC2VePv!Xu~_rAUw0oBtso;Mrzyr`~GHq4o8P*V^6aUO6?! z6@@~Ep5N@Kji!`-Fe@LL$>K!n%7@SYRi~{fEqRYFw~an z1&R6=ZzcA4?QXZJe5rG()S&{}3jIiVRM!n7KnP3;Z?vu*)}BgM0elVhB9WJfyiDXg zh|Z+TqwUQeObyBu_F9q7`ga>XgB)8%LSCR+Z-UI@38}g*3c?dPQNo`M)i%Yln8Vxq z%KUoSngcHGWN^>ksLtD)t zQs-z0$t}9#npFPR`0X)CBaR1aWLR zFH7L$M{N<;l_%r^xG)sdo>?-`=W}uqoMKYbpFz!&@)S6;a%t4ovV0z#xiQXZc?O($ zZl}cUoR!}K=R{J*FQJ{Xd;y#VZh1J`bN+KYK2N?VUqbHUL~b(1m*sinoJ>c=vqWpJ z$XCHVm2xxSROD;mEHUTxbgZs?LtX%P`3QGKu7dmg5$;9#Cb*~NTd2(~_!+FxFUhyT zJtK=S|8ML0_gl?LAKEX!SiW-QO8F4F%YJaDsX9R$4u-O*#O2GEpH%``+VfMl*dSLo z{85@7MplB@#PyM@do0!~LQzJ=Obm|FV>-#|cn-YR@r2Y$PV|?jMn7@~>;@FsfSzG5 z*HN;zT@Salx7~rskkgzcR|$tOVmRTp4?~{mswPDnHFk^et>hg;^$I#&ZxB`tf8;92 zfOtaxf&@8<*pXIjZaD)lw$*Ix9T?JnB!m$-oOUQ3Tj~tH1YCx-WirQA=b4j<-ISA2 zZ!yP{*#k4qJQ67%EyjZD7(3R19ebR%)#qq+1C9hdxDqS2x2(fbGA|2QZhOlfmc)WMCA|OcpPAN`v6d$E)Iy7dlPuP&NW@06DB96$8&3$p;Jc1ue^Gt}Jg*sYJYh5j&9kmEjaS>(t5l=}4JL_?V3W z6(yB6G24-nR4^L~T-AplkIga3j*pflTc%=6c(eI>;c;&vVw3hmb0D@1NKPD>kBkqD&t4x`u>~2z6bClsXYa!h zcOkzo=3?3@xfHW~FEelk?jSSpK(m8fY#ulfBD^RQXZ8iCw=d#soRgV{Y|^MpyC82} zJ%=%9HwmrR9t(~do{CFPGT=XU6v2o5iM{H?j`eef5$x(#Q z!r}c=SVA_mh%5^a#V637HiX zmBY95NI&^06i8GRLW3s`5wZ^Tdmt6}P*h0> zx$qk&MU`ywIN>UEc+sIYZD7i6C5Je&k2-= zwmU>ALLzJ@)oJ9Z-vPmLFtU6>jPi)E9u!hwtQ>rDD~6A#OA3lsRv9l+Zt z;MY&gFMu(J%l6pR=E_Cww*B@7wk~DVN(e7!?lPGr_9)#eJJcQv(dfT9Ekrh79$mO{ zxz%aZTj3QXhPBlVRDWzK)|JnZ)|sToa2&H#9XV=)NP`GN6-7u1ROJ$VV{2gNzDZeR z7uqI$R)h};Kz&8be9}dYMsajz`LO|0hasu_I8rUZ`;kQ*=l{m)PADvyI6bU7EhG+A z=l=^RM-hmO}2$xH1Lp@FD2NPqr1 zg`ra*^-TRy_o!-lMEau?^GHS103@WummvMsY0V(uVZ}IBF9NDc%E3sf{s5_GK;xkv zIAgeT2#sG+Bjpic{mqU#r+z}(cMehV6r|gK{u_p?Gs^F_>J7g^Ha7f`ct`%|HGCjq zwxtLV;tcE*G7&dR8}T**xQU?0SyvyUM&G(nzn+9YG;suIq%X-1`X({b`CTI}>-i6m z(pl`z{n}=$zIAk#vp9B30;0qK@1S@{gD!}(qW{A59FLTrh~b74Bn$~Jfea(pf%QXU zlVhR>UTh`WhzTPiA}1`J15EqKgx&Qi)uQH4P}Af_7@BefO_A{<;2;j2CLszNy0=jd zp62)QV$DW0#A{L}#z+HWd~pPL+UUPKJz?dyJ9pBc@f$#jLsAkEYb4H`W`|xg(ctj^ zk`&H!dP>nLY8GAAasC*j()z>UaCK>VFl1MJK0-%15ZOz=$P6ZshonUkXXJSmiD~3P z3~Nf9FalM~B#PY^K#k10q61$i@xS`7OiS?CHs~ZXp!_1G>LIEy zB+QBa#pzjmbLELy5N3^LLBanaB(jp=tmYA;KQJHXBa?Hp)Ssb-UaY0wB)!0pwGBi` zyXvRNoB({D=^s;-LV(i`9Fz01K4bF9ZOF7p{}c_Rh(cTWXD`6B8kkrB3Wv#evUU6` z5{C;AVe3Q|!p@J5jo=7j9oP@2M@Klih3z=70k{8tIvhv z5xeTMC?C7~Vux=%9)deOOF`bw(Y!qa7CF0~97lbKDXKpK(PFRf^rFoR@2=4`qaVQ9 z0O{IIb`R<#75s<_TEG1E+c?_lsC9hZf=1`s%`imST4xuR475p0%=tQ{Xdx4aPnN7b zTtId=n=N0L))B|bTlnlK93QR`&tX+njH;hgMUx1h19@YcL{c1W)Al_*Ji$wDBs^;0YZf zv?4=d9r|Y^$V2@M$DQy-;!%>qxfr(X4z^PQ(FF48bR|tgM{r*T?A^ur2ky=6TL=Sii9I5g>8lAH7U7CB~q73qIlO;H91Ft_ubUox9 zX#>8}S=S=Rfok8(<3~dP4r7D7bt6d*=K#DJabZMB2bIf6$z1a*TZ*jES z;$rF@ZYvQdt({_V&I~IvSm=Dx11kG>aYuv(IjR1NDwIcrUE*Kj^Y8KS#^y8*UkC?^ z;tf$S3#KO)%>Jv>pm;n;RnDr*)cCIwxk7{*R{ubRU_qTD!aj*4%ojxJ7Nu?z`IHDb zSJfoKrVvqz;#9?9{XV505P6Tt4~YJ>cU zYapIsLy?Q-C98B|(Jfh}d?^EJl?rFQWqZ*n*^9YFYtdPrQ&g{VLTBjuz17@EjLZU4 zT_p0iAli-Ybt$4FXK;A;rDt(kz0&9?e}(_!AWY742sFpCD0**&$#iKkaG8u+!wc(> q0s&v;sbOAH8qRU^)86IemhOr`3HI7^h6gKM5OXG-*%qyZ)Bg+6(gSt? literal 0 HcmV?d00001 diff --git a/user_manage_api/app/providers/base.py b/user_manage_api/app/providers/base.py index 72bc686..50fddf2 100644 --- a/user_manage_api/app/providers/base.py +++ b/user_manage_api/app/providers/base.py @@ -52,3 +52,15 @@ class SystemProvider(ABC): @abstractmethod def get_user_groups(self, username: str) -> List[str]: raise NotImplementedError + + @abstractmethod + def read_user_environment(self, username: str) -> str: + raise NotImplementedError + + @abstractmethod + def write_default_user_environment(self, username: str, content: str) -> None: + raise NotImplementedError + + @abstractmethod + def write_managed_user_environment(self, username: str, content: str) -> None: + raise NotImplementedError diff --git a/user_manage_api/app/providers/cli_provider.py b/user_manage_api/app/providers/cli_provider.py index ff09b6a..cce1cec 100644 --- a/user_manage_api/app/providers/cli_provider.py +++ b/user_manage_api/app/providers/cli_provider.py @@ -1,6 +1,7 @@ -from pathlib import Path +from pathlib import Path, PurePosixPath import subprocess -from typing import List, Optional +import tempfile +from typing import List, Optional, Set from app.core.errors import ApiError, map_command_error from app.core.models import GroupSummary, UserSummary @@ -11,9 +12,9 @@ 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"} + self.allowlist = {"useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent", "mkdir", "ln", "chown", "chmod", "unlink", "cat", "install"} - def run(self, args: List[str], use_sudo: bool = True) -> str: + def run(self, args: List[str], use_sudo: bool = True, strip_output: bool = True, allowed_exit_codes: Optional[Set[int]] = None) -> str: if not args: raise ApiError(500, "invalid_command", "Empty command.") command = args[0] @@ -27,12 +28,18 @@ class CommandExecutor: 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() + if result.returncode != 0 and result.returncode not in (allowed_exit_codes or set()): + raise map_command_error(result.stderr, result.returncode, command) + if strip_output: + return result.stdout.strip() + + return result.stdout class CliSystemProvider(SystemProvider): + managed_start_marker = "# >>> BastionSSO environment >>>" + managed_end_marker = "# <<< BastionSSO environment <<<" + def __init__(self, executor: CommandExecutor): self.executor = executor @@ -76,7 +83,7 @@ class CliSystemProvider(SystemProvider): return users def get_user(self, username: str) -> UserSummary: - output = self.executor.run(["getent", "passwd", username], use_sudo=False) + output = self.executor.run(["getent", "passwd", username], use_sudo=False, allowed_exit_codes={2}) if not output: raise ApiError(404, "not_found", f"User not found: {username}") parts = output.split(":") @@ -101,7 +108,7 @@ class CliSystemProvider(SystemProvider): return groups def get_group(self, groupname: str) -> GroupSummary: - output = self.executor.run(["getent", "group", groupname], use_sudo=False) + output = self.executor.run(["getent", "group", groupname], use_sudo=False, allowed_exit_codes={2}) if not output: raise ApiError(404, "not_found", f"Group not found: {groupname}") parts = output.split(":") @@ -120,3 +127,52 @@ class CliSystemProvider(SystemProvider): 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] + + def read_user_environment(self, username: str) -> str: + bashrc = PurePosixPath(self.get_user(username).home_dir) / ".bashrc" + try: + return self.executor.run(["cat", str(bashrc)], strip_output=False) + except ApiError as exception: + if exception.code == "not_found": + return "" + + raise + + def write_default_user_environment(self, username: str, content: str) -> None: + self._write_user_bashrc(username, content) + + def write_managed_user_environment(self, username: str, content: str) -> None: + current = self.read_user_environment(username) + next_content = self._replace_managed_block(current, content) + self._write_user_bashrc(username, next_content) + + def _write_user_bashrc(self, username: str, content: str) -> None: + user = self.get_user(username) + home_dir = PurePosixPath(user.home_dir) + self.executor.run(["mkdir", "-p", str(home_dir)]) + bashrc = home_dir / ".bashrc" + temp_path = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as temp_file: + temp_file.write(content) + temp_path = temp_file.name + + self.executor.run(["install", "-m", "644", "-o", username, "-g", str(user.gid), temp_path, str(bashrc)]) + finally: + if temp_path: + Path(temp_path).unlink(missing_ok=True) + + def _replace_managed_block(self, current: str, content: str) -> str: + managed_block = f"{self.managed_start_marker}\n{content.rstrip()}\n{self.managed_end_marker}" + start_index = current.find(self.managed_start_marker) + end_index = current.find(self.managed_end_marker) + if start_index >= 0 and end_index >= start_index: + end_index += len(self.managed_end_marker) + next_content = current[:start_index].rstrip() + "\n\n" + managed_block + "\n" + current[end_index:].lstrip() + + return next_content.strip() + "\n" + + if current.strip() == "": + return managed_block + "\n" + + return current.rstrip() + "\n\n" + managed_block + "\n" diff --git a/user_manage_api/app/services/__pycache__/__init__.cpython-310.pyc b/user_manage_api/app/services/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..378f3ca9771fc74bc80be04ba80357dcaaa84ec7 GIT binary patch literal 164 zcmd1j<>g`kg7r=US&TsXF^Gc2CIV1ND6;?$z}+{C=Z^wjvof=vCyf&zV@P+4YjYO#KN gd}dx|NqoFsLFFwDo80`A(wtN~kOjp|K!Sw<0Qo&8#sB~S literal 0 HcmV?d00001 diff --git a/user_manage_api/app/services/__pycache__/user_group_service.cpython-310.pyc b/user_manage_api/app/services/__pycache__/user_group_service.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69caffd74023e9714a8c1f2c6ffba8dc905aa249 GIT binary patch literal 8506 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 literal 0 HcmV?d00001 diff --git a/user_manage_api/app/services/user_group_service.py b/user_manage_api/app/services/user_group_service.py index 738d7e5..324dbb9 100644 --- a/user_manage_api/app/services/user_group_service.py +++ b/user_manage_api/app/services/user_group_service.py @@ -2,7 +2,7 @@ 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.core.models import GroupSummary, UserCreateRequest, UserEnvironmentBatchResult, UserEnvironmentFailure, UserSummary from app.providers.base import SystemProvider @@ -117,6 +117,8 @@ class UserGroupService: primary_group=payload.primary_group, groups=payload.groups, ) + if payload.default_environment_variables.strip() != "": + self.provider.write_default_user_environment(payload.username, payload.default_environment_variables) def delete_user(self, username: str) -> None: self._ensure_user_visible(username) @@ -172,3 +174,32 @@ class UserGroupService: 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))] + + def get_user_environment(self, username: str) -> str: + self._ensure_user_visible(username) + return self.provider.read_user_environment(username) + + def set_user_environment(self, username: str, content: str) -> None: + self._ensure_user_visible(username) + self._ensure_user_unlocked(username) + self.provider.write_managed_user_environment(username, content) + + def set_all_user_environments(self, content: str) -> UserEnvironmentBatchResult: + updated = [] + failed = [] + for user in self.list_users(): + if user.username in self.locked_users: + continue + try: + self.provider.write_managed_user_environment(user.username, content) + updated.append(user.username) + except ApiError as exception: + failed.append(UserEnvironmentFailure(username=user.username, code=exception.code, message=exception.message)) + + return UserEnvironmentBatchResult( + message="User environments updated.", + updated_users=updated, + failed_users=failed, + updated_count=len(updated), + failed_count=len(failed), + ) diff --git a/user_manage_api/logs/user_manage_api.jsonl b/user_manage_api/logs/user_manage_api.jsonl index 12dca23..886acd8 100644 --- a/user_manage_api/logs/user_manage_api.jsonl +++ b/user_manage_api/logs/user_manage_api.jsonl @@ -16,3 +16,10 @@ {"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} +{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1781703084.9984837, "duration_ms": 0} +{"operation": "update_all_user_environments", "target": "*", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1781704169.5766394, "duration_ms": 0} +{"operation": "update_all_user_environments", "target": "*", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1781704214.9847586, "duration_ms": 0} +{"operation": "update_all_user_environments", "target": "*", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1781704683.2575214, "duration_ms": 0} +{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 29, "ts": 1781705328.2093668, "duration_ms": 0} +{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 18, "ts": 1781705328.4396398, "duration_ms": 0} +{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1781705370.4814944, "duration_ms": 0} diff --git a/user_manage_api/logs/user_manage_api.log b/user_manage_api/logs/user_manage_api.log index 1382970..d2e19ed 100644 --- a/user_manage_api/logs/user_manage_api.log +++ b/user_manage_api/logs/user_manage_api.log @@ -16,3 +16,10 @@ 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= +2026-06-17 21:31:24,998 INFO operation=delete_user target=testtest result=success code=None request_id= +2026-06-17 21:49:29,576 INFO operation=update_all_user_environments target=* result=success code=None request_id= +2026-06-17 21:50:14,984 INFO operation=update_all_user_environments target=* result=success code=None request_id= +2026-06-17 21:58:03,257 INFO operation=update_all_user_environments target=* result=success code=None request_id= +2026-06-17 22:08:48,209 INFO operation=create_user target=admin result=failed code=system_command_error request_id= +2026-06-17 22:08:48,439 INFO operation=create_user target=admin result=failed code=system_command_error request_id= +2026-06-17 22:09:30,481 INFO operation=create_user target=testtest result=success code=None request_id= diff --git a/user_manage_api/tests/__pycache__/test_api_integration.cpython-312-pytest-7.4.4.pyc b/user_manage_api/tests/__pycache__/test_api_integration.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2609d3fa2762e002e3d703c7756c3db39fd912a1 GIT binary patch literal 37676 zcmeHwd2}4dnP2xz&tOgrFu3ml0fHEkICy{*X;KtLilj^nk_hU8XpM&G0Wi=UaP@#B zh669;<;GO3#FXWYpbZmacfB@f$yqqrY?%C!PRi86x3=RQaWsSz*>#FXm>h7xQ@A%bM)qm=CI~drW`u^Ub4<2Ed|A7%@tB?bW zvn<2B!|)8xMwnif?oCm1pNVCnT(5=2Gp2|&TF_fS!{&%BYVWnvFdK37I^aAPaYkLe zuBf}$9WCrFl+SshMZHBdY>5;{y}e!#rM;yzTo5UXmiLy^uq{#%t?aF&VSA)1 zTHRY6t?8|yaYv*!TGv}g!_G*3w4t|whFuX~w6V95WlYRzhIe0M_`*=#duHj$dbin` zrGI=2?+F!#nyMfXUj!ky6sGawoVYh9UXl|p&54&myqT6%u7)bqP^B8G3KjI1^VQcZ zz1#U6e9bkkw*~gKu-^guI@q_uz8>~|*f+p_C+vN&-v#?d*zbn@HrThpz6tj2uy2Na z2kf`QzH^lIw|<5d(d}nN$N7+uI1velVhMU7 zBhhG(jEc_ZgbtcWJCz_7Vl+S_foO18bcJGzCbS-4aR~4*u}#a67UKMlwvdxjwM0^B#5cohet(Q0FHzcfk3iMUw`e&VJ}=y z=mav(%(r!1=iYS8ckF?kbAJDU>)foZW`1`&?yKZ@ZU@#~QinhUcG!)>&MD#?$vDZRySUF%321AhuWFTRDC#C8mq=qi>?q?e=>F_oXhg9XS~WgG`a*%e zc&tAH94+a@%JPM-0ACY6`zyHBq_qQUrQ?=`_@EfkJQC(b^FWv<+aOAsf%rL6hGCSK z#Ek^yqu)kqG3-Oqh=gWn!Vu;n+mT?E3Rsgqc_VcY5uGxB#JfpWX7vi)nQF`&1Ogqy zS#l%sPV%i}#@Tq!TRpKa?b|o~)fw-JwClvZhMm)A?lj*$nSQzN!c5-Nz--i+m1IJHy+8@YUf*_N6p!4GPatDGauTvlLoj-pZZcl1lxhO z_?r;O)8cHt7GJIvj{A|Vh~292RR;&8ImYF<;r}2@+f@mmu5wr$)BakCYtlR}5g(*A+;jHR($$XfW zo2<&}DEd=-G2;OsSgoZK-1nUCIy25Dl}Eoa<2{*noxIoBcH4d@k$&!l*~S-v5j)>- z-gxpuTYa8xbRG)LQ>p7@yp(~GJy15mTTgnVic@(fRESnXCm}_4t;h_~s8;l+4q`@Z z1*uj}e(&tNXEV+nDzkia#(OO7I)=>h>>Fn%>>1a#UoNL0UxJ&Ho)o*&5-#DS%z!?o zkRzaVXE19PpE�*}2k|OliwZY3q!~pSJn)H1HVX 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 None: if username in self.users: @@ -78,6 +83,23 @@ class MockProvider(SystemProvider): raise ApiError(404, "not_found", "user not found") return self.user_group_map.get(username, []) + def read_user_environment(self, username: str) -> str: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + return self.environments.get(username, "") + + def write_default_user_environment(self, username: str, content: str) -> None: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + self.default_environments[username] = content + + def write_managed_user_environment(self, username: str, content: str) -> None: + if username not in self.users: + raise ApiError(404, "not_found", "user not found") + if username in self.environment_failures: + raise self.environment_failures[username] + self.environments[username] = content + def build_client( link_home_dir: Optional[str] = None, @@ -106,7 +128,7 @@ def build_client( group_gid_min=group_gid_min, group_gid_max=group_gid_max, ) - audit = AuditLogger("./logs/test_audit.log") + audit = AuditLogger(str(Path(tempfile.gettempdir()) / "bastion_sso_user_manage_api_test_audit.log")) container.app_state = AppState(settings=settings, service=service, audit=audit) return TestClient(create_app()) @@ -150,6 +172,53 @@ def test_change_user_password() -> None: assert response.json()["message"] == "User password updated." +def test_user_environment_endpoints() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + + client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": [], "default_environment_variables": "export A=1"}, headers=headers) + provider = container.app_state.service.provider + assert provider.default_environments["alice"] == "export A=1" + + response = client.put("/users/alice/environment", json={"content": "export B=2"}, headers=headers) + assert response.status_code == 200 + + response = client.get("/users/alice/environment", headers=headers) + assert response.status_code == 200 + assert response.json()["content"] == "export B=2" + + +def test_update_all_user_environments_updates_visible_users() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) + client.post("/users", json={"username": "bob", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) + + response = client.put("/users/environment", json={"content": "export SHARED=1"}, headers=headers) + + assert response.status_code == 200 + assert response.json()["updated_count"] == 2 + provider = container.app_state.service.provider + assert provider.environments["alice"] == "export SHARED=1" + assert provider.environments["bob"] == "export SHARED=1" + + +def test_update_all_user_environments_returns_partial_failures() -> None: + client = build_client() + headers = {"Authorization": "Bearer test-token"} + client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) + client.post("/users", json={"username": "bob", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers) + provider = container.app_state.service.provider + provider.environment_failures["bob"] = ApiError(503, "system_permission_denied", "permission denied") + + response = client.put("/users/environment", json={"content": "export SHARED=1"}, headers=headers) + + assert response.status_code == 200 + assert response.json()["updated_users"] == ["alice"] + assert response.json()["failed_count"] == 1 + assert response.json()["failed_users"][0]["username"] == "bob" + + def test_create_user_rejects_client_shell_and_home_dir() -> None: client = build_client() headers = {"Authorization": "Bearer test-token"} diff --git a/user_manage_api/tests/test_service_unit.py b/user_manage_api/tests/test_service_unit.py index 172a944..339e1b6 100644 --- a/user_manage_api/tests/test_service_unit.py +++ b/user_manage_api/tests/test_service_unit.py @@ -13,6 +13,7 @@ class NoopProvider(SystemProvider): self.created_home_dir: Optional[str] = None self.created_linked_home_dir: Optional[str] = None self.created_shell: Optional[str] = None + self.default_environment_content: 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 @@ -41,6 +42,12 @@ class NoopProvider(SystemProvider): return None def get_user_groups(self, username: str): return [] + def read_user_environment(self, username: str) -> str: + return "" + def write_default_user_environment(self, username: str, content: str) -> None: + self.default_environment_content = content + def write_managed_user_environment(self, username: str, content: str) -> None: + return None def test_create_user_uses_configured_shell_and_home_dir() -> None: @@ -65,6 +72,16 @@ def test_link_home_dir_uses_home_base_symlink_path_and_external_storage() -> Non assert provider.created_linked_home_dir == "/data/home/alice" +def test_create_user_writes_default_environment_without_managed_block() -> None: + provider = NoopProvider() + service = UserGroupService(provider=provider, home_base_dir="/srv/home") + payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", groups=[], default_environment_variables="export A=1\n") + + service.create_user(payload) + + assert provider.default_environment_content == "export A=1\n" + + def test_command_error_mapping_conflict() -> None: error = map_command_error("user already exists", 9) assert error.status_code == 409 @@ -74,15 +91,29 @@ class RecordingExecutor: def __init__(self, home_dir: str) -> None: self.home_dir = home_dir self.commands: List[List[str]] = [] + self.files: dict[str, str] = {} - def run(self, args: List[str], use_sudo: bool = True) -> str: + def run(self, args: List[str], use_sudo: bool = True, strip_output: bool = True, allowed_exit_codes=None) -> str: self.commands.append(args) if args == ["getent", "passwd", "alice"]: return f"alice:x:1000:1000::%s:/bin/bash" % self.home_dir + if args == ["cat", f"{self.home_dir}/.bashrc"]: + if "bashrc" not in self.files: + raise ApiError(404, "not_found", "No such file or directory") + + return self.files["bashrc"] return "" +class MissingUserExecutor: + def run(self, args: List[str], use_sudo: bool = True, strip_output: bool = True, allowed_exit_codes=None) -> str: + if args == ["getent", "passwd", "missing"] and allowed_exit_codes == {2}: + return "" + + raise AssertionError(f"unexpected command: {args}") + + class FakePath: def __init__(self, path: str) -> None: self.path = path @@ -102,6 +133,18 @@ def test_delete_user_unlinks_home_when_it_is_symlink(monkeypatch) -> None: assert ["unlink", "/home/alice"] in executor.commands +def test_get_user_treats_getent_exit_code_two_as_not_found() -> None: + provider = CliSystemProvider(executor=MissingUserExecutor()) + + try: + provider.get_user("missing") + except ApiError as exception: + assert exception.status_code == 404 + assert exception.code == "not_found" + else: + raise AssertionError("expected ApiError") + + 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") @@ -111,3 +154,69 @@ def test_delete_user_does_not_unlink_home_when_it_is_directory(monkeypatch) -> N assert ["userdel", "alice"] in executor.commands assert ["unlink", "/home/old-alice"] not in executor.commands + + +def test_managed_environment_block_replaces_existing_block() -> None: + provider = CliSystemProvider(executor=RecordingExecutor("/home/alice")) + current = "before\n\n# >>> BastionSSO environment >>>\nold=1\n# <<< BastionSSO environment <<<\n\nafter\n" + + result = provider._replace_managed_block(current, "new=2") + + assert "before" in result + assert "after" in result + assert "old=1" not in result + assert "new=2" in result + assert "# >>> BastionSSO environment >>>" in result + assert "# <<< BastionSSO environment <<<" in result + + +def test_read_user_environment_returns_empty_when_bashrc_missing() -> None: + provider = CliSystemProvider(executor=RecordingExecutor("/home/alice")) + + assert provider.read_user_environment("alice") == "" + + +def test_write_user_environment_uses_install_for_bashrc_only() -> None: + executor = RecordingExecutor("/home/alice") + provider = CliSystemProvider(executor=executor) + + provider.write_default_user_environment("alice", "export A=1\n") + + assert ["mkdir", "-p", "/home/alice"] in executor.commands + install_command = next(command for command in executor.commands if command[:7] == ["install", "-m", "644", "-o", "alice", "-g", "1000"]) + assert install_command[-1] == "/home/alice/.bashrc" + assert not any(command[:3] == ["chown", "-R", "alice"] for command in executor.commands) + + +def test_all_user_environments_collects_partial_failures() -> None: + class PartialFailureProvider(NoopProvider): + def list_users(self): + from app.core.models import UserSummary + + return [ + UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash"), + UserSummary(username="bob", uid=1001, gid=1001, home_dir="/home/bob", shell="/bin/bash"), + ] + + def get_user(self, username: str): + from app.core.models import UserSummary + + return UserSummary(username=username, uid=1000, gid=1000, home_dir=f"/home/{username}", shell="/bin/bash") + + def write_managed_user_environment(self, username: str, content: str) -> None: + if username == "bob": + raise ApiError(503, "system_permission_denied", "permission denied") + + service = UserGroupService(provider=PartialFailureProvider(), home_base_dir="/home") + + result = service.set_all_user_environments("export A=1") + + assert result.updated_users == ["alice"] + assert result.failed_count == 1 + assert result.failed_users[0].username == "bob" + + +def test_command_error_mapping_includes_exit_code_when_stderr_empty() -> None: + error = map_command_error("", 7) + + assert error.message == "system command failed with exit code 7."