feat(服务器账号绑定): 增添服务器环境变量管理,优化服务器用户绑定
This commit is contained in:
parent
884e0235b0
commit
9b17f764cb
@ -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
|
||||
|
||||
@ -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' => ['密码不能过于简单,需包含大小写字母、数字、特殊字符中的至少三类。'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
@ -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 返回异常';
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_user_bindings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_resources', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_resources', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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' => [],
|
||||
|
||||
BIN
user_manage_api/app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
user_manage_api/app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/__pycache__/container.cpython-310.pyc
Normal file
BIN
user_manage_api/app/__pycache__/container.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/__pycache__/factory.cpython-310.pyc
Normal file
BIN
user_manage_api/app/__pycache__/factory.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/__pycache__/main.cpython-310.pyc
Normal file
BIN
user_manage_api/app/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/__pycache__/state.cpython-310.pyc
Normal file
BIN
user_manage_api/app/__pycache__/state.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/api/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
user_manage_api/app/api/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/api/__pycache__/deps.cpython-310.pyc
Normal file
BIN
user_manage_api/app/api/__pycache__/deps.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/api/__pycache__/routes.cpython-310.pyc
Normal file
BIN
user_manage_api/app/api/__pycache__/routes.cpython-310.pyc
Normal file
Binary file not shown.
@ -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)
|
||||
|
||||
BIN
user_manage_api/app/core/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
user_manage_api/app/core/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/core/__pycache__/audit.cpython-310.pyc
Normal file
BIN
user_manage_api/app/core/__pycache__/audit.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/core/__pycache__/config.cpython-310.pyc
Normal file
BIN
user_manage_api/app/core/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/core/__pycache__/errors.cpython-310.pyc
Normal file
BIN
user_manage_api/app/core/__pycache__/errors.cpython-310.pyc
Normal file
Binary file not shown.
BIN
user_manage_api/app/core/__pycache__/models.cpython-310.pyc
Normal file
BIN
user_manage_api/app/core/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
@ -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}.")
|
||||
|
||||
@ -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
|
||||
|
||||
Binary file not shown.
BIN
user_manage_api/app/providers/__pycache__/base.cpython-310.pyc
Normal file
BIN
user_manage_api/app/providers/__pycache__/base.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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),
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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=
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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"}
|
||||
|
||||
@ -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."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user