Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/settings/lib/Settings/Personal/PersonalInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { handleError } from '../../../utils/handlers.ts'
const {
federationEnabled,
lookupServerUploadEnabled,
maxPropertyScopes,
} = loadState('settings', 'accountParameters', {})

export default {
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions config/config.sample.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions lib/private/Accounts/AccountManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
19 changes: 19 additions & 0 deletions lib/public/Accounts/IAccountManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
55 changes: 55 additions & 0 deletions tests/lib/Accounts/AccountManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading