feat(服务器用户管理): 完成了服务器用户管理的fastapi封装的修改和更新

This commit is contained in:
Boen_Shi 2026-05-28 10:59:35 +08:00
parent 92cb45e72d
commit ca023c23f8
18 changed files with 949 additions and 10 deletions

View File

@ -9,6 +9,7 @@ use App\Models\AccessLog;
use App\Models\BastionAccount; use App\Models\BastionAccount;
use App\Models\OpsProtocol; use App\Models\OpsProtocol;
use App\Models\ServerResource; use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User; use App\Models\User;
use App\Models\UserServerPermission; use App\Models\UserServerPermission;
use hg\apidoc\annotation as Apidoc; use hg\apidoc\annotation as Apidoc;
@ -66,7 +67,10 @@ class ServerResourceController extends Controller
}); });
} }
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $query->paginate(500)]); $resources = $query->paginate(500);
$this->appendServerUserManagementMeta($resources->getCollection(), $user);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $resources]);
} }
private function resolveResourceIdsFromPermissions(User $user): Collection private function resolveResourceIdsFromPermissions(User $user): Collection
@ -125,6 +129,8 @@ class ServerResourceController extends Controller
if ($isResource) { if ($isResource) {
$parent = ServerResource::query()->findOrFail($targetParentId); $parent = ServerResource::query()->findOrFail($targetParentId);
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
$data['user_api_base_url'] = null;
$data['user_api_token'] = null;
$data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)]; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)];
$data['display_name'] = $data['display_name'] ?? $data['name']; $data['display_name'] = $data['display_name'] ?? $data['name'];
$data['asset_id'] = $parent->asset_id; $data['asset_id'] = $parent->asset_id;
@ -144,6 +150,9 @@ class ServerResourceController extends Controller
$data['protocols'] = []; $data['protocols'] = [];
$data['account_id'] = null; $data['account_id'] = null;
$data['display_name'] = $data['display_name'] ?? $data['name']; $data['display_name'] = $data['display_name'] ?? $data['name'];
if (trim((string) ($data['user_api_token'] ?? '')) === '') {
$data['user_api_token'] = null;
}
} }
unset($data['protocol']); unset($data['protocol']);
@ -158,7 +167,10 @@ class ServerResourceController extends Controller
#[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')] #[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')]
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
{ {
return response()->json(['code' => 0, 'message' => 'ok', 'data' => ServerResource::query()->with('parent')->findOrFail($id)]); $resource = ServerResource::query()->with('parent')->findOrFail($id);
$this->appendServerUserManagementMeta(collect([$resource]), auth('api')->user());
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $resource]);
} }
#[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')] #[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')]
@ -175,6 +187,8 @@ class ServerResourceController extends Controller
if ($isResource) { if ($isResource) {
$parent = ServerResource::query()->findOrFail($targetParentId); $parent = ServerResource::query()->findOrFail($targetParentId);
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
$data['user_api_base_url'] = null;
$data['user_api_token'] = null;
$data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))]; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))];
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
$data['asset_id'] = $parent->asset_id; $data['asset_id'] = $parent->asset_id;
@ -194,6 +208,9 @@ class ServerResourceController extends Controller
$data['protocols'] = []; $data['protocols'] = [];
$data['account_id'] = null; $data['account_id'] = null;
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
if (trim((string) ($data['user_api_token'] ?? '')) === '') {
unset($data['user_api_token']);
}
} }
unset($data['protocol']); unset($data['protocol']);
@ -493,6 +510,7 @@ class ServerResourceController extends Controller
'protocol' => $protocol, 'protocol' => $protocol,
'resource_id' => $resource->id, 'resource_id' => $resource->id,
'resource_name' => $resource->display_name ?: $resource->name, 'resource_name' => $resource->display_name ?: $resource->name,
'server_username' => $this->boundServerUsername($user, $resource),
'bastion_account_id' => $bastionAccount->id, 'bastion_account_id' => $bastionAccount->id,
'client_type' => (string) data_get($result, 'data.client_type', ''), 'client_type' => (string) data_get($result, 'data.client_type', ''),
'response' => $result, 'response' => $result,
@ -502,6 +520,41 @@ class ServerResourceController extends Controller
]); ]);
} }
private function appendServerUserManagementMeta(Collection $resources, ?User $user): void
{
$serverIds = $resources
->map(fn (ServerResource $resource): int => (int) ($resource->parent_id ?: $resource->id))
->unique()
->values();
$bindings = collect();
if ($user && $serverIds->isNotEmpty()) {
$bindings = ServerUserBinding::query()
->where('user_id', $user->id)
->whereIn('server_resource_id', $serverIds)
->get()
->keyBy('server_resource_id');
}
foreach ($resources as $resource) {
$server = $resource->parent_id ? $resource->parent : $resource;
$serverId = (int) ($server?->id ?: $resource->id);
$binding = $bindings->get($serverId);
$resource->setAttribute('user_api_configured', trim((string) ($server?->user_api_base_url ?? $resource->user_api_base_url ?? '')) !== '');
$resource->setAttribute('server_username', $binding?->username);
}
}
private function boundServerUsername(User $user, ServerResource $resource): ?string
{
$serverId = (int) ($resource->parent_id ?: $resource->id);
return ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $serverId)
->value('username');
}
private function extractSsoTokenFromUrl(string $ssoUrl): ?string private function extractSsoTokenFromUrl(string $ssoUrl): ?string
{ {
if (! str_starts_with($ssoUrl, 'sso://')) { if (! str_starts_with($ssoUrl, 'sso://')) {

View File

@ -0,0 +1,311 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User;
use App\Services\ServerUserManagementClient;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
#[Apidoc\Title('服务器用户与用户组管理')]
class ServerSystemUserController extends Controller
{
public function __construct(private ServerUserManagementClient $client)
{
$this->middleware('auth:api');
$this->middleware('permission:platform.servers.manage,api')->except(['updateBoundPassword']);
}
#[Apidoc\Title('修改当前用户绑定服务器账号密码'), Apidoc\Method('PATCH'), Apidoc\Url('/servers/{id}/bound-system-user/password')]
public function updateBoundPassword(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'password' => ['required', 'string', 'min:6', 'max:255'],
]);
$server = $this->server($id);
$resource = ServerResource::query()->with('parent')->findOrFail($id);
/** @var User|null $user */
$user = auth('api')->user();
if (! $user || ! $this->canUpdateBoundPassword($user, $resource)) {
return response()->json([
'code' => 403,
'message' => '无权限修改该服务器账号密码',
'data' => null,
], 403);
}
$binding = ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $server->id)
->first();
if (! $binding || trim((string) $binding->username) === '') {
throw ValidationException::withMessages([
'server' => ['当前用户未绑定该服务器账号。'],
]);
}
$result = $this->client->updatePassword($server, (string) $binding->username, $this->linuxPasswordHash((string) $validated['password']));
$binding->update([
'remote_exists' => true,
'last_synced_at' => now(),
]);
$this->auditLog($request, 'server_bound_user_password_update', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $binding->username],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('服务器用户管理元数据'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}/system-users/meta')]
public function meta(int $id): JsonResponse
{
$server = $this->server($id);
$users = $this->client->users($server);
$groups = $this->client->groups($server);
$userGroups = [];
foreach ($users as $user) {
$username = (string) ($user['username'] ?? '');
if ($username === '') {
continue;
}
try {
$userGroups[$username] = $this->client->userGroups($server, $username)['groups'] ?? [];
} catch (ValidationException) {
$userGroups[$username] = [];
}
}
$bindings = ServerUserBinding::query()
->with('user:id,nickname,email,phone')
->where('server_resource_id', $server->id)
->orderBy('username')
->get();
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'server' => $server,
'users' => $users,
'groups' => $groups,
'user_groups' => $userGroups,
'bindings' => $bindings,
],
]);
}
#[Apidoc\Title('创建服务器用户'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/system-users')]
public function storeUser(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'password_hash' => ['nullable', 'string', 'min:10', 'max:512'],
'password' => ['nullable', 'string', 'min:6', 'max:255'],
'primary_group' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'groups' => ['sometimes', 'array'],
'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'shell' => ['nullable', 'string', 'max:128'],
'home_dir' => ['nullable', 'string', 'max:255'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
]);
$server = $this->server($id);
$passwordHash = (string) ($validated['password_hash'] ?? '');
if ($passwordHash === '') {
$passwordHash = $this->linuxPasswordHash((string) ($validated['password'] ?? ''));
}
$payload = [
'username' => $validated['username'],
'password_hash' => $passwordHash,
'primary_group' => $validated['primary_group'] ?? null,
'groups' => array_values(array_unique($validated['groups'] ?? [])),
'shell' => $validated['shell'] ?? '/bin/bash',
'home_dir' => $validated['home_dir'] ?? null,
];
$result = $this->client->createUser($server, $payload);
$this->upsertBinding((int) ($validated['user_id'] ?? 0), $server, (string) $validated['username'], true);
$this->auditLog($request, 'server_system_user_create', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $validated['username']],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result], 201);
}
#[Apidoc\Title('删除服务器用户'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}/system-users/{username}')]
public function destroyUser(Request $request, int $id, string $username): JsonResponse
{
$server = $this->server($id);
$result = $this->client->deleteUser($server, $username);
ServerUserBinding::query()
->where('server_resource_id', $server->id)
->where('username', $username)
->update(['remote_exists' => false, 'last_synced_at' => now()]);
$this->auditLog($request, 'server_system_user_delete', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $username],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('修改服务器用户密码'), Apidoc\Method('PATCH'), Apidoc\Url('/servers/{id}/system-users/{username}/password')]
public function updatePassword(Request $request, int $id, string $username): JsonResponse
{
$validated = $request->validate([
'password_hash' => ['nullable', 'string', 'min:10', 'max:512'],
'password' => ['nullable', 'string', 'min:6', 'max:255'],
]);
$server = $this->server($id);
$passwordHash = (string) ($validated['password_hash'] ?? '');
if ($passwordHash === '') {
$passwordHash = $this->linuxPasswordHash((string) ($validated['password'] ?? ''));
}
$result = $this->client->updatePassword($server, $username, $passwordHash);
$this->auditLog($request, 'server_system_user_password_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
{
$validated = $request->validate([
'groupname' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
]);
$server = $this->server($id);
$result = $this->client->createGroup($server, (string) $validated['groupname']);
$this->auditLog($request, 'server_system_group_create', [
'server_resource_id' => $server->id,
'metadata' => ['groupname' => $validated['groupname']],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result], 201);
}
#[Apidoc\Title('删除服务器用户组'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}/system-groups/{groupname}')]
public function destroyGroup(Request $request, int $id, string $groupname): JsonResponse
{
$server = $this->server($id);
$result = $this->client->deleteGroup($server, $groupname);
$this->auditLog($request, 'server_system_group_delete', [
'server_resource_id' => $server->id,
'metadata' => ['groupname' => $groupname],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('同步服务器用户组'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/system-users/{username}/groups')]
public function syncUserGroups(Request $request, int $id, string $username): JsonResponse
{
$validated = $request->validate([
'groups' => ['present', 'array'],
'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'mode' => ['nullable', 'string', 'in:append,replace'],
]);
$server = $this->server($id);
$result = $this->client->syncUserGroups(
$server,
$username,
array_values(array_unique($validated['groups'])),
(string) ($validated['mode'] ?? 'replace'),
);
$this->auditLog($request, 'server_system_user_groups_update', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $username, 'groups' => $validated['groups']],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
private function server(int $id): ServerResource
{
$server = ServerResource::query()->with('parent')->findOrFail($id);
return $server->parent_id ? $server->parent()->firstOrFail() : $server;
}
private function upsertBinding(int $userId, ServerResource $server, string $username, bool $remoteExists): void
{
if ($userId <= 0) {
return;
}
ServerUserBinding::query()->updateOrCreate(
['user_id' => $userId, 'server_resource_id' => $server->id],
['username' => $username, 'remote_exists' => $remoteExists, 'last_synced_at' => now()],
);
}
private function canUpdateBoundPassword(User $user, ServerResource $resource): bool
{
if ($user->can('platform.servers.view')) {
return true;
}
$serverId = (int) ($resource->parent_id ?: $resource->id);
$hasBinding = ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $serverId)
->exists();
if (! $hasBinding) {
return false;
}
if (! $resource->parent_id) {
return true;
}
$pivot = $resource->users()
->where('users.id', $user->id)
->first()?->pivot;
if ($pivot && (bool) ($pivot->can_ssh || $pivot->can_sftp || $pivot->can_rdp)) {
return true;
}
return $user->can('resource.servers.use');
}
private function linuxPasswordHash(string $password): string
{
if ($password === '') {
throw ValidationException::withMessages([
'password' => ['请输入服务器账号密码。'],
]);
}
$salt = substr(strtr(base64_encode(random_bytes(12)), '+', '.'), 0, 16);
$hash = crypt($password, '$6$rounds=5000$'.$salt.'$');
if (! is_string($hash) || strlen($hash) < 10) {
throw ValidationException::withMessages([
'password' => ['服务器账号密码 Hash 生成失败。'],
]);
}
return $hash;
}
}

View File

@ -6,8 +6,10 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest; use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest; use App\Http\Requests\UpdateUserRequest;
use App\Models\ServerResource; use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User; use App\Models\User;
use App\Models\UserServerPermission; use App\Models\UserServerPermission;
use App\Services\ServerUserManagementClient;
use hg\apidoc\annotation as Apidoc; use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -20,11 +22,11 @@ use Spatie\Permission\Models\Role;
#[Apidoc\Title('用户管理')] #[Apidoc\Title('用户管理')]
class UserController extends Controller class UserController extends Controller
{ {
public function __construct() public function __construct(private ServerUserManagementClient $serverUserClient)
{ {
$this->middleware('auth:api'); $this->middleware('auth:api');
$this->middleware('permission:platform.users.view,api')->only(['index', 'show']); $this->middleware('permission:platform.users.view,api')->only(['index', 'show']);
$this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'import', 'importTemplate']); $this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'syncServerBindings', 'import', 'importTemplate']);
} }
#[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')] #[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')]
@ -41,7 +43,7 @@ class UserController extends Controller
$perPage = (int) ($validated['per_page'] ?? 20); $perPage = (int) ($validated['per_page'] ?? 20);
$users = User::query() $users = User::query()
->with('roles') ->with(['roles', 'serverUserBindings.server'])
->orderBy($sortBy, $sortOrder) ->orderBy($sortBy, $sortOrder)
->paginate($perPage); ->paginate($perPage);
@ -51,21 +53,26 @@ class UserController extends Controller
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')] #[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
public function store(StoreUserRequest $request): JsonResponse public function store(StoreUserRequest $request): JsonResponse
{ {
$user = User::query()->create($request->validated()); $data = $request->safe()->except(['role_ids', 'server_bindings']);
$user = User::query()->create($data);
if ($request->filled('role_ids')) { if ($request->filled('role_ids')) {
$user->syncRoles($request->validated('role_ids')); $user->syncRoles($request->validated('role_ids'));
} }
if ($request->filled('server_bindings')) {
$this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) $request->validated('password'));
}
$this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]); $this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')], 201); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])], 201);
} }
#[Apidoc\Title('用户详情'), Apidoc\Method('GET'), Apidoc\Url('/users/{id}')] #[Apidoc\Title('用户详情'), Apidoc\Method('GET'), Apidoc\Url('/users/{id}')]
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
{ {
$user = User::query()->with(['roles', 'permissions', 'serverResources'])->findOrFail($id); $user = User::query()->with(['roles', 'permissions', 'serverResources', 'serverUserBindings.server'])->findOrFail($id);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]);
} }
@ -78,7 +85,7 @@ class UserController extends Controller
} }
$user = User::query()->findOrFail($id); $user = User::query()->findOrFail($id);
$user->fill($request->safe()->except(['role_ids'])); $user->fill($request->safe()->except(['role_ids', 'server_bindings']));
if ($request->filled('password')) { if ($request->filled('password')) {
$user->password = $request->validated('password'); $user->password = $request->validated('password');
@ -90,9 +97,33 @@ class UserController extends Controller
$user->syncRoles($request->validated('role_ids')); $user->syncRoles($request->validated('role_ids'));
} }
if ($request->has('server_bindings')) {
$this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) ($request->validated('password') ?? ''));
}
$this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]); $this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]);
}
#[Apidoc\Title('同步用户服务器账号绑定'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/server-bindings')]
public function syncServerBindings(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'server_bindings' => ['present', 'array'],
'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'],
'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'server_bindings.*.create_remote' => ['sometimes', 'boolean'],
'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}$/'],
]);
$user = User::query()->findOrFail($id);
$this->syncServerBindingsPayload($user, $validated['server_bindings'], '');
$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('PUT'), Apidoc\Url('/users/{id}/permissions')] #[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')]
@ -502,6 +533,77 @@ class UserController extends Controller
$zip->close(); $zip->close();
} }
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);
$this->serverUserClient->createUser($targetServer, [
'username' => $username,
'password_hash' => $this->linuxPasswordHash($password),
'primary_group' => null,
'groups' => array_values(array_unique($binding['groups'] ?? [])),
'shell' => '/bin/bash',
'home_dir' => null,
]);
$remoteExists = true;
}
ServerUserBinding::query()->updateOrCreate(
[
'user_id' => $user->id,
'server_resource_id' => $serverId,
],
[
'username' => $username,
'remote_exists' => $remoteExists,
'last_synced_at' => $remoteExists ? now() : null,
'metadata' => [
'groups' => array_values(array_unique($binding['groups'] ?? [])),
],
],
);
}
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 linuxPasswordHash(string $password): string
{
if ($password === '') {
throw ValidationException::withMessages([
'password' => ['请输入服务器账号密码。'],
]);
}
$salt = substr(strtr(base64_encode(random_bytes(12)), '+', '.'), 0, 16);
$hash = crypt($password, '$6$rounds=5000$'.$salt.'$');
if (! is_string($hash) || strlen($hash) < 10) {
throw ValidationException::withMessages([
'password' => ['服务器账号密码 Hash 生成失败。'],
]);
}
return $hash;
}
private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void
{ {
$resourcePermissions = Permission::query() $resourcePermissions = Permission::query()

View File

@ -18,6 +18,8 @@ class StoreServerResourceRequest extends FormRequest
'display_name' => ['nullable', 'string', 'max:255'], 'display_name' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'], 'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
'internal_ip' => ['nullable', 'ip'], 'internal_ip' => ['nullable', 'ip'],
'user_api_base_url' => ['nullable', 'url', 'max:255'],
'user_api_token' => ['nullable', 'string', 'max:2000'],
'asset_id' => ['nullable', 'integer', 'min:1'], 'asset_id' => ['nullable', 'integer', 'min:1'],
'account_id' => ['nullable', 'integer', 'min:1'], 'account_id' => ['nullable', 'integer', 'min:1'],
'protocol' => ['nullable', 'string', 'max:64'], 'protocol' => ['nullable', 'string', 'max:64'],

View File

@ -21,6 +21,13 @@ class StoreUserRequest extends FormRequest
'force_password_change' => ['sometimes', 'boolean'], 'force_password_change' => ['sometimes', 'boolean'],
'role_ids' => ['sometimes', 'array'], 'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'], 'role_ids.*' => ['integer', 'exists:roles,id'],
'server_bindings' => ['sometimes', 'array'],
'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'],
'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'server_bindings.*.create_remote' => ['sometimes', 'boolean'],
'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}$/'],
]; ];
} }
} }

