diff --git a/core/Command/Maintenance/Install.php b/core/Command/Maintenance/Install.php index 8dc551b97b102..41f7c2ae6aa59 100644 --- a/core/Command/Maintenance/Install.php +++ b/core/Command/Maintenance/Install.php @@ -27,6 +27,19 @@ use function get_class; class Install extends Command { + /** + * SSL/TLS command line options and the installer options they provide. The database + * setup translates those, see \OC\Setup\AbstractDatabase::ENCRYPTION_OPTIONS. + * `--database-ssl-no-verify` is handled separately as it takes no value. + */ + private const array SSL_OPTIONS = [ + 'database-ssl-mode' => 'dbsslmode', + 'database-ssl-ca' => 'dbsslca', + 'database-ssl-cert' => 'dbsslcert', + 'database-ssl-key' => 'dbsslkey', + 'database-ssl-crl' => 'dbsslcrl', + ]; + public function __construct( private SystemConfig $config, private IniGetWrapper $iniGetWrapper, @@ -46,6 +59,12 @@ protected function configure(): void { ->addOption('database-user', null, InputOption::VALUE_REQUIRED, 'Login to connect to the database') ->addOption('database-pass', null, InputOption::VALUE_OPTIONAL, 'Password of the database user', null) ->addOption('database-table-space', null, InputOption::VALUE_OPTIONAL, 'Table space of the database (oci only)', null) + ->addOption('database-ssl-mode', null, InputOption::VALUE_REQUIRED, 'Encryption mode for the database connection, e.g. "require" or "verify-full" (pgsql only)') + ->addOption('database-ssl-ca', null, InputOption::VALUE_REQUIRED, 'Path to the CA certificate the database server is verified against (mysql and pgsql only)') + ->addOption('database-ssl-cert', null, InputOption::VALUE_REQUIRED, 'Path to the client certificate used to authenticate against the database (mysql and pgsql only)') + ->addOption('database-ssl-key', null, InputOption::VALUE_REQUIRED, 'Path to the private key of the client certificate (mysql and pgsql only)') + ->addOption('database-ssl-crl', null, InputOption::VALUE_REQUIRED, 'Path to the certificate revocation list (pgsql only)') + ->addOption('database-ssl-no-verify', null, InputOption::VALUE_NONE, 'Do not verify that the database server certificate matches the hostname used to connect (mysql only)') ->addOption('disable-admin-user', null, InputOption::VALUE_NONE, 'Disable the creation of an admin user') ->addOption('admin-user', null, InputOption::VALUE_REQUIRED, 'Login of the admin account', 'admin') ->addOption('admin-pass', null, InputOption::VALUE_REQUIRED, 'Password of the admin account') @@ -184,6 +203,19 @@ protected function validateInput(InputInterface $input, OutputInterface $output, if ($db === 'oci') { $options['dbtablespace'] = $input->getParameterOption('--database-table-space', ''); } + // The database setup translates these into the system config values that configure + // an encrypted connection, and rejects the ones it does not support, + // see \OC\Setup\AbstractDatabase::getEncryptionConfig() + foreach (self::SSL_OPTIONS as $option => $installerOption) { + $value = $input->getOption($option); + if ($value !== null) { + $options[$installerOption] = (string)$value; + } + } + if ($input->getOption('database-ssl-no-verify')) { + $options['dbsslnoverify'] = true; + } + return $options; } diff --git a/core/Controller/SetupController.php b/core/Controller/SetupController.php index aa0ce4e1118eb..77024d1ea06f3 100644 --- a/core/Controller/SetupController.php +++ b/core/Controller/SetupController.php @@ -81,6 +81,12 @@ public function display(array $post): void { 'dbtablespace' => '', 'dbhost' => 'localhost', 'dbtype' => '', + 'dbsslmode' => '', + 'dbsslca' => '', + 'dbsslcert' => '', + 'dbsslkey' => '', + 'dbsslcrl' => '', + 'dbsslnoverify' => false, 'hasAutoconfig' => false, 'serverRoot' => \OC::$SERVERROOT, 'version' => implode('.', $this->serverVersion->getVersion()), diff --git a/core/src/install.ts b/core/src/install.ts index 166a13d6df22f..8ff0d73f7b336 100644 --- a/core/src/install.ts +++ b/core/src/install.ts @@ -24,6 +24,19 @@ export type SetupConfig = { dbhost: string dbtype: DbType | '' + /** Encryption mode of the connection, pgsql only */ + dbsslmode: string + /** Path to the CA certificate the database server is verified against */ + dbsslca: string + /** Path to the client certificate used to authenticate against the database */ + dbsslcert: string + /** Path to the private key of the client certificate */ + dbsslkey: string + /** Path to the certificate revocation list, pgsql only */ + dbsslcrl: string + /** Skip verifying that the server certificate matches the host, mysql only */ + dbsslnoverify: boolean + databases: Partial> hasAutoconfig: boolean diff --git a/core/src/views/Setup.spec.ts b/core/src/views/Setup.spec.ts index 2db6607a51b66..38c533dce3b95 100644 --- a/core/src/views/Setup.spec.ts +++ b/core/src/views/Setup.spec.ts @@ -20,6 +20,12 @@ const defaultConfig = Object.freeze({ dbtablespace: '', dbhost: '', dbtype: '', + dbsslmode: '', + dbsslca: '', + dbsslcert: '', + dbsslkey: '', + dbsslcrl: '', + dbsslnoverify: false, databases: { sqlite: 'SQLite', mysql: 'MySQL/MariaDB', @@ -155,6 +161,67 @@ describe('Default setup page', () => { }) }) +describe('Encrypted database connection', () => { + beforeEach(cleanup) + beforeEach(() => { + removeInitialState() + mockInitialState('core', 'links', links) + }) + + it.each(['sqlite', 'oci'])('Is not offered for %s', async (dbtype) => { + mockInitialState('core', 'config', { + ...defaultConfig, + dbtype, + databases: { sqlite: 'SQLite', mysql: 'MySQL/MariaDB', pgsql: 'PostgreSQL', oci: 'Oracle' }, + } as SetupConfig) + const component = render(SetupView) + + await expect(component.findByText('Encrypted database connection')).rejects.toThrow() + }) + + it('Offers the PDO options for mysql', async () => { + mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'mysql' } as SetupConfig) + const component = render(SetupView) + + await expect(component.findByText('Encrypted database connection')).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /CA certificate path/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Client certificate path/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Client certificate key path/ })).resolves.not.toThrow() + await expect(component.findByRole('checkbox', { name: /Do not verify that the server certificate/ })).resolves.not.toThrow() + + // Both are PostgreSQL specific + await expect(component.findByRole('textbox', { name: /Encryption mode/ })).rejects.toThrow() + await expect(component.findByRole('textbox', { name: /Certificate revocation list path/ })).rejects.toThrow() + }) + + it('Offers the libpq parameters for pgsql', async () => { + mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'pgsql' } as SetupConfig) + const component = render(SetupView) + + await expect(component.findByRole('textbox', { name: /Encryption mode/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /CA certificate path/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Client certificate path/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Client certificate key path/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Certificate revocation list path/ })).resolves.not.toThrow() + + // MySQL specific + await expect(component.findByRole('checkbox', { name: /Do not verify that the server certificate/ })).rejects.toThrow() + }) + + it('Renders the submitted values on error', async () => { + mockInitialState('core', 'config', { + ...defaultConfig, + dbtype: 'pgsql', + dbsslmode: 'verify-full', + dbsslca: '/ca.pem', + } as SetupConfig) + const component = render(SetupView) + + expect((await component.findByRole('textbox', { name: /Encryption mode/ }) as HTMLInputElement).value).toBe('verify-full') + expect((await component.findByRole('textbox', { name: /CA certificate path/ }) as HTMLInputElement).value).toBe('/ca.pem') + }) +}) + describe('Setup page with errors and warning', () => { beforeEach(cleanup) beforeEach(() => { diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue index 61034cb9919db..0b3e434e6b860 100644 --- a/core/src/views/Setup.vue +++ b/core/src/views/Setup.vue @@ -186,6 +186,69 @@ name="dbhost" spellcheck="false" /> + + +
+ {{ t('core', 'Encrypted database connection') }} + +
+ + {{ t('core', 'Encrypted database connection') }} + + + + + + + + + + + + + + {{ t('core', 'Do not verify that the server certificate matches the database host') }} + +
+
@@ -324,6 +387,14 @@ export default defineComponent({ return 'success' }, + /** + * Only MySQL/MariaDB and PostgreSQL can be configured to use an encrypted + * connection through the installer, see OC\Setup\AbstractDatabase. + */ + supportsEncryptedConnection(): boolean { + return this.config?.dbtype === 'mysql' || this.config?.dbtype === 'pgsql' + }, + firstAndOnlyDatabase(): string | null { const dbNames = Object.values(this.config?.databases || {}) if (dbNames.length === 1) { diff --git a/lib/private/DB/ConnectionFactory.php b/lib/private/DB/ConnectionFactory.php index 71bd57188e28c..91ed009f4d45d 100644 --- a/lib/private/DB/ConnectionFactory.php +++ b/lib/private/DB/ConnectionFactory.php @@ -210,7 +210,7 @@ public function createConnectionParams(string $configPrefix = '', array $additio //additional driver options, eg. for mysql ssl $driverOptions = $this->config->getValue($configPrefix . 'dbdriveroptions', $this->config->getValue('dbdriveroptions', null)); if ($driverOptions) { - $connectionParams['driverOptions'] = $driverOptions; + $connectionParams['driverOptions'] = array_merge($connectionParams['driverOptions'], $driverOptions); } // set default table creation options diff --git a/lib/private/Setup/AbstractDatabase.php b/lib/private/Setup/AbstractDatabase.php index 436c8ee697a1b..e4565aec49883 100644 --- a/lib/private/Setup/AbstractDatabase.php +++ b/lib/private/Setup/AbstractDatabase.php @@ -25,6 +25,19 @@ abstract class AbstractDatabase { */ protected const array CONNECTION_ENCRYPTION_OPTIONS = ['dbdriveroptions']; + /** + * Installer options describing an encrypted database connection independently of the + * database in use, as provided by the web installer and `occ maintenance:install`. + * @var string[] + */ + protected const array ENCRYPTION_OPTIONS = ['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl', 'dbsslnoverify']; + + /** + * The subset of {@see static::ENCRYPTION_OPTIONS} this database supports. + * @var string[] + */ + protected const array SUPPORTED_ENCRYPTION_OPTIONS = []; + protected string $dbUser; protected string $dbPassword; protected string $dbName; @@ -53,16 +66,46 @@ public function validate(array $config): array { if (substr_count($config['dbname'], '.') >= 1) { $errors[] = $this->trans->t('You cannot use dots in the database name %s', [$this->dbprettyname]); } + return array_merge($errors, $this->validateEncryptionOptions($config)); + } + + /** + * Validate the installer options configuring an encrypted database connection. + * + * @param array $config The options passed to the installer + * @return string[] + */ + protected function validateEncryptionOptions(array $config): array { + $errors = []; foreach (static::CONNECTION_ENCRYPTION_OPTIONS as $option) { if (isset($config[$option]) && !is_array($config[$option])) { - // Fail instead of ignoring the option, otherwise the instance would be - // installed with an unencrypted connection without the admin noticing. $errors[] = $this->trans->t('The database option "%1$s" for %2$s has to be a list of values', [$option, $this->dbprettyname]); } } + foreach (static::ENCRYPTION_OPTIONS as $option) { + if (!empty($config[$option]) && !in_array($option, static::SUPPORTED_ENCRYPTION_OPTIONS, true)) { + $errors[] = $this->trans->t('The database option "%1$s" is not supported by %2$s', [$option, $this->dbprettyname]); + } + } + // A client certificate is useless without its private key and vice versa + if (in_array('dbsslcert', static::SUPPORTED_ENCRYPTION_OPTIONS, true) + && empty($config['dbsslcert']) !== empty($config['dbsslkey'])) { + $errors[] = $this->trans->t('The database options "dbsslcert" and "dbsslkey" have to be provided together'); + } return $errors; } + /** + * Translate the `ENCRYPTION_OPTIONS` into the system config values that + * configure an encrypted connection for this database. + * + * @param array $config The options passed to the installer + * @return array System config values, empty if no option was provided + */ + protected function getEncryptionConfig(array $config): array { + return []; + } + public function initialize(array $config): void { $dbUser = $config['dbuser']; $dbPass = $config['dbpass']; @@ -95,6 +138,13 @@ public function initialize(array $config): void { $configValues[$option] = $config[$option]; } + // The database independent options end up in the same config values, so they are + // applied on top of any raw value provided, e.g. through an autoconfig file. + // array_replace() instead of array_merge() to keep the numeric PDO attribute keys. + foreach ($this->getEncryptionConfig($config) as $option => $value) { + $configValues[$option] = array_replace($configValues[$option] ?? [], $value); + } + $this->config->setValues($configValues); $this->dbUser = $dbUser; diff --git a/lib/private/Setup/MySQL.php b/lib/private/Setup/MySQL.php index 8a08a0c09ba15..866d4ac0fa99d 100644 --- a/lib/private/Setup/MySQL.php +++ b/lib/private/Setup/MySQL.php @@ -17,6 +17,12 @@ class MySQL extends AbstractDatabase { public string $dbprettyname = 'MySQL/MariaDB'; + /** + * There is no equivalent to the PostgreSQL `sslmode`, the connection is encrypted by + * providing a CA certificate. A revocation list cannot be passed through PDO either. + */ + protected const array SUPPORTED_ENCRYPTION_OPTIONS = ['dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslnoverify']; + #[\Override] public function setupDatabase(): void { //check if the database user has admin right @@ -80,6 +86,47 @@ public function setupDatabase(): void { } } + #[\Override] + protected function getEncryptionConfig(array $config): array { + $attributes = $this->getSslAttributes(); + + $driverOptions = []; + foreach (['dbsslca' => 'ca', 'dbsslcert' => 'cert', 'dbsslkey' => 'key'] as $option => $attribute) { + if (!empty($config[$option])) { + $driverOptions[$attributes[$attribute]] = (string)$config[$option]; + } + } + if (!empty($config['dbsslnoverify'])) { + $driverOptions[$attributes['verify']] = false; + } + + return $driverOptions === [] ? [] : ['dbdriveroptions' => $driverOptions]; + } + + /** + * PDO attributes configuring an encrypted connection. + * + * @return array{ca: int, cert: int, key: int, verify: int} + */ + private function getSslAttributes(): array { + // TODO: simplify once we only support PHP 8.5+. + if (PHP_VERSION_ID >= 80500 && class_exists(\Pdo\Mysql::class)) { + /** @psalm-suppress UndefinedClass */ + return [ + 'ca' => \Pdo\Mysql::ATTR_SSL_CA, + 'cert' => \Pdo\Mysql::ATTR_SSL_CERT, + 'key' => \Pdo\Mysql::ATTR_SSL_KEY, + 'verify' => \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT, + ]; + } + return [ + 'ca' => \PDO::MYSQL_ATTR_SSL_CA, + 'cert' => \PDO::MYSQL_ATTR_SSL_CERT, + 'key' => \PDO::MYSQL_ATTR_SSL_KEY, + 'verify' => \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT, + ]; + } + private function createDatabase(\OC\DB\Connection $connection): void { try { $name = $this->dbName; diff --git a/lib/private/Setup/OCI.php b/lib/private/Setup/OCI.php index 13174f791e363..944679bd84af1 100644 --- a/lib/private/Setup/OCI.php +++ b/lib/private/Setup/OCI.php @@ -42,7 +42,8 @@ public function validate(array $config): array { } elseif (empty($config['dbname'])) { $errors[] = $this->trans->t('Enter the database name for %s', [$this->dbprettyname]); } - return $errors; + // Oracle is configured through the connect string and `sqlnet.ora`, not by the installer + return array_merge($errors, $this->validateEncryptionOptions($config)); } #[\Override] diff --git a/lib/private/Setup/PostgreSQL.php b/lib/private/Setup/PostgreSQL.php index 5b8ceb768f55e..e9a59ac66ba68 100644 --- a/lib/private/Setup/PostgreSQL.php +++ b/lib/private/Setup/PostgreSQL.php @@ -19,6 +19,33 @@ class PostgreSQL extends AbstractDatabase { // #[\Override] TODO: Uncomment this when we only support PHP 8.5+ support protected const array CONNECTION_ENCRYPTION_OPTIONS = [...parent::CONNECTION_ENCRYPTION_OPTIONS, 'pgsql_ssl']; + // #[\Override] TODO: Uncomment this when we only support PHP 8.5+ support + protected const array SUPPORTED_ENCRYPTION_OPTIONS = ['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl']; + + /** + * Installer options mapped onto the `pgsql_ssl` connection parameters, as read by + * {@see \OC\DB\ConnectionFactory::createConnectionParams()}. + */ + private const array SSL_PARAMETERS = [ + 'dbsslmode' => 'mode', + 'dbsslca' => 'rootcert', + 'dbsslcert' => 'cert', + 'dbsslkey' => 'key', + 'dbsslcrl' => 'crl', + ]; + + #[\Override] + protected function getEncryptionConfig(array $config): array { + $pgsqlSsl = []; + foreach (self::SSL_PARAMETERS as $option => $parameter) { + if (!empty($config[$option])) { + $pgsqlSsl[$parameter] = (string)$config[$option]; + } + } + + return $pgsqlSsl === [] ? [] : ['pgsql_ssl' => $pgsqlSsl]; + } + /** * @throws DatabaseSetupException */ diff --git a/lib/private/Setup/Sqlite.php b/lib/private/Setup/Sqlite.php index 96eb60a1bf01f..88ce72bfaa12c 100644 --- a/lib/private/Setup/Sqlite.php +++ b/lib/private/Setup/Sqlite.php @@ -15,7 +15,8 @@ class Sqlite extends AbstractDatabase { #[\Override] public function validate(array $config): array { - return []; + // SQLite needs no credentials, but an encrypted connection is not a thing either + return $this->validateEncryptionOptions($config); } #[\Override] diff --git a/tests/Core/Command/Maintenance/InstallTest.php b/tests/Core/Command/Maintenance/InstallTest.php new file mode 100644 index 0000000000000..6619996378551 --- /dev/null +++ b/tests/Core/Command/Maintenance/InstallTest.php @@ -0,0 +1,91 @@ +command = new Install( + $this->createMock(SystemConfig::class), + $this->createMock(IniGetWrapper::class), + ); + } + + /** + * @param array $parameters + * @return array The installer options built from the command line input + */ + private function validateInput(array $parameters): array { + $input = new ArrayInput(array_merge([ + '--database-name' => 'nextcloud', + '--database-user' => 'admin', + '--database-pass' => 'admin-password', + '--admin-pass' => 'admin-password', + ], $parameters), $this->command->getDefinition()); + + return self::invokePrivate($this->command, 'validateInput', [$input, new NullOutput(), ['sqlite', 'mysql', 'pgsql', 'oci']]); + } + + public static function encryptionOptions(): array { + return [ + '--database-ssl-mode' => ['--database-ssl-mode', 'verify-full', 'dbsslmode', 'verify-full'], + '--database-ssl-ca' => ['--database-ssl-ca', '/ca.pem', 'dbsslca', '/ca.pem'], + '--database-ssl-cert' => ['--database-ssl-cert', '/client.crt', 'dbsslcert', '/client.crt'], + '--database-ssl-key' => ['--database-ssl-key', '/client.key', 'dbsslkey', '/client.key'], + '--database-ssl-crl' => ['--database-ssl-crl', '/crl.pem', 'dbsslcrl', '/crl.pem'], + '--database-ssl-no-verify' => ['--database-ssl-no-verify', true, 'dbsslnoverify', true], + ]; + } + + /** + * The command only forwards the options, the database setup translates them into the + * system config values and rejects the ones it does not support. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('encryptionOptions')] + public function testForwardsEncryptionOptions(string $parameter, string|bool $value, string $option, string|bool $expected): void { + $options = $this->validateInput([ + '--database' => 'pgsql', + $parameter => $value, + ]); + + $this->assertSame($expected, $options[$option]); + } + + public function testNoEncryptionOptions(): void { + $options = $this->validateInput(['--database' => 'mysql']); + + foreach (['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl', 'dbsslnoverify'] as $option) { + $this->assertArrayNotHasKey($option, $options); + } + } + + /** + * An option that does not apply to the chosen database is not filtered out here, it + * has to be reported by the database setup instead of being silently dropped. + */ + public function testForwardsEncryptionOptionsRegardlessOfDatabase(): void { + $options = $this->validateInput([ + '--database' => 'sqlite', + '--database-ssl-ca' => '/ca.pem', + ]); + + $this->assertSame('/ca.pem', $options['dbsslca']); + } +} diff --git a/tests/lib/Setup/AbstractDatabaseTest.php b/tests/lib/Setup/AbstractDatabaseTest.php index 70ccfd45f62c6..9b6a777ede31b 100644 --- a/tests/lib/Setup/AbstractDatabaseTest.php +++ b/tests/lib/Setup/AbstractDatabaseTest.php @@ -171,6 +171,41 @@ public function testValidateRejectsMalformedEncryptionOptions(): void { ], $errors); } + public static function encryptionOptions(): array { + return [ + 'dbsslmode' => ['dbsslmode', 'verify-full'], + 'dbsslca' => ['dbsslca', '/ca.pem'], + 'dbsslcert' => ['dbsslcert', '/client.crt'], + 'dbsslkey' => ['dbsslkey', '/client.key'], + 'dbsslcrl' => ['dbsslcrl', '/crl.pem'], + 'dbsslnoverify' => ['dbsslnoverify', true], + ]; + } + + /** + * A database that cannot be configured to use an encrypted connection has to reject + * every such option instead of installing an unencrypted instance silently. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('encryptionOptions')] + public function testValidateRejectsUnsupportedEncryptionOptions(string $option, string|bool $value): void { + $errors = $this->database->validate($this->options([$option => $value])); + + $this->assertContains("The database option \"$option\" is not supported by Test", $errors); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('encryptionOptions')] + public function testInitializeSkipsUnsupportedEncryptionOptions(string $option, string|bool $value): void { + $this->config->expects($this->once()) + ->method('setValues') + ->with([ + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + 'dbtableprefix' => 'oc_', + ]); + + $this->database->initialize($this->options([$option => $value])); + } + public function testValidateAcceptsEncryptionOptions(): void { $errors = $this->database->validate($this->options([ 'dbdriveroptions' => [self::MYSQL_ATTR_SSL_CA => '/ca.pem'], diff --git a/tests/lib/Setup/MySQLTest.php b/tests/lib/Setup/MySQLTest.php new file mode 100644 index 0000000000000..4ef008cb3a040 --- /dev/null +++ b/tests/lib/Setup/MySQLTest.php @@ -0,0 +1,163 @@ +config = $this->createMock(SystemConfig::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(fn (string $text, array $parameters = []) => vsprintf($text, $parameters)); + + $this->database = new MySQL( + $l10n, + $this->config, + $this->createMock(LoggerInterface::class), + $this->createMock(ISecureRandom::class), + ); + } + + /** + * MySQL/MariaDB is configured through PDO driver options, keyed by the numeric PDO + * attributes - which is why the web installer and CLI cannot pass them directly. + */ + public function testInitializeMapsEncryptionOptions(): void { + $this->config->expects($this->once()) + ->method('setValues') + ->with([ + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + 'dbtableprefix' => 'oc_', + 'dbdriveroptions' => [ + self::ATTR_SSL_CA => '/ca.pem', + self::ATTR_SSL_CERT => '/client.crt', + self::ATTR_SSL_KEY => '/client.key', + self::ATTR_SSL_VERIFY_SERVER_CERT => false, + ], + ]); + + $this->database->initialize($this->options([ + 'dbsslca' => '/ca.pem', + 'dbsslcert' => '/client.crt', + 'dbsslkey' => '/client.key', + 'dbsslnoverify' => true, + ])); + } + + /** + * Driver options provided as raw config value, e.g. through an autoconfig file, must + * survive - and their numeric keys must not be renumbered. + */ + public function testInitializeMergesWithRawDriverOptions(): void { + $this->config->expects($this->once()) + ->method('setValues') + ->with([ + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + 'dbtableprefix' => 'oc_', + 'dbdriveroptions' => [ + self::ATTR_INIT_COMMAND => 'SET wait_timeout = 28800', + self::ATTR_SSL_CA => '/ca.pem', + ], + ]); + + $this->database->initialize($this->options([ + 'dbdriveroptions' => [self::ATTR_INIT_COMMAND => 'SET wait_timeout = 28800'], + 'dbsslca' => '/ca.pem', + ])); + } + + public function testInitializeSkipsEmptyEncryptionOptions(): void { + $this->config->expects($this->once()) + ->method('setValues') + ->with([ + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + 'dbtableprefix' => 'oc_', + ]); + + $this->database->initialize($this->options([ + 'dbsslca' => '', + 'dbsslcert' => '', + 'dbsslkey' => '', + 'dbsslnoverify' => false, + ])); + } + + public static function unsupportedEncryptionOptions(): array { + return [ + // There is no PDO equivalent of the PostgreSQL sslmode + 'dbsslmode' => ['dbsslmode', 'require'], + // A revocation list cannot be passed through PDO + 'dbsslcrl' => ['dbsslcrl', '/crl.pem'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unsupportedEncryptionOptions')] + public function testValidateRejectsUnsupportedEncryptionOptions(string $option, string $value): void { + $errors = $this->database->validate($this->options([$option => $value])); + + $this->assertEquals([ + "The database option \"$option\" is not supported by MySQL/MariaDB", + ], $errors); + } + + public function testValidateRejectsIncompleteClientCertificate(): void { + $errors = $this->database->validate($this->options(['dbsslcert' => '/client.crt'])); + + $this->assertEquals([ + 'The database options "dbsslcert" and "dbsslkey" have to be provided together', + ], $errors); + } + + public function testValidateAcceptsEncryptionOptions(): void { + $errors = $this->database->validate($this->options([ + 'dbsslca' => '/ca.pem', + 'dbsslcert' => '/client.crt', + 'dbsslkey' => '/client.key', + 'dbsslnoverify' => true, + ])); + + $this->assertEquals([], $errors); + } + + private function options(array $additional = []): array { + return array_merge([ + 'dbuser' => 'admin', + 'dbpass' => 'admin-password', + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + ], $additional); + } +} diff --git a/tests/lib/Setup/PostgreSQLTest.php b/tests/lib/Setup/PostgreSQLTest.php index 335e7014b82e8..abad8891e3cbb 100644 --- a/tests/lib/Setup/PostgreSQLTest.php +++ b/tests/lib/Setup/PostgreSQLTest.php @@ -65,6 +65,79 @@ public function testInitializePersistsPgsqlSsl(): void { $this->database->initialize($this->options(['pgsql_ssl' => self::PGSQL_SSL])); } + /** + * The database independent options provided by the web installer and the CLI have to + * end up in the `pgsql_ssl` connection parameters. + */ + public function testInitializeMapsEncryptionOptions(): void { + $this->config->expects($this->once()) + ->method('setValues') + ->with([ + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + 'dbtableprefix' => 'oc_', + 'pgsql_ssl' => self::PGSQL_SSL + ['crl' => '/client.crl'], + ]); + + $this->database->initialize($this->options([ + 'dbsslmode' => 'verify-full', + 'dbsslca' => '/rootCA.crt', + 'dbsslcert' => '/client.crt', + 'dbsslkey' => '/client.key', + 'dbsslcrl' => '/client.crl', + ])); + } + + /** + * A `pgsql_ssl` value provided as raw config value, e.g. through an autoconfig file, + * must survive the mapped options. + */ + public function testInitializeMergesWithRawPgsqlSsl(): void { + $this->config->expects($this->once()) + ->method('setValues') + ->with([ + 'dbname' => 'nextcloud', + 'dbhost' => 'db.example.org', + 'dbtableprefix' => 'oc_', + 'pgsql_ssl' => ['rootcert' => '/rootCA.crt', 'mode' => 'verify-full'], + ]); + + $this->database->initialize($this->options([ + 'pgsql_ssl' => ['rootcert' => '/rootCA.crt'], + 'dbsslmode' => 'verify-full', + ])); + } + + public function testValidateRejectsUnsupportedEncryptionOptions(): void { + // There is no PDO attribute to skip the host verification for PostgreSQL, + // the sslmode covers it + $errors = $this->database->validate($this->options(['dbsslnoverify' => true])); + + $this->assertEquals([ + 'The database option "dbsslnoverify" is not supported by PostgreSQL', + ], $errors); + } + + public function testValidateRejectsIncompleteClientCertificate(): void { + $errors = $this->database->validate($this->options(['dbsslkey' => '/client.key'])); + + $this->assertEquals([ + 'The database options "dbsslcert" and "dbsslkey" have to be provided together', + ], $errors); + } + + public function testValidateAcceptsEncryptionOptions(): void { + $errors = $this->database->validate($this->options([ + 'dbsslmode' => 'verify-full', + 'dbsslca' => '/rootCA.crt', + 'dbsslcert' => '/client.crt', + 'dbsslkey' => '/client.key', + 'dbsslcrl' => '/client.crl', + ])); + + $this->assertEquals([], $errors); + } + public static function emptyPgsqlSsl(): array { return [ 'not provided' => [[]],