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 0000000..acf55e0 Binary files /dev/null and b/user_manage_api/app/__pycache__/__init__.cpython-310.pyc differ 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 0000000..d66b138 Binary files /dev/null and b/user_manage_api/app/__pycache__/container.cpython-310.pyc differ diff --git a/user_manage_api/app/__pycache__/factory.cpython-310.pyc b/user_manage_api/app/__pycache__/factory.cpython-310.pyc new file mode 100644 index 0000000..751c100 Binary files /dev/null and b/user_manage_api/app/__pycache__/factory.cpython-310.pyc differ diff --git a/user_manage_api/app/__pycache__/main.cpython-310.pyc b/user_manage_api/app/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..f81f3a1 Binary files /dev/null and b/user_manage_api/app/__pycache__/main.cpython-310.pyc differ diff --git a/user_manage_api/app/__pycache__/state.cpython-310.pyc b/user_manage_api/app/__pycache__/state.cpython-310.pyc new file mode 100644 index 0000000..525c505 Binary files /dev/null and b/user_manage_api/app/__pycache__/state.cpython-310.pyc differ 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 0000000..887b309 Binary files /dev/null and b/user_manage_api/app/api/__pycache__/__init__.cpython-310.pyc differ 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 0000000..5b1f0ba Binary files /dev/null and b/user_manage_api/app/api/__pycache__/deps.cpython-310.pyc differ diff --git a/user_manage_api/app/api/__pycache__/routes.cpython-310.pyc b/user_manage_api/app/api/__pycache__/routes.cpython-310.pyc new file mode 100644 index 0000000..750b4c6 Binary files /dev/null and b/user_manage_api/app/api/__pycache__/routes.cpython-310.pyc differ diff --git a/user_manage_api/app/api/routes.py b/user_manage_api/app/api/routes.py index 0e688bc..6e70959 100644 --- a/user_manage_api/app/api/routes.py +++ b/user_manage_api/app/api/routes.py @@ -6,7 +6,7 @@ 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 +from app.core.models import ApiResponse, GroupCreateRequest, UserCreateRequest, UserEnvironmentBatchResult, UserEnvironmentUpdateRequest, UserGroupsUpdateRequest, UserPasswordUpdateRequest router = APIRouter(dependencies=[Depends(auth_dependency)]) @@ -50,11 +50,32 @@ def list_users() -> 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 0000000..58cb96a Binary files /dev/null and b/user_manage_api/app/core/__pycache__/__init__.cpython-310.pyc differ 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 0000000..6320be5 Binary files /dev/null and b/user_manage_api/app/core/__pycache__/audit.cpython-310.pyc differ diff --git a/user_manage_api/app/core/__pycache__/config.cpython-310.pyc b/user_manage_api/app/core/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..b82301c Binary files /dev/null and b/user_manage_api/app/core/__pycache__/config.cpython-310.pyc differ 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 0000000..e9cad0c Binary files /dev/null and b/user_manage_api/app/core/__pycache__/errors.cpython-310.pyc differ 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 0000000..8dc0339 Binary files /dev/null and b/user_manage_api/app/core/__pycache__/models.cpython-310.pyc differ diff --git a/user_manage_api/app/core/errors.py b/user_manage_api/app/core/errors.py index 4a78fec..0072df4 100644 --- a/user_manage_api/app/core/errors.py +++ b/user_manage_api/app/core/errors.py @@ -6,7 +6,7 @@ class ApiError(Exception): self.message = message -def map_command_error(stderr: str, exit_code: int) -> 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 0000000..48c8fe2 Binary files /dev/null and b/user_manage_api/app/providers/__pycache__/__init__.cpython-310.pyc differ 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 0000000..fc7429e Binary files /dev/null and b/user_manage_api/app/providers/__pycache__/base.cpython-310.pyc differ diff --git a/user_manage_api/app/providers/__pycache__/cli_provider.cpython-310.pyc b/user_manage_api/app/providers/__pycache__/cli_provider.cpython-310.pyc new file mode 100644 index 0000000..b08d38d Binary files /dev/null and b/user_manage_api/app/providers/__pycache__/cli_provider.cpython-310.pyc differ 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 0000000..378f3ca Binary files /dev/null and b/user_manage_api/app/services/__pycache__/__init__.cpython-310.pyc differ 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 0000000..69caffd Binary files /dev/null and b/user_manage_api/app/services/__pycache__/user_group_service.cpython-310.pyc differ 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 0000000..2609d3f Binary files /dev/null and b/user_manage_api/tests/__pycache__/test_api_integration.cpython-312-pytest-7.4.4.pyc differ diff --git a/user_manage_api/tests/__pycache__/test_service_unit.cpython-312-pytest-7.4.4.pyc b/user_manage_api/tests/__pycache__/test_service_unit.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..d7e7b22 Binary files /dev/null and b/user_manage_api/tests/__pycache__/test_service_unit.cpython-312-pytest-7.4.4.pyc differ diff --git a/user_manage_api/tests/test_api_integration.py b/user_manage_api/tests/test_api_integration.py index ff77f2c..74cb15f 100644 --- a/user_manage_api/tests/test_api_integration.py +++ b/user_manage_api/tests/test_api_integration.py @@ -1,4 +1,6 @@ from fastapi.testclient import TestClient +from pathlib import Path +import tempfile from typing import Dict, List, Optional import app.container as container @@ -17,6 +19,9 @@ class MockProvider(SystemProvider): self.users: Dict[str, UserSummary] = {} self.groups: Dict[str, GroupSummary] = {} self.user_group_map: Dict[str, List[str]] = {} + self.environments: Dict[str, str] = {} + self.default_environments: Dict[str, str] = {} + self.environment_failures: Dict[str, ApiError] = {} 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: @@ -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."