Skip to content

Commit fe48158

Browse files
committed
feat: integrate Adminer database browser with role-based access control
1 parent af34acb commit fe48158

25 files changed

Lines changed: 831 additions & 5 deletions

File tree

app/Enums/UserRole.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ public static function assignable(): array
4949
return [self::Viewer, self::Member, self::Admin];
5050
}
5151

52+
/**
53+
* Whether this role meets or exceeds the given minimum role.
54+
*/
55+
public function meetsMinimum(self $required): bool
56+
{
57+
return $this->level() >= $required->level();
58+
}
59+
60+
public function level(): int
61+
{
62+
return match ($this) {
63+
self::Demo => 0,
64+
self::Viewer => 1,
65+
self::Member => 2,
66+
self::Admin => 3,
67+
};
68+
}
69+
5270
/**
5371
* Validation rule string for assignable roles.
5472
*/
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Web;
4+
5+
use App\Enums\DatabaseType;
6+
use App\Http\Controllers\Controller;
7+
use App\Models\DatabaseServer;
8+
use App\Services\AdminerService;
9+
use Illuminate\Support\Facades\Gate;
10+
11+
class AdminerController extends Controller
12+
{
13+
public function __invoke(AdminerService $adminer): void
14+
{
15+
Gate::authorize('adminer', DatabaseServer::class);
16+
17+
$credentials = null;
18+
$serverId = session()->pull('adminer_server_id');
19+
20+
// Initial load: build credentials from the database server stored in session
21+
if ($serverId) {
22+
/** @var DatabaseServer $server */
23+
$server = DatabaseServer::findOrFail($serverId);
24+
abort_unless($server->supportsAdminer(), 403);
25+
26+
$credentials = $this->buildCredentials($server);
27+
$_POST['auth'] = [
28+
'driver' => $credentials['driver'],
29+
'server' => $credentials['server'],
30+
'username' => $credentials['username'],
31+
'password' => $credentials['password'],
32+
'db' => $credentials['db'],
33+
];
34+
}
35+
36+
// Release the session lock before Adminer runs. Adminer is long-lived
37+
// and loads sub-resources (CSS/JS) through this same route — holding
38+
// the lock would block those requests and cause timeouts.
39+
session()->save();
40+
41+
$adminer->render($credentials);
42+
}
43+
44+
/**
45+
* @return array{driver: string, server: string, username: string, password: string, db: string}
46+
*/
47+
private function buildCredentials(DatabaseServer $server): array
48+
{
49+
$driver = match ($server->database_type) { // @phpstan-ignore match.unhandled (supportsAdminer() filters unsupported types)
50+
DatabaseType::MYSQL => 'server',
51+
DatabaseType::POSTGRESQL => 'pgsql',
52+
DatabaseType::SQLITE => 'sqlite',
53+
};
54+
55+
$serverAddress = $server->database_type === DatabaseType::SQLITE
56+
? ''
57+
: $server->host.':'.$server->port;
58+
59+
$db = '';
60+
$databaseNames = $server->backups->first()?->database_names;
61+
if ($databaseNames && count($databaseNames) === 1) {
62+
$db = $databaseNames[0];
63+
}
64+
65+
return [
66+
'driver' => $driver,
67+
'server' => $serverAddress,
68+
'username' => $server->username ?? '',
69+
'password' => $server->getDecryptedPassword(),
70+
'db' => $db,
71+
];
72+
}
73+
}

app/Livewire/Configuration/Application.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,53 @@
22

33
namespace App\Livewire\Configuration;
44

5+
use App\Enums\UserRole;
6+
use App\Livewire\Forms\ConfigurationForm;
7+
use App\Traits\Toast;
58
use Illuminate\Contracts\View\View;
9+
use Livewire\Attributes\Computed;
610
use Livewire\Attributes\Title;
711
use Livewire\Component;
12+
use Symfony\Component\HttpFoundation\Response;
813

914
#[Title('Configuration')]
1015
class Application extends Component
1116
{
17+
use Toast;
18+
19+
public ConfigurationForm $form;
20+
21+
public function mount(): void
22+
{
23+
$this->form->loadFromConfig();
24+
}
25+
26+
#[Computed]
27+
public function isAdmin(): bool
28+
{
29+
return auth()->user()->isAdmin();
30+
}
31+
32+
public function saveApplicationConfig(): void
33+
{
34+
abort_unless(auth()->user()->isAdmin(), Response::HTTP_FORBIDDEN);
35+
36+
$this->form->saveApplication();
37+
38+
$this->success(__('Application configuration saved.'));
39+
}
40+
41+
/**
42+
* @return array<int, array{id: string, name: string}>
43+
*/
44+
public function getAdminerRoleOptions(): array
45+
{
46+
return array_map(
47+
fn (UserRole $role) => ['id' => $role->value, 'name' => $role->label()],
48+
UserRole::assignable(),
49+
);
50+
}
51+
1252
/**
1353
* @return array<int, array{key: string, label: string, class?: string}>
1454
*/
@@ -50,6 +90,7 @@ public function render(): View
5090
return view('livewire.configuration.application', [
5191
'headers' => $this->getHeaders(),
5292
'appConfig' => $this->getAppConfig(),
93+
'adminerRoleOptions' => $this->getAdminerRoleOptions(),
5394
]);
5495
}
5596
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Livewire\DatabaseServer;
4+
5+
use Illuminate\View\View;
6+
use Livewire\Attributes\On;
7+
use Livewire\Component;
8+
9+
class AdminerModal extends Component
10+
{
11+
public bool $showModal = false;
12+
13+
public string $serverName = '';
14+
15+
public string $databaseIcon = '';
16+
17+
public string $databaseType = '';
18+
19+
public string $adminerUrl = '';
20+
21+
#[On('open-adminer-modal')]
22+
public function openModal(string $serverName, string $databaseIcon, string $databaseType, string $adminerUrl): void
23+
{
24+
$this->serverName = $serverName;
25+
$this->databaseIcon = $databaseIcon;
26+
$this->databaseType = $databaseType;
27+
$this->adminerUrl = $adminerUrl;
28+
$this->showModal = true;
29+
}
30+
31+
public function closeModal(): void
32+
{
33+
$this->showModal = false;
34+
$this->adminerUrl = '';
35+
}
36+
37+
public function render(): View
38+
{
39+
return view('livewire.database-server.adminer-modal');
40+
}
41+
}

app/Livewire/DatabaseServer/Index.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use App\Traits\Toast;
1313
use Illuminate\Contracts\View\View;
1414
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
15+
use Illuminate\Support\Facades\Gate;
1516
use Illuminate\Support\Facades\View as ViewFacade;
1617
use Livewire\Attributes\Locked;
1718
use Livewire\Attributes\Title;
@@ -132,6 +133,23 @@ public function confirmRestore(string $id): void
132133
$this->dispatch('open-restore-modal', mode: 'from-server', targetServerId: $id);
133134
}
134135

136+
public function openAdminer(string $id): void
137+
{
138+
$server = DatabaseServer::findOrFail($id);
139+
140+
abort_unless($server->supportsAdminer(), 403);
141+
$this->authorize('adminer', DatabaseServer::class);
142+
143+
session()->put('adminer_server_id', $server->id);
144+
145+
$this->dispatch('open-adminer-modal',
146+
serverName: $server->name,
147+
databaseIcon: $server->database_type->icon(),
148+
databaseType: $server->database_type->label(),
149+
adminerUrl: route('adminer'),
150+
);
151+
}
152+
135153
public function runBackup(string $backupId, TriggerBackupAction $action): void
136154
{
137155
$backup = Backup::with(['databaseServer', 'volume', 'backupSchedule'])->findOrFail($backupId);
@@ -173,6 +191,7 @@ public function render(): View
173191
return view('livewire.database-server.index', [
174192
'servers' => $servers,
175193
'headers' => $this->headers(),
194+
'canAdminer' => Gate::allows('adminer', DatabaseServer::class),
176195
]);
177196
}
178197
}

app/Livewire/Forms/ConfigurationForm.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
namespace App\Livewire\Forms;
44

5+
use App\Enums\UserRole;
56
use App\Facades\AppConfig;
67
use Cron\CronExpression;
8+
use Illuminate\Validation\Rule;
79
use Livewire\Form;
810

