feat(服务器用户管理): 完成了服务器用户管理的fastapi封装的修改和更新
This commit is contained in:
parent
92cb45e72d
commit
ca023c23f8
@ -9,6 +9,7 @@ use App\Models\AccessLog;
|
||||
use App\Models\BastionAccount;
|
||||
use App\Models\OpsProtocol;
|
||||
use App\Models\ServerResource;
|
||||
use App\Models\ServerUserBinding;
|
||||
use App\Models\User;
|
||||
use App\Models\UserServerPermission;
|
||||
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
|
||||
@ -125,6 +129,8 @@ class ServerResourceController extends Controller
|
||||
if ($isResource) {
|
||||
$parent = ServerResource::query()->findOrFail($targetParentId);
|
||||
$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['display_name'] = $data['display_name'] ?? $data['name'];
|
||||
$data['asset_id'] = $parent->asset_id;
|
||||
@ -144,6 +150,9 @@ class ServerResourceController extends Controller
|
||||
$data['protocols'] = [];
|
||||
$data['account_id'] = null;
|
||||
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
||||
if (trim((string) ($data['user_api_token'] ?? '')) === '') {
|
||||
$data['user_api_token'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
unset($data['protocol']);
|
||||
@ -158,7 +167,10 @@ class ServerResourceController extends Controller
|
||||
#[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')]
|
||||
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}')]
|
||||
@ -175,6 +187,8 @@ class ServerResourceController extends Controller
|
||||
if ($isResource) {
|
||||
$parent = ServerResource::query()->findOrFail($targetParentId);
|
||||
$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['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
|
||||
$data['asset_id'] = $parent->asset_id;
|
||||
@ -194,6 +208,9 @@ class ServerResourceController extends Controller
|
||||
$data['protocols'] = [];
|
||||
$data['account_id'] = null;
|
||||
$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']);
|
||||
@ -493,6 +510,7 @@ class ServerResourceController extends Controller
|
||||
'protocol' => $protocol,
|
||||
'resource_id' => $resource->id,
|
||||
'resource_name' => $resource->display_name ?: $resource->name,
|
||||
'server_username' => $this->boundServerUsername($user, $resource),
|
||||
'bastion_account_id' => $bastionAccount->id,
|
||||
'client_type' => (string) data_get($result, 'data.client_type', ''),
|
||||
'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
|
||||
{
|
||||
if (! str_starts_with($ssoUrl, 'sso://')) {
|
||||
|
||||
311
app/Http/Controllers/Api/ServerSystemUserController.php
Normal file
311
app/Http/Controllers/Api/ServerSystemUserController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreUserRequest;
|
||||
use App\Http\Requests\UpdateUserRequest;
|
||||
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\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -20,11 +22,11 @@ use Spatie\Permission\Models\Role;
|
||||
#[Apidoc\Title('用户管理')]
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
public function __construct(private ServerUserManagementClient $serverUserClient)
|
||||
{
|
||||
$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', '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')]
|
||||
@ -41,7 +43,7 @@ class UserController extends Controller
|
||||
$perPage = (int) ($validated['per_page'] ?? 20);
|
||||
|
||||
$users = User::query()
|
||||
->with('roles')
|
||||
->with(['roles', 'serverUserBindings.server'])
|
||||
->orderBy($sortBy, $sortOrder)
|
||||
->paginate($perPage);
|
||||
|
||||
@ -51,21 +53,26 @@ class UserController extends Controller
|
||||
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
|
||||
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')) {
|
||||
$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]]);
|
||||
|
||||
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}')]
|
||||
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]);
|
||||
}
|
||||
@ -78,7 +85,7 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$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')) {
|
||||
$user->password = $request->validated('password');
|
||||
@ -90,9 +97,33 @@ class UserController extends Controller
|
||||
$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]]);
|
||||
|
||||
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')]
|
||||
@ -502,6 +533,77 @@ class UserController extends Controller
|
||||
$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
|
||||
{
|
||||
$resourcePermissions = Permission::query()
|
||||
|
||||
@ -18,6 +18,8 @@ class StoreServerResourceRequest extends FormRequest
|
||||
'display_name' => ['nullable', 'string', 'max:255'],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
|
||||
'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'],
|
||||
'account_id' => ['nullable', 'integer', 'min:1'],
|
||||
'protocol' => ['nullable', 'string', 'max:64'],
|
||||
|
||||
@ -21,6 +21,13 @@ class StoreUserRequest extends FormRequest
|
||||
'force_password_change' => ['sometimes', 'boolean'],
|
||||
'role_ids' => ['sometimes', 'array'],
|
||||
'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}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@ class UpdateServerResourceRequest extends FormRequest
|
||||
'display_name' => ['nullable', 'string', 'max:255'],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
|
||||
'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'],
|
||||
'account_id' => ['nullable', 'integer', 'min:1'],
|
||||
'protocol' => ['nullable', 'string', 'max:64'],
|
||||
|
||||
@ -24,6 +24,13 @@ class UpdateUserRequest extends FormRequest
|
||||
'force_password_change' => ['sometimes', 'boolean'],
|
||||
'role_ids' => ['sometimes', 'array'],
|
||||
'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}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ class ServerResource extends Model
|
||||
'display_name',
|
||||
'parent_id',
|
||||
'internal_ip',
|
||||
'user_api_base_url',
|
||||
'user_api_token',
|
||||
'asset_id',
|
||||
'account_id',
|
||||
'protocols',
|
||||
@ -25,6 +27,10 @@ class ServerResource extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'user_api_token',
|
||||
];
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
@ -42,6 +48,11 @@ class ServerResource extends Model
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function serverUserBindings(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerUserBinding::class, 'server_resource_id');
|
||||
}
|
||||
|
||||
public function accessLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AccessLog::class);
|
||||
@ -51,6 +62,7 @@ class ServerResource extends Model
|
||||
{
|
||||
return [
|
||||
'protocols' => 'array',
|
||||
'user_api_token' => 'encrypted',
|
||||
'allow_copy_temp_password' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
40
app/Models/ServerUserBinding.php
Normal file
40
app/Models/ServerUserBinding.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,11 @@ class User extends Authenticatable implements JWTSubject
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function serverUserBindings(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerUserBinding::class);
|
||||
}
|
||||
|
||||
public function opsSoftwarePreferences(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserOpsSoftwarePreference::class);
|
||||
|
||||
139
app/Services/ServerUserManagementClient.php
Normal file
139
app/Services/ServerUserManagementClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -64,6 +64,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'guard_name' => '守卫',
|
||||
'parent_id' => '所属服务器',
|
||||
'internal_ip' => '内网IP',
|
||||
'user_api_base_url' => '用户管理API地址',
|
||||
'user_api_token' => '用户管理API密钥',
|
||||
'asset_id' => '资产ID',
|
||||
'account_id' => '账号ID',
|
||||
'protocol' => '协议',
|
||||
@ -87,6 +89,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'per_page' => '每页数量',
|
||||
'username' => '用户名',
|
||||
'token' => '令牌',
|
||||
'server_bindings' => '服务器账号绑定',
|
||||
'server_resource_id' => '服务器',
|
||||
'password_hash' => '服务器账号密码',
|
||||
'groups' => '用户组',
|
||||
'groupname' => '用户组',
|
||||
];
|
||||
|
||||
$resolveAttribute = function (string $field) use ($attributeLabels): string {
|
||||
|
||||
@ -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' => [
|
||||
'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'),
|
||||
'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'),
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
167
tests/Feature/ServerSystemUserManagementTest.php
Normal file
167
tests/Feature/ServerSystemUserManagementTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
13
user_manage_api/logs/user_manage_api.jsonl
Normal file
13
user_manage_api/logs/user_manage_api.jsonl
Normal 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}
|
||||
13
user_manage_api/logs/user_manage_api.log
Normal file
13
user_manage_api/logs/user_manage_api.log
Normal 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=
|
||||
Loading…
x
Reference in New Issue
Block a user