View File

@ -18,6 +18,8 @@ class UpdateServerResourceRequest extends FormRequest
'display_name' => ['nullable', 'string', 'max:255'], 'display_name' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'], 'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
'internal_ip' => ['nullable', 'ip'], 'internal_ip' => ['nullable', 'ip'],
'user_api_base_url' => ['nullable', 'url', 'max:255'],
'user_api_token' => ['nullable', 'string', 'max:2000'],
'asset_id' => ['nullable', 'integer', 'min:1'], 'asset_id' => ['nullable', 'integer', 'min:1'],
'account_id' => ['nullable', 'integer', 'min:1'], 'account_id' => ['nullable', 'integer', 'min:1'],
'protocol' => ['nullable', 'string', 'max:64'], 'protocol' => ['nullable', 'string', 'max:64'],

View File

@ -24,6 +24,13 @@ class UpdateUserRequest extends FormRequest
'force_password_change' => ['sometimes', 'boolean'], 'force_password_change' => ['sometimes', 'boolean'],
'role_ids' => ['sometimes', 'array'], 'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'], 'role_ids.*' => ['integer', 'exists:roles,id'],
'server_bindings' => ['sometimes', 'array'],
'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'],
'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'server_bindings.*.create_remote' => ['sometimes', 'boolean'],
'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}$/'],
]; ];
} }
} }

