diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a7952c574a611..bc7643331b76a 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -11,6 +11,9 @@ 'NCU\\Config\\Exceptions\\TypeConflictException' => $baseDir . '/lib/unstable/Config/Exceptions/TypeConflictException.php', 'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', 'NCU\\Config\\IUserPreferences' => $baseDir . '/lib/unstable/Config/IUserPreferences.php', + 'NCU\\Config\\Lexicon\\ConfigLexiconEntry' => $baseDir . '/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php', + 'NCU\\Config\\Lexicon\\ConfigLexiconStrictness' => $baseDir . '/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php', + 'NCU\\Config\\Lexicon\\IConfigLexicon' => $baseDir . '/lib/unstable/Config/Lexicon/IConfigLexicon.php', 'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php', 'OCP\\Accounts\\IAccount' => $baseDir . '/lib/public/Accounts/IAccount.php', 'OCP\\Accounts\\IAccountManager' => $baseDir . '/lib/public/Accounts/IAccountManager.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 46d99f58500b4..310c5d056b362 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -52,6 +52,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\Config\\Exceptions\\TypeConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/TypeConflictException.php', 'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', 'NCU\\Config\\IUserPreferences' => __DIR__ . '/../../..' . '/lib/unstable/Config/IUserPreferences.php', + 'NCU\\Config\\Lexicon\\ConfigLexiconEntry' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php', + 'NCU\\Config\\Lexicon\\ConfigLexiconStrictness' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php', + 'NCU\\Config\\Lexicon\\IConfigLexicon' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/IConfigLexicon.php', 'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php', 'OCP\\Accounts\\IAccount' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccount.php', 'OCP\\Accounts\\IAccountManager' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountManager.php', diff --git a/lib/private/AppConfig.php b/lib/private/AppConfig.php index dc9bac7745d17..2428dbca8b0a1 100644 --- a/lib/private/AppConfig.php +++ b/lib/private/AppConfig.php @@ -11,6 +11,10 @@ use InvalidArgumentException; use JsonException; +use NCU\Config\Lexicon\ConfigLexiconEntry; +use NCU\Config\Lexicon\ConfigLexiconStrictness; +use NCU\Config\Lexicon\IConfigLexicon; +use OC\AppFramework\Bootstrap\Coordinator; use OCP\DB\Exception as DBException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Exceptions\AppConfigIncorrectTypeException; @@ -55,6 +59,8 @@ class AppConfig implements IAppConfig { private array $valueTypes = []; // type for all config values private bool $fastLoaded = false; private bool $lazyLoaded = false; + /** @var array, strictness: ConfigLexiconStrictness}> ['app_id' => ['strictness' => ConfigLexiconStrictness, 'entries' => ['config_key' => ConfigLexiconEntry[]]] */ + private array $configLexiconDetails = []; /** * $migrationCompleted is only needed to manage the previous structure @@ -430,6 +436,9 @@ private function getTypedValue( int $type, ): string { $this->assertParams($app, $key, valueType: $type); + if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, $default)) { + return $default; // returns default if strictness of lexicon is set to WARNING (block and report) + } $this->loadConfig($app, $lazy); /** @@ -721,6 +730,9 @@ private function setTypedValue( int $type, ): bool { $this->assertParams($app, $key); + if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type)) { + return false; // returns false as database is not updated + } $this->loadConfig(null, $lazy); $sensitive = $this->isTyped(self::VALUE_SENSITIVE, $type); @@ -1559,4 +1571,113 @@ private function getSensitiveKeys(string $app): array { public function clearCachedConfig(): void { $this->clearCache(); } + + /** + * verify and compare current use of config values with defined lexicon + * + * @throws AppConfigUnknownKeyException + * @throws AppConfigTypeConflictException + */ + private function compareRegisteredConfigValues( + string $app, + string $key, + bool &$lazy, + int &$type, + string &$default = '', + ): bool { + if (in_array($key, + [ + 'enabled', + 'installed_version', + 'types', + ])) { + return false; + } + $configDetails = $this->getConfigDetailsFromLexicon($app); + if (!array_key_exists($key, $configDetails['entries'])) { + return $this->applyLexiconStrictness( + $configDetails['strictness'], + 'The app config key ' . $app . '/' . $key . ' is not defined in the config lexicon' + ); + } + + /** @var ConfigLexiconEntry $configValue */ + $configValue = $configDetails['entries'][$key]; + $type &= ~self::VALUE_SENSITIVE; + + if ($type === self::VALUE_MIXED) { + $type = $configValue->getValueType()->value; // we overwrite if value was requested as mixed + } elseif ($configValue->getValueType()->value !== $type) { + throw new AppConfigTypeConflictException('The app config key ' . $app . '/' . $key . ' is typed incorrectly in relation to the config lexicon'); + } + + $lazy = $configValue->isLazy(); + $default = $configValue->getDefault() ?? $default; // default from Lexicon got priority + if ($configValue->isFlagged(self::FLAG_SENSITIVE)) { + $type |= self::VALUE_SENSITIVE; + } + if ($configValue->isDeprecated()) { + $this->logger->notice('App config key ' . $app . '/' . $key . ' is set as deprecated.'); + } + + return true; + } + + /** + * manage ConfigLexicon behavior based on strictness set in IConfigLexicon + * + * @see IConfigLexicon::getStrictness() + * @param string $app + * @param string $key + * @param ConfigLexiconStrictness $strictness + * + * @return bool TRUE if conflict can be fully ignored + * @throws AppConfigUnknownKeyException + */ + private function applyLexiconStrictness( + ?ConfigLexiconStrictness $strictness, + string $line = '', + ): bool { + if ($strictness === null) { + return true; + } + + switch ($strictness) { + case ConfigLexiconStrictness::IGNORE: + return true; + case ConfigLexiconStrictness::NOTICE: + $this->logger->notice($line); + return true; + case ConfigLexiconStrictness::WARNING: + $this->logger->warning($line); + return false; + } + + throw new AppConfigUnknownKeyException($line); + } + + /** + * extract details from registered $appId's config lexicon + * + * @param string $appId + * + * @return array{entries: array, strictness: ConfigLexiconStrictness} + */ + private function getConfigDetailsFromLexicon(string $appId): array { + if (!array_key_exists($appId, $this->configLexiconDetails)) { + $entries = []; + $bootstrapCoordinator = \OCP\Server::get(Coordinator::class); + $configLexicon = $bootstrapCoordinator->getRegistrationContext()?->getConfigLexicon($appId); + foreach ($configLexicon?->getAppConfigs() ?? [] as $configEntry) { + $entries[$configEntry->getKey()] = $configEntry; + } + + $this->configLexiconDetails[$appId] = [ + 'entries' => $entries, + 'strictness' => $configLexicon?->getStrictness() ?? ConfigLexiconStrictness::IGNORE + ]; + } + + return $this->configLexiconDetails[$appId]; + } } diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index d7a380f9e1de8..f3b612edc38bb 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -10,6 +10,7 @@ namespace OC\AppFramework\Bootstrap; use Closure; +use NCU\Config\Lexicon\IConfigLexicon; use OC\Support\CrashReport\Registry; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IRegistrationContext; @@ -141,6 +142,9 @@ class RegistrationContext { /** @var ServiceRegistration[] */ private array $declarativeSettings = []; + /** @var array */ + private array $configLexiconClasses = []; + /** @var ServiceRegistration[] */ private array $teamResourceProviders = []; @@ -422,6 +426,13 @@ public function registerMailProvider(string $class): void { $class ); } + + public function registerConfigLexicon(string $configLexiconClass): void { + $this->context->registerConfigLexicon( + $this->appId, + $configLexiconClass + ); + } }; } @@ -621,6 +632,13 @@ public function registerMailProvider(string $appId, string $class): void { $this->mailProviders[] = new ServiceRegistration($appId, $class); } + /** + * @psalm-param class-string $configLexiconClass + */ + public function registerConfigLexicon(string $appId, string $configLexiconClass): void { + $this->configLexiconClasses[$appId] = $configLexiconClass; + } + /** * @param App[] $apps */ @@ -972,4 +990,20 @@ public function getTaskProcessingTaskTypes(): array { public function getMailProviders(): array { return $this->mailProviders; } + + /** + * returns IConfigLexicon registered by the app. + * null if none registered. + * + * @param string $appId + * + * @return IConfigLexicon|null + */ + public function getConfigLexicon(string $appId): ?IConfigLexicon { + if (!array_key_exists($appId, $this->configLexiconClasses)) { + return null; + } + + return \OCP\Server::get($this->configLexiconClasses[$appId]); + } } diff --git a/lib/private/Config/UserPreferences.php b/lib/private/Config/UserPreferences.php index d9fd5f6a06499..86888f44de678 100644 --- a/lib/private/Config/UserPreferences.php +++ b/lib/private/Config/UserPreferences.php @@ -15,7 +15,10 @@ use NCU\Config\Exceptions\TypeConflictException; use NCU\Config\Exceptions\UnknownKeyException; use NCU\Config\IUserPreferences; +use NCU\Config\Lexicon\ConfigLexiconEntry; +use NCU\Config\Lexicon\ConfigLexiconStrictness; use NCU\Config\ValueType; +use OC\AppFramework\Bootstrap\Coordinator; use OCP\DB\Exception as DBException; use OCP\DB\IResult; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -63,6 +66,8 @@ class UserPreferences implements IUserPreferences { private array $fastLoaded = []; /** @var array ['user_id' => bool] */ private array $lazyLoaded = []; + /** @var array, strictness: ConfigLexiconStrictness}> ['app_id' => ['strictness' => ConfigLexiconStrictness, 'entries' => ['config_key' => ConfigLexiconEntry[]]] */ + private array $configLexiconDetails = []; public function __construct( protected IDBConnection $connection, @@ -706,6 +711,9 @@ private function getTypedValue( ValueType $type, ): string { $this->assertParams($userId, $app, $key); + if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, default: $default)) { + return $default; // returns default if strictness of lexicon is set to WARNING (block and report) + } $this->loadPreferences($userId, $lazy); /** @@ -1038,6 +1046,9 @@ private function setTypedValue( ValueType $type, ): bool { $this->assertParams($userId, $app, $key); + if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, $flags)) { + return false; // returns false as database is not updated + } $this->loadPreferences($userId, $lazy); $inserted = $refreshCache = false; @@ -1045,7 +1056,7 @@ private function setTypedValue( $sensitive = $this->isFlagged(self::FLAG_SENSITIVE, $flags); if ($sensitive || ($this->hasKey($userId, $app, $key, $lazy) && $this->isSensitive($userId, $app, $key, $lazy))) { $value = self::ENCRYPTION_PREFIX . $this->crypto->encrypt($value); - $flags |= UserPreferences::FLAG_SENSITIVE; + $flags |= self::FLAG_SENSITIVE; } // if requested, we fill the 'indexed' field with current value @@ -1803,4 +1814,97 @@ private function decryptSensitiveValue(string $userId, string $app, string $key, ]); } } + + + /** + * verify and compare current use of config values with defined lexicon + * + * @throws UnknownKeyException + * @throws TypeConflictException + */ + private function compareRegisteredConfigValues( + string $app, + string $key, + bool &$lazy, + ValueType &$type, + int &$flags = 0, + string &$default = '', + ): bool { + $configDetails = $this->getConfigDetailsFromLexicon($app); + if (!array_key_exists($key, $configDetails['entries'])) { + return $this->applyLexiconStrictness($configDetails['strictness'], 'The user preference key ' . $app . '/' . $key . ' is not defined in the config lexicon'); + } + + /** @var ConfigLexiconEntry $configValue */ + $configValue = $configDetails['entries'][$key]; + if ($type === ValueType::MIXED) { + $type = $configValue->getValueType()->value; // we overwrite if value was requested as mixed + } elseif ($configValue->getValueType()->value !== $type) { + throw new TypeConflictException('The user preference key ' . $app . '/' . $key . ' is typed incorrectly in relation to the config lexicon'); + } + + $lazy = $configValue->isLazy(); + $default = $configValue->getDefault() ?? $default; // default from Lexicon got priority + $flags = $configValue->getFlags(); + + if ($configValue->isDeprecated()) { + $this->logger->notice('User preference key ' . $app . '/' . $key . ' is set as deprecated.'); + } + + return true; + } + + /** + * manage ConfigLexicon behavior based on strictness set in IConfigLexicon + * + * @see IConfigLexicon::getStrictness() + * @param ConfigLexiconStrictness|null $strictness + * @param string $line + * + * @return bool TRUE if conflict can be fully ignored + * @throws UnknownKeyException + */ + private function applyLexiconStrictness(?ConfigLexiconStrictness $strictness, string $line = ''): bool { + if ($strictness === null) { + return true; + } + + switch ($strictness) { + case ConfigLexiconStrictness::IGNORE: + return true; + case ConfigLexiconStrictness::NOTICE: + $this->logger->notice($line); + return true; + case ConfigLexiconStrictness::WARNING: + $this->logger->warning($line); + return false; + } + + throw new UnknownKeyException($line); + } + + /** + * extract details from registered $appId's config lexicon + * + * @param string $appId + * + * @return array{entries: array, strictness: ConfigLexiconStrictness} + */ + private function getConfigDetailsFromLexicon(string $appId): array { + if (!array_key_exists($appId, $this->configLexiconDetails)) { + $entries = []; + $bootstrapCoordinator = \OCP\Server::get(Coordinator::class); + $configLexicon = $bootstrapCoordinator->getRegistrationContext()?->getConfigLexicon($appId); + foreach ($configLexicon?->getUserPreferences() ?? [] as $configEntry) { + $entries[$configEntry->getKey()] = $configEntry; + } + + $this->configLexiconDetails[$appId] = [ + 'entries' => $entries, + 'strictness' => $configLexicon?->getStrictness() ?? ConfigLexiconStrictness::IGNORE + ]; + } + + return $this->configLexiconDetails[$appId]; + } } diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index b9e5413e5c260..99bbdaea050d6 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -423,4 +423,15 @@ public function registerTaskProcessingTaskType(string $taskProcessingTaskTypeCla */ public function registerMailProvider(string $class): void; + + /** + * Register an implementation of \OCP\ConfigLexicon\IConfigLexicon that + * will handle the implementation of config lexicon + * + * @param string $configLexiconClass + * + * @psalm-param class-string<\NCU\Config\Lexicon\IConfigLexicon> $configLexiconClass + * @since 31.0.0 + */ + public function registerConfigLexicon(string $configLexiconClass): void; } diff --git a/lib/public/IAppConfig.php b/lib/public/IAppConfig.php index fe894da8d3156..d4d5c1c09c740 100644 --- a/lib/public/IAppConfig.php +++ b/lib/public/IAppConfig.php @@ -45,6 +45,9 @@ interface IAppConfig { /** @since 29.0.0 */ public const VALUE_ARRAY = 64; + /** @since 31.0.0 */ + public const FLAG_SENSITIVE = 1; // value is sensitive + /** * Get list of all apps that have at least one config value stored in database * diff --git a/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php b/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php new file mode 100644 index 0000000000000..9f325884944b1 --- /dev/null +++ b/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php @@ -0,0 +1,186 @@ +default = match ($type) { + ValueType::MIXED => (string)$default, + ValueType::STRING => $this->convertFromString((string)$default), + ValueType::INT => $this->convertFromInt((int)$default), + ValueType::FLOAT => $this->convertFromFloat((float)$default), + ValueType::BOOL => $this->convertFromBool((bool)$default), + ValueType::ARRAY => $this->convertFromArray((array)$default) + }; + } + + /** @psalm-suppress UndefinedClass */ + if (\OC::$CLI) { // only store definition if ran from CLI + $this->definition = $definition; + } + } + + /** + * @inheritDoc + * + * @return string config key + * @since 31.0.0 + */ + public function getKey(): string { + return $this->key; + } + + /** + * @inheritDoc + * + * @return ValueType + * @since 31.0.0 + */ + public function getValueType(): ValueType { + return $this->type; + } + + /** + * @param string $default + * @return string + * @since 31.0.0 + */ + private function convertFromString(string $default): string { + return $default; + } + + /** + * @param int $default + * @return string + * @since 31.0.0 + */ + private function convertFromInt(int $default): string { + return (string)$default; + } + + /** + * @param float $default + * @return string + * @since 31.0.0 + */ + private function convertFromFloat(float $default): string { + return (string)$default; + } + + /** + * @param bool $default + * @return string + * @since 31.0.0 + */ + private function convertFromBool(bool $default): string { + return ($default) ? '1' : '0'; + } + + /** + * @param array $default + * @return string + * @since 31.0.0 + */ + private function convertFromArray(array $default): string { + return json_encode($default); + } + + /** + * @inheritDoc + * + * @return string|null NULL if no default is set + * @since 31.0.0 + */ + public function getDefault(): ?string { + return $this->default; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getDefinition(): string { + return $this->definition; + } + + /** + * @inheritDoc + * + * @see IAppConfig for details on lazy config values + * @return bool TRUE if config value is lazy + * @since 31.0.0 + */ + public function isLazy(): bool { + return $this->lazy; + } + + /** + * @inheritDoc + * + * @see IAppConfig for details on sensitive config values + * @return int bitflag about the config value + * @since 31.0.0 + */ + public function getFlags(): int { + return $this->flags; + } + + /** + * @param int $flag + * + * @return bool TRUE is config value bitflag contains $flag + * @since 31.0.0 + */ + public function isFlagged(int $flag): bool { + return (bool)($flag & $this->getFlags()); + } + + /** + * @inheritDoc + * + * @return bool TRUE if config si deprecated + * @since 31.0.0 + */ + public function isDeprecated(): bool { + return $this->deprecated; + } +} diff --git a/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php b/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php new file mode 100644 index 0000000000000..7ba984b4746a1 --- /dev/null +++ b/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php @@ -0,0 +1,31 @@ +connection = \OCP\Server::get(IDBConnection::class); $this->logger = \OCP\Server::get(LoggerInterface::class); $this->crypto = \OCP\Server::get(ICrypto::class); + $this->coordinator = \OCP\Server::get(Coordinator::class); // storing current config and emptying the data table $sql = $this->connection->getQueryBuilder(); @@ -178,6 +182,7 @@ private function generateAppConfig(bool $preLoading = true): IAppConfig { $this->connection, $this->logger, $this->crypto, + $this->coordinator ); $msg = ' generateAppConfig() failed to confirm cache status';