feat(服务器账号绑定): 增添服务器环境变量管理,优化服务器用户绑定

This commit is contained in:
Boen_Shi 2026-06-17 23:18:53 +08:00
parent 884e0235b0
commit 9b17f764cb
44 changed files with 1043 additions and 38 deletions

View File

@ -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

View File

@ -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' => ['密码不能过于简单,需包含大小写字母、数字、特殊字符中的至少三类。'],
]);
}
}
}

View File

@ -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

View File

@ -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'],

View File

@ -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'],
];
}
}

View File

@ -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'],

View File

@ -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'],
];
}
}

View File

@ -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',

View File

@ -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',
];

View File

@ -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 返回异常';
}
}

View File

@ -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');
});
}
};

View File

@ -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');
});
}
};

View File

@ -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');
});
}
};

View File

@ -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' => [],

Binary file not shown.

Binary file not shown.

View File

@ -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)

View File

@ -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}.")

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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),
)

View File

@ -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}

View File

@ -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=

View File

@ -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"}

View File

@ -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."