View File

@ -17,6 +17,8 @@ class ServerResource extends Model
'display_name', 'display_name',
'parent_id', 'parent_id',
'internal_ip', 'internal_ip',
'user_api_base_url',
'user_api_token',
'asset_id', 'asset_id',
'account_id', 'account_id',
'protocols', 'protocols',
@ -25,6 +27,10 @@ class ServerResource extends Model
'is_active', 'is_active',
]; ];
protected $hidden = [
'user_api_token',
];
public function parent(): BelongsTo public function parent(): BelongsTo
{ {
return $this->belongsTo(self::class, 'parent_id'); return $this->belongsTo(self::class, 'parent_id');
@ -42,6 +48,11 @@ class ServerResource extends Model
->withTimestamps(); ->withTimestamps();
} }
public function serverUserBindings(): HasMany
{
return $this->hasMany(ServerUserBinding::class, 'server_resource_id');
}
public function accessLogs(): HasMany public function accessLogs(): HasMany
{ {
return $this->hasMany(AccessLog::class); return $this->hasMany(AccessLog::class);
@ -51,6 +62,7 @@ class ServerResource extends Model
{ {
return [ return [
'protocols' => 'array', 'protocols' => 'array',
'user_api_token' => 'encrypted',
'allow_copy_temp_password' => 'boolean', 'allow_copy_temp_password' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
]; ];

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServerUserBinding extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'server_resource_id',
'username',
'remote_exists',
'last_synced_at',
'metadata',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(ServerResource::class, 'server_resource_id');
}
protected function casts(): array
{
return [
'remote_exists' => 'boolean',
'last_synced_at' => 'datetime',
'metadata' => 'array',
];
}
}

View File

@ -48,6 +48,11 @@ class User extends Authenticatable implements JWTSubject
->withTimestamps(); ->withTimestamps();
} }
public function serverUserBindings(): HasMany
{
return $this->hasMany(ServerUserBinding::class);
}
public function opsSoftwarePreferences(): HasMany public function opsSoftwarePreferences(): HasMany
{ {
return $this->hasMany(UserOpsSoftwarePreference::class); return $this->hasMany(UserOpsSoftwarePreference::class);

View File

@ -0,0 +1,139 @@
<?php
namespace App\Services;
use App\Models\ServerResource;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class ServerUserManagementClient
{
public function users(ServerResource $server): array
{
return $this->request($server, 'get', '/users')->json();
}
public function user(ServerResource $server, string $username): array
{
return $this->request($server, 'get', '/users/'.$this->encodePath($username))->json();
}
public function createUser(ServerResource $server, array $payload): array
{
return $this->request($server, 'post', '/users', $payload)->json();
}
public function deleteUser(ServerResource $server, string $username): array
{
return $this->request($server, 'delete', '/users/'.$this->encodePath($username))->json();
}
public function updatePassword(ServerResource $server, string $username, string $passwordHash): array
{
return $this->request($server, 'patch', '/users/'.$this->encodePath($username).'/password', [
'password_hash' => $passwordHash,
])->json();
}
public function groups(ServerResource $server): array
{
return $this->request($server, 'get', '/groups')->json();
}
public function createGroup(ServerResource $server, string $groupname): array
{
return $this->request($server, 'post', '/groups', ['groupname' => $groupname])->json();
}
public function deleteGroup(ServerResource $server, string $groupname): array
{
return $this->request($server, 'delete', '/groups/'.$this->encodePath($groupname))->json();
}
public function userGroups(ServerResource $server, string $username): array
{
return $this->request($server, 'get', '/users/'.$this->encodePath($username).'/groups')->json();
}
public function syncUserGroups(ServerResource $server, string $username, array $groups, string $mode = 'replace'): array
{
return $this->request($server, 'post', '/users/'.$this->encodePath($username).'/groups', [
'groups' => array_values($groups),
'mode' => $mode,
])->json();
}
public function removeUserGroups(ServerResource $server, string $username, array $groups): array
{
return $this->request($server, 'delete', '/users/'.$this->encodePath($username).'/groups', [
'groups' => array_values($groups),
'mode' => 'append',
])->json();
}
private function request(ServerResource $server, string $method, string $path, array $payload = [])
{
$target = $this->resolveServer($server);
$baseUrl = rtrim((string) $target->user_api_base_url, '/');
$token = trim((string) $target->user_api_token);
if ($baseUrl === '' || $token === '') {
throw ValidationException::withMessages([
'server' => ['该服务器未配置用户管理 API 地址或密钥。'],
]);
}
try {
$pending = Http::baseUrl($baseUrl)
->acceptJson()
->asJson()
->timeout((int) config('services.server_user_management.timeout', 15))
->connectTimeout((int) config('services.server_user_management.connect_timeout', 5))
->retry(2, 200, throw: false)
->withToken($token)
->withOptions([
'verify' => (bool) config('services.server_user_management.verify_ssl', false),
]);
$response = match ($method) {
'post' => $pending->post($path, $payload),
'patch' => $pending->patch($path, $payload),
'delete' => empty($payload) ? $pending->delete($path) : $pending->send('DELETE', $path, ['json' => $payload]),
default => $pending->get($path),
};
} catch (ConnectionException|RequestException $exception) {
throw ValidationException::withMessages([
'server' => ['服务器用户管理 API 调用失败:'.$exception->getMessage()],
]);
}
if (! $response->successful()) {
$message = (string) (data_get($response->json(), 'message')
?: data_get($response->json(), 'detail.message')
?: data_get($response->json(), 'detail')
?: '服务器用户管理 API 返回异常');
throw ValidationException::withMessages([
'server' => [$message],
]);
}
return $response;
}
private function resolveServer(ServerResource $server): ServerResource
{
if (! $server->parent_id) {
return $server;
}
return $server->parent()->firstOrFail();
}
private function encodePath(string $value): string
{
return rawurlencode($value);
}
}

View File

@ -64,6 +64,8 @@ return Application::configure(basePath: dirname(__DIR__))
'guard_name' => '守卫', 'guard_name' => '守卫',
'parent_id' => '所属服务器', 'parent_id' => '所属服务器',
'internal_ip' => '内网IP', 'internal_ip' => '内网IP',
'user_api_base_url' => '用户管理API地址',
'user_api_token' => '用户管理API密钥',
'asset_id' => '资产ID', 'asset_id' => '资产ID',
'account_id' => '账号ID', 'account_id' => '账号ID',
'protocol' => '协议', 'protocol' => '协议',
@ -87,6 +89,11 @@ return Application::configure(basePath: dirname(__DIR__))
'per_page' => '每页数量', 'per_page' => '每页数量',
'username' => '用户名', 'username' => '用户名',
'token' => '令牌', 'token' => '令牌',
'server_bindings' => '服务器账号绑定',
'server_resource_id' => '服务器',
'password_hash' => '服务器账号密码',
'groups' => '用户组',
'groupname' => '用户组',
]; ];
$resolveAttribute = function (string $field) use ($attributeLabels): string { $resolveAttribute = function (string $field) use ($attributeLabels): string {

View File

@ -55,6 +55,12 @@ return [
], ],
], ],
'server_user_management' => [
'timeout' => (int) env('SERVER_USER_MANAGEMENT_TIMEOUT', 15),
'connect_timeout' => (int) env('SERVER_USER_MANAGEMENT_CONNECT_TIMEOUT', 5),
'verify_ssl' => (bool) env('SERVER_USER_MANAGEMENT_VERIFY_SSL', false),
],
'ops_client' => [ 'ops_client' => [
'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'), 'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'),
'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'), 'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'),

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_resources', function (Blueprint $table) {
$table->string('user_api_base_url')->nullable()->after('internal_ip');
$table->text('user_api_token')->nullable()->after('user_api_base_url');
});
}
public function down(): void
{
Schema::table('server_resources', function (Blueprint $table) {
$table->dropColumn(['user_api_base_url', 'user_api_token']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('server_user_bindings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('server_resource_id')->constrained('server_resources')->cascadeOnDelete();
$table->string('username');
$table->boolean('remote_exists')->default(false);
$table->timestamp('last_synced_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['user_id', 'server_resource_id']);
$table->index(['server_resource_id', 'username']);
});
}
public function down(): void
{
Schema::dropIfExists('server_user_bindings');
}
};

View File

@ -0,0 +1,167 @@
<?php
namespace Tests\Feature;
use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ServerSystemUserManagementTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_read_server_system_user_meta(): void
{
Http::preventStrayRequests();
Http::fake([
'http://node.test/users' => Http::response([
['username' => 'alice', 'uid' => 1000, 'gid' => 1000, 'home_dir' => '/home/alice', 'shell' => '/bin/bash'],
]),
'http://node.test/groups' => Http::response([
['groupname' => 'dev', 'gid' => 1000, 'members' => ['alice']],
]),
'http://node.test/users/alice/groups' => Http::response(['username' => 'alice', 'groups' => ['dev']]),
]);
$admin = $this->admin();
$server = $this->server();
ServerUserBinding::query()->create([
'user_id' => $admin->id,
'server_resource_id' => $server->id,
'username' => 'alice',
'remote_exists' => true,
]);
$response = $this->actingAs($admin, 'api')->getJson('/servers/'.$server->id.'/system-users/meta');
$response
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.users.0.username', 'alice')
->assertJsonPath('data.groups.0.groupname', 'dev')
->assertJsonPath('data.user_groups.alice.0', 'dev');
}
public function test_creating_user_can_create_remote_server_account_and_binding(): void
{
Http::preventStrayRequests();
Http::fake([
'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201),
]);
$admin = $this->admin();
$server = $this->server();
$response = $this->actingAs($admin, 'api')->postJson('/users', [
'nickname' => 'Alice',
'email' => 'alice@example.com',
'phone' => '13800138000',
'password' => 'secret123',
'server_bindings' => [[
'server_resource_id' => $server->id,
'username' => 'alice',
'create_remote' => true,
'groups' => [],
]],
]);
$response->assertCreated()->assertJsonPath('code', 0);
$user = User::query()->where('email', 'alice@example.com')->firstOrFail();
$this->assertDatabaseHas('server_user_bindings', [
'user_id' => $user->id,
'server_resource_id' => $server->id,
'username' => 'alice',
'remote_exists' => true,
]);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'http://node.test/users'
&& $request['username'] === 'alice'
&& is_string($request['password_hash'])
&& str_starts_with($request['password_hash'], '$6$');
});
}
public function test_deleting_sso_user_does_not_delete_remote_server_user(): void
{
Http::fake();
$admin = $this->admin();
$server = $this->server();
$target = User::factory()->create();
ServerUserBinding::query()->create([
'user_id' => $target->id,
'server_resource_id' => $server->id,
'username' => 'target',
'remote_exists' => true,
]);
$response = $this->actingAs($admin, 'api')->deleteJson('/users/'.$target->id);
$response->assertOk()->assertJsonPath('code', 0);
$this->assertDatabaseMissing('server_user_bindings', ['user_id' => $target->id]);
Http::assertNothingSent();
}
public function test_server_list_includes_bound_username_without_exposing_token(): void
{
$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' => 'admin',
'remote_exists' => true,
]);
$response = $this->actingAs($admin, 'api')->getJson('/servers');
$response
->assertOk()
->assertJsonPath('data.data.0.user_api_configured', true)
->assertJsonMissingPath('data.data.0.user_api_token');
$resourcePayload = collect($response->json('data.data'))->firstWhere('id', $resource->id);
$this->assertSame('admin', $resourcePayload['server_username'] ?? null);
}
private function admin(): User
{
$user = User::factory()->create();
Role::query()->firstOrCreate(['name' => 'admin', 'guard_name' => 'api']);
$user->assignRole('admin');
return $user;
}
private function server(): ServerResource
{
return ServerResource::query()->create([
'name' => 'server01',
'display_name' => 'Server 01',
'internal_ip' => '10.0.0.10',
'user_api_base_url' => 'http://node.test',
'user_api_token' => 'secret-token',
'asset_id' => 1,
'account_id' => null,
'protocols' => [],
'is_active' => true,
]);
}
}

