Allow to automatically fix missing grants & elevalte database users

Co-authored-by: Johannes Meyer
This commit is contained in:
Yonas Habteab 2023-09-14 17:17:16 +02:00 committed by Johannes Meyer
parent 12bc95099e
commit 2657f032dc
7 changed files with 327 additions and 42 deletions

View File

@ -4,8 +4,11 @@
namespace Icinga\Controllers;
use Exception;
use Icinga\Application\Hook\MigrationHook;
use Icinga\Application\Icinga;
use Icinga\Application\MigrationManager;
use Icinga\Common\Database;
use Icinga\Exception\MissingParameterException;
use Icinga\Forms\MigrationForm;
use Icinga\Web\Notification;
@ -19,6 +22,13 @@ use ipl\Web\Widget\ActionLink;
class MigrationsController extends CompatController
{
use Database;
public function init()
{
Icinga::app()->getModuleManager()->loadModule('setup');
}
public function indexAction(): void
{
$mm = MigrationManager::instance();
@ -44,6 +54,8 @@ class MigrationsController extends CompatController
}
$migrateListForm = new MigrationForm();
$migrateListForm->setRenderDatabaseUserChange(! $mm->validateDatabasePrivileges());
$migrateGlobalForm = new MigrationForm();
$migrateGlobalForm->getAttributes()->set('name', sprintf('migrate-%s', MigrationHook::ALL_MIGRATIONS));
@ -193,12 +205,18 @@ class MigrationsController extends CompatController
$form->on(MigrationForm::ON_SUCCESS, function (MigrationForm $form) {
$mm = MigrationManager::instance();
/** @var array<string, string> $elevatedPrivileges */
$elevatedPrivileges = $form->getValue('database_setup');
if ($elevatedPrivileges !== null && $elevatedPrivileges['grant_privileges'] === 'y') {
$mm->fixIcingaWebMysqlGrants($this->getDb(), $elevatedPrivileges);
}
$pressedButton = $form->getPressedSubmitElement();
if ($pressedButton) {
$name = substr($pressedButton->getName(), 8);
switch ($name) {
case MigrationHook::ALL_MIGRATIONS:
if ($mm->applyAll()) {
if ($mm->applyAll($elevatedPrivileges)) {
Notification::success($this->translate('Applied all migrations successfully'));
} else {
Notification::error(
@ -211,7 +229,7 @@ class MigrationsController extends CompatController
break;
default:
$migration = $mm->getMigration($name);
if ($mm->apply($migration)) {
if ($mm->apply($migration, $elevatedPrivileges)) {
Notification::success($this->translate('Applied pending migrations successfully'));
} else {
Notification::error(

View File

@ -4,20 +4,34 @@
namespace Icinga\Forms;
use Icinga\Application\MigrationManager;
use ipl\Html\Attributes;
use ipl\Html\Form;
use ipl\Html\FormElement\CheckboxElement;
use ipl\Html\FormElement\FieldsetElement;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\I18n\Translation;
use ipl\Validator\CallbackValidator;
use ipl\Web\Common\CsrfCounterMeasure;
use ipl\Web\Common\FormUid;
use ipl\Web\FormDecorator\IcingaFormDecorator;
use PDOException;
class MigrationForm extends Form
{
use CsrfCounterMeasure;
use FormUid;
use Translation;
protected $defaultAttributes = [
'class' => ['icinga-form', 'migration-form', 'icinga-controls'],
'name' => 'migration-form'
];
/** @var bool Whether to allow changing the current database user and password */
protected $renderDatabaseUserChange = false;
public function hasBeenSubmitted(): bool
{
if (! $this->hasBeenSent()) {
@ -25,11 +39,99 @@ class MigrationForm extends Form
}
$pressedButton = $this->getPressedSubmitElement();
return $pressedButton && strpos($pressedButton->getName(), 'migrate-') !== false;
}
public function setRenderDatabaseUserChange(bool $value = true): self
{
$this->renderDatabaseUserChange = $value;
return $this;
}
protected function assemble(): void
{
$this->addHtml($this->createUidElement());
if ($this->renderDatabaseUserChange) {
$mm = MigrationManager::instance();
$newDbSetup = new FieldsetElement('database_setup', ['required' => true]);
$newDbSetup
->setDefaultElementDecorator(new IcingaFormDecorator())
->addElement('text', 'username', [
'required' => true,
'label' => $this->translate('Username'),
'description' => $this->translate(
'A user which is able to create and/or alter the database schema.'
)
])
->addElement('password', 'password', [
'required' => true,
'autocomplete' => 'new-password',
'label' => $this->translate('Password'),
'description' => $this->translate('The password for the database user defined above.'),
'validators' => [
new CallbackValidator(function ($_, CallbackValidator $validator) use ($mm, $newDbSetup): bool {
/** @var array<string, string> $values */
$values = $this->getValue('database_setup');
/** @var CheckboxElement $checkBox */
$checkBox = $newDbSetup->getElement('grant_privileges');
$canIssueGrants = $checkBox->isChecked();
$elevationConfig = [
'username' => $values['username'],
'password' => $values['password']
];
try {
if (! $mm->validateDatabasePrivileges($elevationConfig, $canIssueGrants)) {
$validator->addMessage(sprintf(
$this->translate(
'The provided credentials cannot be used to execute "%s" SQL commands'
. ' and/or grant the missing privileges to other users.'
),
implode(' ,', $mm->getRequiredDatabasePrivileges())
));
return false;
}
} catch (PDOException $e) {
$validator->addMessage($e->getMessage());
return false;
}
return true;
})
]
])
->addElement('checkbox', 'grant_privileges', [
'required' => false,
'label' => $this->translate('Grant Missing Privileges'),
'description' => $this->translate(
'Allows to automatically grant the required privileges to the database user specified'
. ' in the respective resource config. If you do not want to provide additional credentials'
. ' each time, you can enable this and Icinga Web will grant the active database user the'
. ' missing privileges.'
)
]);
$this->addHtml(
new HtmlElement(
'div',
Attributes::create(['class' => 'change-database-user-description']),
new HtmlElement('span', null, Text::create(sprintf(
$this->translate(
'It seems that the currently used database user does not have the required privileges to'
. ' execute the %s SQL commands. Please provide an alternative user'
. ' that has the appropriate credentials to resolve this issue.'
),
implode(', ', $mm->getRequiredDatabasePrivileges())
)))
)
);
$this->addElement($newDbSetup);
}
}
}

View File

@ -27,8 +27,7 @@ class DbMigration
public function __construct(string $version, string $scriptPath)
{
$this->scriptPath = $scriptPath;
$this->setVersion($version);
$this->version = $version;
}
/**
@ -41,20 +40,6 @@ class DbMigration
return $this->version;
}
/**
* Set the sql script version the queries are loaded from
*
* @param string $version
*
* @return $this
*/
public function setVersion(string $version): self
{
$this->version = $version;
return $this;
}
/**
* Get upgrade script relative path name
*

View File

@ -14,13 +14,16 @@ use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Application\Modules\Module;
use Icinga\Model\Schema;
use Icinga\Module\Setup\Utils\DbTool;
use Icinga\Web\Session;
use ipl\I18n\Translation;
use ipl\Orm\Query;
use ipl\Sql\Adapter\Pgsql;
use ipl\Sql\Connection;
use ipl\Stdlib\Filter;
use ipl\Stdlib\Str;
use PDO;
use PDOException;
use SplFileInfo;
use stdClass;
@ -128,6 +131,13 @@ abstract class MigrationHook implements Countable
*/
abstract public function getVersion(): string;
/**
* Get a database connection
*
* @return Connection
*/
abstract public function getDb(): Connection;
/**
* Get all the pending migrations of this hook
*
@ -166,9 +176,12 @@ abstract class MigrationHook implements Countable
*
* @return bool Whether the migration(s) have been successfully applied
*/
public function run(): bool
final public function run(Connection $conn = null): bool
{
$conn = $this->getDb();
if (! $conn) {
$conn = $this->getDb();
}
foreach ($this->getMigrations() as $migration) {
try {
$migration->apply($conn);
@ -176,7 +189,7 @@ abstract class MigrationHook implements Countable
$this->version = $migration->getVersion();
unset($this->migrations[$migration->getVersion()]);
Logger::error(
Logger::info(
"Applied %s pending migration version %s successfully",
$this->getName(),
$migration->getVersion()
@ -243,13 +256,6 @@ abstract class MigrationHook implements Countable
return count($this->getMigrations());
}
/**
* Get a database connection
*
* @return Connection
*/
abstract protected function getDb(): Connection;
/**
* Get a schema version query
*

View File

@ -8,11 +8,14 @@ use Countable;
use Generator;
use Icinga\Application\Hook\MigrationHook;
use Icinga\Exception\NotFoundError;
use Icinga\Module\Setup\Utils\DbTool;
use Icinga\Module\Setup\WebWizard;
use ipl\I18n\Translation;
use ipl\Sql;
use ReflectionClass;
/**
* Migration manager encapsulates PHP code and DB migrations and manages all pending migrations in a
* structured way.
* Migration manager allows you to manage all pending migrations in a structured way.
*/
final class MigrationManager implements Countable
{
@ -82,7 +85,7 @@ final class MigrationManager implements Countable
*
* @return MigrationHook
*
* @throws NotFoundError When there are no pending PHP code migrations matching the given module name
* @throws NotFoundError When there are no pending migrations matching the given module name
*/
public function getMigration(string $module): MigrationHook
{
@ -124,10 +127,11 @@ final class MigrationManager implements Countable
* Apply the given migration hook
*
* @param MigrationHook $hook
* @param ?array<string, string> $elevateConfig
*
* @return bool
*/
public function apply(MigrationHook $hook): bool
public function apply(MigrationHook $hook, array $elevateConfig = null): bool
{
if ($hook->isModule() && $this->hasMigrations(MigrationHook::DEFAULT_MODULE)) {
Logger::error(
@ -137,7 +141,12 @@ final class MigrationManager implements Countable
return false;
}
if ($hook->run()) {
$conn = $hook->getDb();
if ($elevateConfig && ! $this->checkRequiredPrivileges($conn)) {
$conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
}
if ($hook->run($conn)) {
unset($this->pendingMigrations[$hook->getModuleName()]);
Logger::info('Applied pending %s migrations successfully', $hook->getName());
@ -151,21 +160,23 @@ final class MigrationManager implements Countable
/**
* Apply all pending modules/framework migrations
*
* @param ?array<string, string> $elevateConfig
*
* @return bool
*/
public function applyAll(): bool
public function applyAll(array $elevateConfig = null): bool
{
$default = MigrationHook::DEFAULT_MODULE;
if ($this->hasMigrations($default)) {
$migration = $this->getMigration($default);
if (! $this->apply($migration)) {
if (! $this->apply($migration, $elevateConfig)) {
return false;
}
}
$succeeded = true;
foreach ($this->getPendingMigrations() as $migration) {
if (! $this->apply($migration) && $succeeded) {
if (! $this->apply($migration, $elevateConfig) && $succeeded) {
$succeeded = false;
}
}
@ -189,6 +200,99 @@ final class MigrationManager implements Countable
}
}
/**
* Get the required database privileges for database migrations
*
* @return string[]
*/
public function getRequiredDatabasePrivileges(): array
{
return ['CREATE','SELECT','INSERT','UPDATE','DELETE','DROP','ALTER','CREATE VIEW','INDEX','EXECUTE'];
}
/**
* Verify whether all database users of all pending migrations do have the required SQL privileges
*
* @param ?array<string, string> $elevateConfig
* @param bool $canIssueGrant
*
* @return bool
*/
public function validateDatabasePrivileges(array $elevateConfig = null, bool $canIssueGrant = false): bool
{
if (! $this->hasPendingMigrations()) {
return true;
}
foreach ($this->getPendingMigrations() as $migration) {
if (! $this->checkRequiredPrivileges($migration->getDb(), $elevateConfig, $canIssueGrant)) {
return false;
}
}
return true;
}
/**
* Check if there are missing grants for the Icinga Web database and fix them
*
* This fixes the following problems on existing installations:
* - Setups made by the wizard have no access to `icingaweb_schema`
* - Setups made by the wizard have no DDL grants
* - Setups done manually using the advanced documentation chapter have no DDL grants
*
* @param Sql\Connection $db
* @param array<string, string> $elevateConfig
*/
public function fixIcingaWebMysqlGrants(Sql\Connection $db, array $elevateConfig): void
{
$wizardProperties = (new ReflectionClass(WebWizard::class))
->getDefaultProperties();
/** @var array<int, string> $privileges */
$privileges = $wizardProperties['databaseUsagePrivileges'];
/** @var array<int, string> $tables */
$tables = $wizardProperties['databaseTables'];
$actualUsername = $db->getConfig()->username;
$db = $this->elevateDatabaseConnection($db, $elevateConfig);
$tool = $this->createDbTool($db);
$tool->connectToDb();
if ($tool->checkPrivileges(['SELECT'], [], $actualUsername)) {
// Checks only database level grants. If this succeeds, the grants were issued manually.
if (! $tool->checkPrivileges($privileges, [], $actualUsername) && $tool->isGrantable($privileges)) {
// Any missing grant is now granted on database level as well, not to mix things up
$tool->grantPrivileges($privileges, [], $actualUsername);
}
} elseif (! $tool->checkPrivileges($privileges, $tables, $actualUsername) && $tool->isGrantable($privileges)) {
// The above ensures that if this fails, we can safely apply table level grants, as it's
// very likely that the existing grants were issued by the setup wizard
$tool->grantPrivileges($privileges, $tables, $actualUsername);
}
}
/**
* Create and return a DbTool instance
*
* @param Sql\Connection $db
*
* @return DbTool
*/
private function createDbTool(Sql\Connection $db): DbTool
{
$config = $db->getConfig();
return new DbTool(array_merge([
'db' => $config->db,
'host' => $config->host,
'port' => $config->port,
'dbname' => $config->dbname,
'username' => $config->username,
'password' => $config->password,
'charset' => $config->charset
], $db->getAdapter()->getOptions($config)));
}
protected function load(): void
{
$this->pendingMigrations = [];
@ -205,6 +309,55 @@ final class MigrationManager implements Countable
ksort($this->pendingMigrations);
}
/**
* Check the required SQL privileges of the given connection
*
* @param Sql\Connection $conn
* @param ?array<string, string> $elevateConfig
* @param bool $canIssueGrants
*
* @return bool
*/
protected function checkRequiredPrivileges(
Sql\Connection $conn,
array $elevateConfig = null,
bool $canIssueGrants = false
): bool {
if ($elevateConfig) {
$conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
}
$dbTool = $this->createDbTool($conn);
$dbTool->connectToDb();
if (! $dbTool->checkPrivileges($this->getRequiredDatabasePrivileges())) {
return false;
}
if ($canIssueGrants && ! $dbTool->isGrantable($this->getRequiredDatabasePrivileges())) {
return false;
}
return true;
}
/**
* Override the database config of the given connection by the specified new config
*
* Overrides only the username and password of existing database connection.
*
* @param Sql\Connection $conn
* @param array<string, string> $elevateConfig
* @return Sql\Connection
*/
protected function elevateDatabaseConnection(Sql\Connection $conn, array $elevateConfig): Sql\Connection
{
$config = clone $conn->getConfig();
$config->username = $elevateConfig['username'];
$config->password = $elevateConfig['password'];
return new Sql\Connection($config);
}
/**
* Get all pending migrations as an array
*

View File

@ -8,10 +8,18 @@ use Icinga\Application\Hook\MigrationHook;
use Icinga\Common\Database;
use Icinga\Model\Schema;
use ipl\Orm\Query;
use ipl\Sql\Connection;
class DbMigration extends MigrationHook
{
use Database;
use Database {
getDb as public getPublicDb;
}
public function getDb(): Connection
{
return $this->getPublicDb();
}
public function getName(): string
{

View File

@ -2,13 +2,11 @@
@visual-width: 1.5em;
.migration-state-banner {
.migration-state-banner, .change-database-user-description {
.rounded-corners();
border: 1px solid @gray-lighter;
color: @text-color-light;
padding: 1em;
text-align: center;
border: 1px solid @gray-light;
color: @text-color;
}
.migrations {
@ -45,6 +43,17 @@
}
// Layout
.migration-state-banner, .change-database-user-description {
padding: 1em;
text-align: center;
&.change-database-user-description {
max-width: 50em;
padding: .5em;
}
}
.pending-migrations-hint {
text-align: center;
@ -63,6 +72,10 @@
.icinga-form { // Reset Icinga Form layout styles
width: unset;
max-width: unset;
fieldset {
max-width: 50em;
}
}
.migration-list-control {