911
class ConfigurationForm extends Form
1012
{
13+
// Application settings
14+
public bool $adminer_enabled = false;
15+
16+
public string $adminer_role = 'admin';
17+
1118
// Backup settings
1219
public string $working_directory = '';
1320

@@ -34,6 +41,8 @@ class ConfigurationForm extends Form
3441

3542
public function loadFromConfig(): void
3643
{
44+
$this->adminer_enabled = (bool) AppConfig::get('app.adminer_enabled');
45+
$this->adminer_role = (string) AppConfig::get('app.adminer_role');
3746
$this->working_directory = (string) AppConfig::get('backup.working_directory');
3847
$this->compression = (string) AppConfig::get('backup.compression');
3948
$this->compression_level = (int) AppConfig::get('backup.compression_level');
@@ -45,6 +54,31 @@ public function loadFromConfig(): void
4554
$this->verify_files_cron = (string) AppConfig::get('backup.verify_files_cron');
4655
}
4756

57+
/**
58+
* @return array<string, mixed>
59+
*/
60+
private function applicationRules(): array
61+
{
62+
return [
63+
'adminer_enabled' => ['boolean'],
64+
'adminer_role' => ['required', 'string', Rule::in(array_column(UserRole::assignable(), 'value'))],
65+
];
66+
}
67+
68+
public function saveApplication(): void
69+
{
70+
$this->validate($this->applicationRules());
71+
72+
$appKeyMap = [
73+
'adminer_enabled' => 'app.adminer_enabled',
74+
'adminer_role' => 'app.adminer_role',
75+
];
76+
77+
foreach ($appKeyMap as $property => $configKey) {
78+
AppConfig::set($configKey, $this->{$property});
79+
}
80+
}
81+
4882
/**
4983
* @return array<string, mixed>
5084
*/

app/Models/DatabaseServer.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,16 @@ public function getDecryptedPassword(): string
165165
}
166166
}
167167

168+
/**
169+
* Check if this server supports browsing via Adminer.
170+
* Requires a compatible database type (MySQL, PostgreSQL, SQLite) and no SSH.
171+
*/
172+
public function supportsAdminer(): bool
173+
{
174+
return in_array($this->database_type, [DatabaseType::MYSQL, DatabaseType::POSTGRESQL, DatabaseType::SQLITE])
175+
&& $this->ssh_config_id === null;
176+
}
177+
168178
/**
169179
* Check if this server requires an SSH tunnel for connections.
170180
* SQLite servers never need SSH tunnels since they use local file paths.

app/Models/User.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public function belongsToOrganization(Organization $organization): bool
151151
/**
152152
* Get the user's role in the current org context.
153153
*/
154-
private function currentOrgRole(): ?UserRole
154+
public function currentOrgRole(): ?UserRole
155155
{
156156
return $this->roleIn(app(\App\Services\CurrentOrganization::class)->model());
157157
}

app/Policies/DatabaseServerPolicy.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace App\Policies;
44

5+
use App\Enums\UserRole;
6+
use App\Facades\AppConfig;
57
use App\Models\DatabaseServer;
68
use App\Models\User;
79

@@ -61,6 +63,32 @@ public function delete(User $user, DatabaseServer $databaseServer): bool
6163
return $user->canPerformActions();
6264
}
6365

66+
/**
67+
* Determine whether the user can open the Adminer database browser.
68+
* Requires the feature to be enabled and the user to meet the configured minimum role.
69+
* Server compatibility (database type, SSH) is checked separately via DatabaseServer::supportsAdminer().
70+
*/
71+
public function adminer(User $user): bool
72+
{
73+
if (! AppConfig::get('app.adminer_enabled')) {
74+
return false;
75+
}
76+
77+
$requiredRole = UserRole::tryFrom((string) AppConfig::get('app.adminer_role'));
78+
79+
if ($requiredRole === null) {
80+
return false;
81+
}
82+
83+
if ($user->isSuperAdmin()) {
84+
return true;
85+
}
86+
87+
$currentRole = $user->currentOrgRole();
88+
89+
return $currentRole !== null && $currentRole->meetsMinimum($requiredRole);
90+
}
91+
6492
/**
6593
* Determine whether the user can run a backup.
6694
* Demo users can trigger backups.

0 commit comments

Comments
 (0)