View File

@ -0,0 +1,13 @@
{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 13, "ts": 1779863595.5246663, "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": 8, "ts": 1779863595.7691226, "duration_ms": 0}
{"operation": "create_user", "target": "testadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779863611.8820772, "duration_ms": 0}
{"operation": "delete_user", "target": "testadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864235.6534963, "duration_ms": 0}
{"operation": "create_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864389.9342248, "duration_ms": 0}
{"operation": "delete_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864398.5722318, "duration_ms": 0}
{"operation": "create_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864421.8503268, "duration_ms": 0}
{"operation": "delete_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864451.530417, "duration_ms": 0}
{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864549.259966, "duration_ms": 0}
{"operation": "change_user_password", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864630.5760016, "duration_ms": 0}
{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864656.8571804, "duration_ms": 0}
{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865162.184787, "duration_ms": 0}
{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865345.4267325, "duration_ms": 0}

View File

@ -0,0 +1,13 @@
2026-05-27 14:33:15,524 INFO operation=create_user target=admin result=failed code=system_command_error request_id=
2026-05-27 14:33:15,769 INFO operation=create_user target=admin result=failed code=system_command_error request_id=
2026-05-27 14:33:31,882 INFO operation=create_user target=testadmin result=success code=None request_id=
2026-05-27 14:43:55,653 INFO operation=delete_user target=testadmin result=success code=None request_id=
2026-05-27 14:46:29,934 INFO operation=create_user target=test result=success code=None request_id=
2026-05-27 14:46:38,572 INFO operation=delete_user target=test result=success code=None request_id=
2026-05-27 14:47:01,850 INFO operation=create_user target=test result=success code=None request_id=
2026-05-27 14:47:31,530 INFO operation=delete_user target=test result=success code=None request_id=
2026-05-27 14:49:09,260 INFO operation=create_user target=testtest result=success code=None request_id=
2026-05-27 14:50:30,576 INFO operation=change_user_password target=testtest result=success code=None request_id=
2026-05-27 14:50:56,857 INFO operation=delete_user target=testtest result=success code=None request_id=
2026-05-27 14:59:22,184 INFO operation=create_user target=testtest result=success code=None request_id=
2026-05-27 15:02:25,426 INFO operation=delete_user target=testtest result=success code=None request_id=