diff --git a/apps/settings/lib/Settings/Personal/PersonalInfo.php b/apps/settings/lib/Settings/Personal/PersonalInfo.php index 0aa70be16ab4b..d118d794f6632 100644 --- a/apps/settings/lib/Settings/Personal/PersonalInfo.php +++ b/apps/settings/lib/Settings/Personal/PersonalInfo.php @@ -115,12 +115,19 @@ public function getForm(): TemplateResponse { 'pronouns' => $this->getProperty($account, IAccountManager::PROPERTY_PRONOUNS), ]; + $maxPropertyScopes = array_filter( + $this->config->getSystemValue('account_manager.max_property_scope', []), + static fn (string $scope, string $property): bool => in_array($property, IAccountManager::ALLOWED_PROPERTIES, true) && in_array($scope, IAccountManager::ALLOWED_SCOPES, true), + ARRAY_FILTER_USE_BOTH, + ); + $accountParameters = [ 'avatarChangeSupported' => $user->canChangeAvatar(), 'displayNameChangeSupported' => $user->canChangeDisplayName(), 'emailChangeSupported' => $user->canChangeEmail(), 'federationEnabled' => $federationEnabled, 'lookupServerUploadEnabled' => $lookupServerUploadEnabled, + 'maxPropertyScopes' => $maxPropertyScopes, ]; $profileParameters = [ diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue index 4bf562498e494..1572821eed07e 100644 --- a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue +++ b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue @@ -52,6 +52,7 @@ import { handleError } from '../../../utils/handlers.ts' const { federationEnabled, lookupServerUploadEnabled, + maxPropertyScopes, } = loadState('settings', 'accountParameters', {}) export default { @@ -123,18 +124,24 @@ export default { }, supportedScopes() { - const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable] - - if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) { - return scopes - } - - if (federationEnabled) { - scopes.push(SCOPE_ENUM.FEDERATED) + const scopes = [...PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]] + + if (!UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) { + if (federationEnabled) { + scopes.push(SCOPE_ENUM.FEDERATED) + } + if (lookupServerUploadEnabled) { + scopes.push(SCOPE_ENUM.PUBLISHED) + } } - if (lookupServerUploadEnabled) { - scopes.push(SCOPE_ENUM.PUBLISHED) + // Apply admin-configured scope ceiling for this property. + const propertyKey = PROPERTY_READABLE_KEYS_ENUM[this.readable] + const maxScope = propertyKey && maxPropertyScopes?.[propertyKey] + if (maxScope) { + const order = [SCOPE_ENUM.PRIVATE, SCOPE_ENUM.LOCAL, SCOPE_ENUM.FEDERATED, SCOPE_ENUM.PUBLISHED] + const maxIndex = order.indexOf(maxScope) + return scopes.filter((scope) => order.indexOf(scope) <= maxIndex) } return scopes diff --git a/config/config.sample.php b/config/config.sample.php index b645121a59a4f..bf8367420d760 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -2886,6 +2886,29 @@ */ 'account_manager.default_property_scope' => [], + /** + * Set a maximum allowed visibility scope for individual account properties. + * Users cannot set a property to a scope more visible than the configured + * ceiling, neither through the UI nor the API. + * + * Valid property names and scope values are defined in + * ``OCP\Accounts\IAccountManager``. + * + * Example: Prevent users from making their email or website visible beyond + * the local instance: + * ``[ + * \OCP\Accounts\IAccountManager::PROPERTY_EMAIL => \OCP\Accounts\IAccountManager::SCOPE_LOCAL, + * \OCP\Accounts\IAccountManager::PROPERTY_WEBSITE => \OCP\Accounts\IAccountManager::SCOPE_LOCAL, + * ]`` + * + * WARNING: Restricting the scope of properties that are required for + * federation (``displayname``, ``email``, ``avatar``, ``pronouns``) below + * ``SCOPE_FEDERATED`` will break federated sharing and other cross-instance + * features that depend on those fields being visible to trusted remote + * servers. + */ + 'account_manager.max_property_scope' => [], + /** * Enable the deprecated Projects feature, superseded by Related Resources since * Nextcloud 25. diff --git a/lib/private/Accounts/AccountManager.php b/lib/private/Accounts/AccountManager.php index f3d2878b254bc..ec8b390904ff0 100644 --- a/lib/private/Accounts/AccountManager.php +++ b/lib/private/Accounts/AccountManager.php @@ -120,6 +120,21 @@ protected function testPropertyScope(IAccountProperty $property, array $allowedS throw new InvalidArgumentException('scope'); } + // Enforce admin-configured per-property scope ceiling. + $maxScopes = $this->config->getSystemValue('account_manager.max_property_scope', []); + if (isset($maxScopes[$property->getName()])) { + $maxScope = $maxScopes[$property->getName()]; + $currentOrder = self::PROPERTY_SCOPE_ORDER[$property->getScope()] ?? 0; + $maxOrder = self::PROPERTY_SCOPE_ORDER[$maxScope] ?? PHP_INT_MAX; + if ($currentOrder > $maxOrder) { + if ($throwOnData) { + throw new InvalidArgumentException('scope'); + } else { + $property->setScope($maxScope); + } + } + } + if ( $property->getScope() === self::SCOPE_PRIVATE && in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL]) diff --git a/lib/public/Accounts/IAccountManager.php b/lib/public/Accounts/IAccountManager.php index ae5535ef13b70..6aa612c018cf7 100644 --- a/lib/public/Accounts/IAccountManager.php +++ b/lib/public/Accounts/IAccountManager.php @@ -59,6 +59,25 @@ interface IAccountManager { self::SCOPE_PUBLISHED, ]; + /** + * Visibility order of scopes from least to most visible. + * Used to compare scope levels when enforcing admin-configured ceilings + * via the ``account_manager.max_property_scope`` system config key. + * + * Warning: restricting properties that federation depends on + * (``PROPERTY_DISPLAYNAME``, ``PROPERTY_EMAIL``, ``PROPERTY_AVATAR``, + * ``PROPERTY_PRONOUNS``) below ``SCOPE_FEDERATED`` will break federated + * sharing and other cross-instance features. + * + * @since 32.0.0 + */ + public const PROPERTY_SCOPE_ORDER = [ + self::SCOPE_PRIVATE => 0, + self::SCOPE_LOCAL => 1, + self::SCOPE_FEDERATED => 2, + self::SCOPE_PUBLISHED => 3, + ]; + /** * @since 15.0.0 */ diff --git a/tests/lib/Accounts/AccountManagerTest.php b/tests/lib/Accounts/AccountManagerTest.php index 888ae69f5df04..97e5e6eee365d 100644 --- a/tests/lib/Accounts/AccountManagerTest.php +++ b/tests/lib/Accounts/AccountManagerTest.php @@ -1062,4 +1062,59 @@ public function testSetDefaultPropertyScopes(array $propertyScopes, array $expec $this->assertEquals($expectedResultScopeValue, $resultScope, "The result scope doesn't follow the value set into the config or defaults correctly."); } } + + public function testUpdateAccountRejectsScoperAboveAdminCeiling(): void { + $user = $this->createMock(IUser::class); + $account = new Account($user); + $account->setProperty(IAccountManager::PROPERTY_WEBSITE, 'https://example.com', IAccountManager::SCOPE_FEDERATED, IAccountManager::NOT_VERIFIED); + + $manager = $this->getInstance(['getUser', 'updateUser']); + $manager->method('getUser')->with($user, false)->willReturn([]); + $this->config->method('getSystemValue') + ->willReturnMap([ + ['account_manager.default_property_scope', [], []], + ['account_manager.max_property_scope', [], [IAccountManager::PROPERTY_WEBSITE => IAccountManager::SCOPE_LOCAL]], + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('scope'); + $manager->updateAccount($account); + } + + public function testUpdateAccountAllowsScopeAtOrBelowAdminCeiling(): void { + $user = $this->createMock(IUser::class); + $account = new Account($user); + $account->setProperty(IAccountManager::PROPERTY_WEBSITE, 'https://example.com', IAccountManager::SCOPE_LOCAL, IAccountManager::NOT_VERIFIED); + + $manager = $this->getInstance(['getUser', 'updateUser']); + $manager->method('getUser')->with($user, false)->willReturn([]); + $this->config->method('getSystemValueString')->willReturn(''); + $this->config->method('getSystemValue') + ->willReturnMap([ + ['account_manager.default_property_scope', [], []], + ['account_manager.max_property_scope', [], [IAccountManager::PROPERTY_WEBSITE => IAccountManager::SCOPE_LOCAL]], + ]); + $manager->expects($this->once())->method('updateUser'); + + $manager->updateAccount($account); + } + + public function testUpdateAccountIgnoresInvalidMaxScopeConfig(): void { + $user = $this->createMock(IUser::class); + $account = new Account($user); + $account->setProperty(IAccountManager::PROPERTY_WEBSITE, 'https://example.com', IAccountManager::SCOPE_FEDERATED, IAccountManager::NOT_VERIFIED); + + $manager = $this->getInstance(['getUser', 'updateUser']); + $manager->method('getUser')->with($user, false)->willReturn([]); + $this->config->method('getSystemValueString')->willReturn(''); + $this->config->method('getSystemValue') + ->willReturnMap([ + ['account_manager.default_property_scope', [], []], + // 'not-a-scope' is not a valid scope value, so no ceiling applies + ['account_manager.max_property_scope', [], [IAccountManager::PROPERTY_WEBSITE => 'not-a-scope']], + ]); + $manager->expects($this->once())->method('updateUser'); + + $manager->updateAccount($account); + } }