Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22630a5ce6 |
@@ -80,6 +80,22 @@ class ShareAPIController extends OCSController {
|
||||
private ?Node $lockedNode = null;
|
||||
private array $trustedServerCache = [];
|
||||
|
||||
/**
|
||||
* Per-request user object cache to avoid repeated backend lookups when
|
||||
* formatting multiple shares for the same owner, sharer, or recipient.
|
||||
* Keyed by UID. Stores null to avoid re-querying a UID that doesn't exist.
|
||||
*
|
||||
* @var array<string, \OCP\IUser|null>
|
||||
*/
|
||||
private array $userObjectCache = [];
|
||||
|
||||
/**
|
||||
* Per-request group object cache for the same reason as $userObjectCache.
|
||||
*
|
||||
* @var array<string, \OCP\IGroup|null>
|
||||
*/
|
||||
private array $groupObjectCache = [];
|
||||
|
||||
/**
|
||||
* Share20OCS constructor.
|
||||
*/
|
||||
@@ -110,19 +126,47 @@ class ShareAPIController extends OCSController {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a user object, using the request-scoped cache to avoid repeated
|
||||
* backend calls for the same UID within a single API response.
|
||||
* Uses array_key_exists() so that UIDs resolving to null are cached and
|
||||
* not re-queried (e.g. shares from deleted users).
|
||||
*
|
||||
* @since 34.0.0
|
||||
* @internal
|
||||
*/
|
||||
private function getCachedUser(string $uid): ?\OCP\IUser {
|
||||
if (!array_key_exists($uid, $this->userObjectCache)) {
|
||||
$this->userObjectCache[$uid] = $this->userManager->get($uid);
|
||||
}
|
||||
return $this->userObjectCache[$uid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a group object, using the request-scoped cache to avoid repeated
|
||||
* backend calls for the same GID within a single API response.
|
||||
*
|
||||
* @since 34.0.0
|
||||
* @internal
|
||||
*/
|
||||
private function getCachedGroup(string $gid): ?\OCP\IGroup {
|
||||
if (!array_key_exists($gid, $this->groupObjectCache)) {
|
||||
$this->groupObjectCache[$gid] = $this->groupManager->get($gid);
|
||||
}
|
||||
return $this->groupObjectCache[$gid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IShare to an array for OCS output
|
||||
*
|
||||
* @param IShare $share
|
||||
* @param Node|null $recipientNode
|
||||
* @return Files_SharingShare
|
||||
* @throws NotFoundException In case the node can't be resolved.
|
||||
*
|
||||
* @suppress PhanUndeclaredClassMethod
|
||||
*/
|
||||
protected function formatShare(IShare $share, ?Node $recipientNode = null): array {
|
||||
$sharedBy = $this->userManager->get($share->getSharedBy());
|
||||
$shareOwner = $this->userManager->get($share->getShareOwner());
|
||||
$sharedBy = $this->getCachedUser($share->getSharedBy());
|
||||
$shareOwner = $this->getCachedUser($share->getShareOwner());
|
||||
|
||||
$isOwnShare = false;
|
||||
if ($shareOwner !== null) {
|
||||
@@ -240,7 +284,7 @@ class ShareAPIController extends OCSController {
|
||||
}
|
||||
|
||||
if ($share->getShareType() === IShare::TYPE_USER) {
|
||||
$sharedWith = $this->userManager->get($share->getSharedWith());
|
||||
$sharedWith = $this->getCachedUser($share->getSharedWith());
|
||||
$result['share_with'] = $share->getSharedWith();
|
||||
$result['share_with_displayname'] = $sharedWith !== null ? $sharedWith->getDisplayName() : $share->getSharedWith();
|
||||
$result['share_with_displayname_unique'] = $sharedWith !== null ? (
|
||||
@@ -260,7 +304,7 @@ class ShareAPIController extends OCSController {
|
||||
];
|
||||
}
|
||||
} elseif ($share->getShareType() === IShare::TYPE_GROUP) {
|
||||
$group = $this->groupManager->get($share->getSharedWith());
|
||||
$group = $this->getCachedGroup($share->getSharedWith());
|
||||
$result['share_with'] = $share->getSharedWith();
|
||||
$result['share_with_displayname'] = $group !== null ? $group->getDisplayName() : $share->getSharedWith();
|
||||
} elseif ($share->getShareType() === IShare::TYPE_LINK) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
namespace OCA\Settings\Controller;
|
||||
|
||||
use OC\Settings\AuthorizedGroup;
|
||||
use OCA\Settings\Service\AuthorizedGroupService;
|
||||
use OCA\Settings\Service\NotFoundException;
|
||||
use OCP\AppFramework\Controller;
|
||||
@@ -26,37 +25,13 @@ class AuthorizedGroupController extends Controller {
|
||||
/**
|
||||
* @throws NotFoundException
|
||||
* @throws Exception
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function saveSettings(array $newGroups, string $class): DataResponse {
|
||||
$currentGroups = $this->authorizedGroupService->findExistingGroupsForClass($class);
|
||||
|
||||
foreach ($currentGroups as $group) {
|
||||
/** @var AuthorizedGroup $group */
|
||||
$removed = true;
|
||||
foreach ($newGroups as $groupData) {
|
||||
if ($groupData['gid'] === $group->getGroupId()) {
|
||||
$removed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($removed) {
|
||||
$this->authorizedGroupService->delete($group->getId());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newGroups as $groupData) {
|
||||
$added = true;
|
||||
foreach ($currentGroups as $group) {
|
||||
/** @var AuthorizedGroup $group */
|
||||
if ($groupData['gid'] === $group->getGroupId()) {
|
||||
$added = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($added) {
|
||||
$this->authorizedGroupService->create($groupData['gid'], $class);
|
||||
}
|
||||
}
|
||||
// Delegate the diff-and-apply logic to the service so that the cache
|
||||
// is flushed exactly once after all mutations, regardless of how many
|
||||
// groups were added or removed.
|
||||
$this->authorizedGroupService->saveSettings($newGroups, $class);
|
||||
|
||||
return new DataResponse(['valid' => true]);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,74 @@ readonly class AuthorizedGroupService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new AuthorizedGroup
|
||||
* Applies a bulk delegation update for a setting class in a single
|
||||
* transaction-like sequence, then invalidates the cache exactly once.
|
||||
*
|
||||
* @param list<array{gid: string}> $newGroups Desired final state
|
||||
* @throws Exception
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function saveSettings(array $newGroups, string $class): void {
|
||||
$currentGroups = $this->findExistingGroupsForClass($class);
|
||||
|
||||
foreach ($currentGroups as $group) {
|
||||
$removed = true;
|
||||
foreach ($newGroups as $groupData) {
|
||||
if ($groupData['gid'] === $group->getGroupId()) {
|
||||
$removed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
try {
|
||||
// $group is already a hydrated AuthorizedGroup entity
|
||||
// returned by findExistingGroupsForClass()
|
||||
$this->mapper->delete($group);
|
||||
} catch (\Exception $exception) {
|
||||
$this->handleException($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We attempt the insert unconditionally and treat a unique-
|
||||
// constraint violation as idempotent — cheaper than a read-before-write
|
||||
// and race-safe under concurrent saveSettings() calls.
|
||||
foreach ($newGroups as $groupData) {
|
||||
$added = true;
|
||||
foreach ($currentGroups as $group) {
|
||||
if ($groupData['gid'] === $group->getGroupId()) {
|
||||
$added = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($added) {
|
||||
try {
|
||||
$newGroup = new AuthorizedGroup();
|
||||
$newGroup->setGroupId($groupData['gid']);
|
||||
$newGroup->setClass($class);
|
||||
$this->mapper->insert($newGroup);
|
||||
} catch (Exception $e) {
|
||||
// The DB unique constraint prevented the duplicate
|
||||
// so treat as idempotent success.
|
||||
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->mapper->clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new AuthorizedGroup and invalidate the distributed cache.
|
||||
*
|
||||
* Adding a delegation may grant access to every current member of the
|
||||
* affected group. A full cache clear is used rather than per-user
|
||||
* invalidation to avoid an extra group-membership backend call at write
|
||||
* time.
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws ConflictException
|
||||
@@ -73,7 +140,14 @@ readonly class AuthorizedGroupService {
|
||||
$authorizedGroup = new AuthorizedGroup();
|
||||
$authorizedGroup->setGroupId($groupId);
|
||||
$authorizedGroup->setClass($class);
|
||||
return $this->mapper->insert($authorizedGroup);
|
||||
|
||||
$result = $this->mapper->insert($authorizedGroup);
|
||||
|
||||
// Invalidate after successful insert so the next request re-evaluates
|
||||
// all users' authorized classes.
|
||||
$this->mapper->clearCache();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +158,8 @@ readonly class AuthorizedGroupService {
|
||||
try {
|
||||
$authorizedGroup = $this->mapper->find($id);
|
||||
$this->mapper->delete($authorizedGroup);
|
||||
// Revoking a delegation must take effect immediately.
|
||||
$this->mapper->clearCache();
|
||||
} catch (\Exception $exception) {
|
||||
$this->handleException($exception);
|
||||
}
|
||||
@@ -107,6 +183,9 @@ readonly class AuthorizedGroupService {
|
||||
public function removeAuthorizationAssociatedTo(IGroup $group): void {
|
||||
try {
|
||||
$this->mapper->removeGroup($group->getGID());
|
||||
// Group deletion removes all delegations for that GID
|
||||
// all affected users need re-evaluation on their next request
|
||||
$this->mapper->clearCache();
|
||||
} catch (\Exception $exception) {
|
||||
$this->handleException($exception);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
namespace OCA\Settings\Tests\Controller\Admin;
|
||||
|
||||
use OC\Settings\AuthorizedGroup;
|
||||
use OCA\Settings\Controller\AuthorizedGroupController;
|
||||
use OCA\Settings\Service\AuthorizedGroupService;
|
||||
use OCP\IRequest;
|
||||
@@ -28,26 +27,14 @@ class DelegationControllerTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testSaveSettings(): void {
|
||||
$setting = 'MySecretSetting';
|
||||
$oldGroups = [];
|
||||
$oldGroups[] = AuthorizedGroup::fromParams(['groupId' => 'hello', 'class' => $setting]);
|
||||
$goodbye = AuthorizedGroup::fromParams(['groupId' => 'goodbye', 'class' => $setting, 'id' => 42]);
|
||||
$oldGroups[] = $goodbye;
|
||||
$this->service->expects($this->once())
|
||||
->method('findExistingGroupsForClass')
|
||||
->with('MySecretSetting')
|
||||
->willReturn($oldGroups);
|
||||
$newGroups = [['gid' => 'hello'], ['gid' => 'world']];
|
||||
|
||||
// The controller delegates the entire diff-and-apply to the service.
|
||||
$this->service->expects($this->once())
|
||||
->method('delete')
|
||||
->with(42);
|
||||
->method('saveSettings')
|
||||
->with($newGroups, 'MySecretSetting');
|
||||
|
||||
$this->service->expects($this->once())
|
||||
->method('create')
|
||||
->with('world', 'MySecretSetting')
|
||||
->willReturn(AuthorizedGroup::fromParams(['groupId' => 'world', 'class' => $setting]));
|
||||
|
||||
$result = $this->controller->saveSettings([['gid' => 'hello'], ['gid' => 'world']], 'MySecretSetting');
|
||||
$result = $this->controller->saveSettings($newGroups, 'MySecretSetting');
|
||||
|
||||
$this->assertEquals(['valid' => true], $result->getData());
|
||||
}
|
||||
|
||||
@@ -155,4 +155,133 @@ class AuthorizedGroupServiceTest extends TestCase {
|
||||
$this->assertEquals($class1, $result1->getClass());
|
||||
$this->assertEquals($class2, $result2->getClass());
|
||||
}
|
||||
|
||||
public function testCreateClearsCacheAfterSuccessfulInsertion(): void {
|
||||
$this->mapper->method('findByGroupIdAndClass')
|
||||
->willThrowException(new DoesNotExistException('not found'));
|
||||
|
||||
$inserted = new AuthorizedGroup();
|
||||
$inserted->setGroupId('admins');
|
||||
$inserted->setClass('OCA\Settings\Admin\Security');
|
||||
$inserted->setId(1);
|
||||
$this->mapper->method('insert')->willReturn($inserted);
|
||||
|
||||
// Cache must be cleared after a new delegation is persisted so that
|
||||
// every member of the group sees updated access on their next request.
|
||||
$this->mapper->expects($this->once())->method('clearCache');
|
||||
|
||||
$this->service->create('admins', 'OCA\Settings\Admin\Security');
|
||||
}
|
||||
|
||||
public function testCreateDoesNotClearCacheWhenConflictExceptionIsThrown(): void {
|
||||
$existing = new AuthorizedGroup();
|
||||
$existing->setGroupId('admins');
|
||||
$existing->setClass('OCA\Settings\Admin\Security');
|
||||
$this->mapper->method('findByGroupIdAndClass')->willReturn($existing);
|
||||
|
||||
// No DB write occurs, so the cache must not be invalidated.
|
||||
$this->mapper->expects($this->never())->method('clearCache');
|
||||
|
||||
$this->expectException(ConflictException::class);
|
||||
$this->service->create('admins', 'OCA\Settings\Admin\Security');
|
||||
}
|
||||
|
||||
public function testDeleteClearsCacheAfterSuccessfulDeletion(): void {
|
||||
$group = new AuthorizedGroup();
|
||||
$group->setId(1);
|
||||
$this->mapper->method('find')->with(1)->willReturn($group);
|
||||
|
||||
// Revoking a delegation must take effect immediately
|
||||
$this->mapper->expects($this->once())->method('clearCache');
|
||||
|
||||
$this->service->delete(1);
|
||||
}
|
||||
|
||||
public function testRemoveAuthorizationAssociatedToClearsCacheOnGroupDeletion(): void {
|
||||
$iGroup = $this->createMock(\OCP\IGroup::class);
|
||||
$iGroup->method('getGID')->willReturn('devs');
|
||||
|
||||
// All delegations for the GID are removed
|
||||
$this->mapper->expects($this->once())->method('clearCache');
|
||||
|
||||
$this->service->removeAuthorizationAssociatedTo($iGroup);
|
||||
}
|
||||
|
||||
public function testSaveSettingsFlushesExactlyOnce(): void {
|
||||
// Current state: group 'admins' is authorized.
|
||||
$existing = new AuthorizedGroup();
|
||||
$existing->setId(1);
|
||||
$existing->setGroupId('admins');
|
||||
$existing->setClass('OCA\Settings\Admin\Security');
|
||||
|
||||
$this->mapper->method('findExistingGroupsForClass')
|
||||
->willReturn([$existing]);
|
||||
|
||||
// New state: 'admins' removed, 'editors' added.
|
||||
// findByGroupIdAndClass must throw so the insert proceeds.
|
||||
$this->mapper->method('findByGroupIdAndClass')
|
||||
->willThrowException(new DoesNotExistException('not found'));
|
||||
|
||||
// Key assertion: clearCache() called exactly once for the two mutations.
|
||||
$this->mapper->expects($this->once())->method('clearCache');
|
||||
$this->mapper->expects($this->once())->method('delete')->with($existing);
|
||||
$this->mapper->expects($this->once())->method('insert');
|
||||
|
||||
$this->service->saveSettings([['gid' => 'editors']], 'OCA\Settings\Admin\Security');
|
||||
}
|
||||
|
||||
public function testSaveSettingsDoesNotCallMapperFindForDeletion(): void {
|
||||
// Verifies that deletion uses the already-hydrated entity from
|
||||
// findExistingGroupsForClass() without an extra mapper->find() call.
|
||||
$existing = new AuthorizedGroup();
|
||||
$existing->setId(42);
|
||||
$existing->setGroupId('devs');
|
||||
$existing->setClass('OCA\Settings\Admin\Users');
|
||||
|
||||
$this->mapper->method('findExistingGroupsForClass')->willReturn([$existing]);
|
||||
$this->mapper->method('findByGroupIdAndClass')
|
||||
->willThrowException(new DoesNotExistException('not found'));
|
||||
|
||||
// mapper->find() must NEVER be called
|
||||
$this->mapper->expects($this->never())->method('find');
|
||||
$this->mapper->expects($this->once())->method('delete')->with($existing);
|
||||
$this->mapper->expects($this->once())->method('clearCache');
|
||||
|
||||
// Remove 'devs', add nothing.
|
||||
$this->service->saveSettings([], 'OCA\Settings\Admin\Users');
|
||||
}
|
||||
|
||||
public function testSaveSettingsHandlesConcurrentInsertViaUniqueConstraint(): void {
|
||||
// Simulates a concurrent insert: another process inserted the row between
|
||||
// our snapshot read and our insert attempt. The DB unique constraint fires
|
||||
// and we must treat it as idempotent (no exception propagated).
|
||||
$this->mapper->method('findExistingGroupsForClass')->willReturn([]);
|
||||
|
||||
// OCP\DB\Exception base class has getReason()
|
||||
// overrides it. Use a mock so getReason() returns the expected reason code.
|
||||
$uniqueViolation = $this->createMock(\OCP\DB\Exception::class);
|
||||
$uniqueViolation->method('getReason')
|
||||
->willReturn(\OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION);
|
||||
$this->mapper->method('insert')->willThrowException($uniqueViolation);
|
||||
|
||||
// clearCache() must still be called
|
||||
$this->mapper->expects($this->once())->method('clearCache');
|
||||
|
||||
// Must not throw, unique constraint violation is handled as idempotent.
|
||||
$this->service->saveSettings([['gid' => 'ops']], 'OCA\Settings\Admin\Security');
|
||||
}
|
||||
|
||||
public function testSaveSettingsPropagatesNonUniqueDbExceptions(): void {
|
||||
$this->mapper->method('findExistingGroupsForClass')->willReturn([]);
|
||||
|
||||
// Use a mock so getReason() returns a non-unique reason code, causing
|
||||
// saveSettings() to re-throw the exception.
|
||||
$otherDbError = $this->createMock(\OCP\DB\Exception::class);
|
||||
$otherDbError->method('getReason')
|
||||
->willReturn(\OCP\DB\Exception::REASON_CONNECTION_LOST);
|
||||
$this->mapper->method('insert')->willThrowException($otherDbError);
|
||||
|
||||
$this->expectException(\OCP\DB\Exception::class);
|
||||
$this->service->saveSettings([['gid' => 'editors']], 'OCA\Settings\Admin\Security');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\Core\Migrations;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\Attributes\AddIndex;
|
||||
use OCP\Migration\Attributes\DataCleansing;
|
||||
use OCP\Migration\Attributes\DropIndex;
|
||||
use OCP\Migration\Attributes\IndexType;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Adds a UNIQUE constraint on (group_id, class) to the authorized_groups table.
|
||||
*
|
||||
* Without this constraint, a narrow concurrent-write race in
|
||||
* AuthorizedGroupService::saveSettings() could insert duplicate rows, causing
|
||||
* findAllClassesForUser() to return the same authorized class name more than
|
||||
* once. The constraint makes the database the authoritative guard.
|
||||
*
|
||||
* preSchemaChange() removes any pre-existing duplicate rows (keeping the row
|
||||
* with the lowest id per pair) so that the index creation cannot fail on
|
||||
* instances that already have duplicates from before this migration.
|
||||
*/
|
||||
#[DataCleansing(table: 'authorized_groups', description: 'Remove duplicate (group_id, class) rows before adding unique index')]
|
||||
#[DropIndex(table: 'authorized_groups', type: IndexType::INDEX, description: 'Drop non-unique admindel_groupid_idx, superseded by the new unique index')]
|
||||
#[AddIndex(table: 'authorized_groups', type: IndexType::UNIQUE, description: 'Add unique index on (group_id, class) to prevent concurrent duplicate inserts')]
|
||||
class Version34000Date20260331000000 extends SimpleMigrationStep {
|
||||
|
||||
public function __construct(
|
||||
private readonly IDBConnection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate (group_id, class) rows before the schema change.
|
||||
* Keeps the row with the lowest id for each pair.
|
||||
*
|
||||
*/
|
||||
#[Override]
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
|
||||
// Fetch all rows ordered so that within each (group_id, class) pair the
|
||||
// lowest id comes first — the first occurrence is the one we keep.
|
||||
$result = $qb->select('id', 'group_id', 'class')
|
||||
->from('authorized_groups')
|
||||
->orderBy('group_id')
|
||||
->addOrderBy('class')
|
||||
->addOrderBy('id')
|
||||
->executeQuery();
|
||||
|
||||
/** @var array<string, true> $seen */
|
||||
$seen = [];
|
||||
/** @var list<int> $idsToDelete */
|
||||
$idsToDelete = [];
|
||||
|
||||
while ($row = $result->fetch()) {
|
||||
// Use NUL byte as separator — group_id and class are both max 200 chars
|
||||
// of arbitrary text, so we need a separator that cannot appear in either.
|
||||
$key = $row['group_id'] . "\0" . $row['class'];
|
||||
if (isset($seen[$key])) {
|
||||
$idsToDelete[] = (int)$row['id'];
|
||||
} else {
|
||||
$seen[$key] = true;
|
||||
}
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
if ($idsToDelete === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$output->info(sprintf(
|
||||
'authorized_groups: removing %d duplicate row(s) before adding unique index.',
|
||||
count($idsToDelete)
|
||||
));
|
||||
|
||||
// Delete in chunks of 1000 to stay within query-parameter limits across
|
||||
// all supported database engines.
|
||||
foreach (array_chunk($idsToDelete, 1000) as $chunk) {
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
$qb->delete('authorized_groups')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
|
||||
->executeStatement();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the UNIQUE index on (group_id, class) and drop the superseded
|
||||
* plain index on group_id.
|
||||
*/
|
||||
#[Override]
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('authorized_groups')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('authorized_groups');
|
||||
|
||||
// Idempotency guard — safe to run twice (e.g. after a failed upgrade retry).
|
||||
if ($table->hasIndex('admindel_group_class_uniq')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The original index on group_id alone is superseded by the new unique
|
||||
// index on (group_id, class): any lookup by group_id will use the
|
||||
// leftmost column of the composite index.
|
||||
if ($table->hasIndex('admindel_groupid_idx')) {
|
||||
$table->dropIndex('admindel_groupid_idx');
|
||||
}
|
||||
|
||||
$table->addUniqueIndex(['group_id', 'class'], 'admindel_group_class_uniq');
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -1597,6 +1597,7 @@ return array(
|
||||
'OC\\Core\\Migrations\\Version33000Date20251209123503' => $baseDir . '/core/Migrations/Version33000Date20251209123503.php',
|
||||
'OC\\Core\\Migrations\\Version33000Date20260126120000' => $baseDir . '/core/Migrations/Version33000Date20260126120000.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260318095645' => $baseDir . '/core/Migrations/Version34000Date20260318095645.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260331000000' => $baseDir . '/core/Migrations/Version34000Date20260331000000.php',
|
||||
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
|
||||
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
|
||||
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',
|
||||
|
||||
@@ -1638,6 +1638,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
|
||||
'OC\\Core\\Migrations\\Version33000Date20251209123503' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20251209123503.php',
|
||||
'OC\\Core\\Migrations\\Version33000Date20260126120000' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20260126120000.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260318095645' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260318095645.php',
|
||||
'OC\\Core\\Migrations\\Version34000Date20260331000000' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260331000000.php',
|
||||
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
|
||||
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
|
||||
'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php',
|
||||
|
||||
@@ -60,6 +60,14 @@ class SecurityMiddleware extends Middleware {
|
||||
private ?bool $isAdminUser = null;
|
||||
private ?bool $isSubAdmin = null;
|
||||
|
||||
/**
|
||||
* Lazily-resolved list of authorized admin-setting classes for the current
|
||||
* user. Null means not yet resolved; an empty array means resolved but empty.
|
||||
*
|
||||
* @var list<string>|null
|
||||
*/
|
||||
private ?array $authorizedClassesCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly IRequest $request,
|
||||
private readonly MiddlewareUtils $middlewareUtils,
|
||||
@@ -94,6 +102,25 @@ class SecurityMiddleware extends Middleware {
|
||||
return $this->isSubAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the setting classes the current user is delegated access to,
|
||||
* memoizing the result for the lifetime of this middleware instance
|
||||
* (i.e., the current request).
|
||||
*
|
||||
* @return list<string>
|
||||
* @since 34.0.0
|
||||
* @internal
|
||||
*/
|
||||
private function getAuthorizedClasses(): array {
|
||||
if ($this->authorizedClassesCache === null) {
|
||||
$user = $this->userSession->getUser();
|
||||
$this->authorizedClassesCache = $user !== null
|
||||
? $this->groupAuthorizationMapper->findAllClassesForUser($user)
|
||||
: [];
|
||||
}
|
||||
return $this->authorizedClassesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* This runs all the security checks before a method call. The
|
||||
* security checks are determined by inspecting the controller method
|
||||
@@ -146,7 +173,7 @@ class SecurityMiddleware extends Middleware {
|
||||
|
||||
if (!$authorized) {
|
||||
$settingClasses = $this->middlewareUtils->getAuthorizedAdminSettingClasses($reflectionMethod);
|
||||
$authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser());
|
||||
$authorizedClasses = $this->getAuthorizedClasses();
|
||||
foreach ($settingClasses as $settingClass) {
|
||||
$authorized = in_array($settingClass, $authorizedClasses, true);
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\ICache;
|
||||
use OCP\ICacheFactory;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
@@ -23,35 +25,118 @@ use OCP\Server;
|
||||
* @template-extends QBMapper<AuthorizedGroup>
|
||||
*/
|
||||
class AuthorizedGroupMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
|
||||
private const CACHE_PREFIX = 'nc_authorized_group_';
|
||||
|
||||
private const CACHE_TTL_DISTRIBUTED = 300;
|
||||
|
||||
private const CACHE_TTL_LOCAL = 60;
|
||||
|
||||
private readonly ICache $distributedCache;
|
||||
|
||||
private readonly ICache $localCache;
|
||||
|
||||
/**
|
||||
* @param IDBConnection $db The database connection
|
||||
* @param ICacheFactory $cacheFactory Factory to create the cache instances
|
||||
*/
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
private readonly ICacheFactory $cacheFactory,
|
||||
) {
|
||||
parent::__construct($db, 'authorized_groups', AuthorizedGroup::class);
|
||||
// Distributed so multiple nodes share the same cached delegation map.
|
||||
$this->distributedCache = $this->cacheFactory->createDistributed(self::CACHE_PREFIX);
|
||||
// Per-process shield: absorbs burst traffic on a single node without
|
||||
// hitting the distributed cache (and network) on every request.
|
||||
$this->localCache = $this->cacheFactory->createLocal(self::CACHE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
* Returns all setting class names that the given user is authorized to
|
||||
* access via group delegation.
|
||||
*
|
||||
* Uses a two-tier cache strategy:
|
||||
* 1. Per-process local cache (TTL: {@see self::CACHE_TTL_LOCAL} s) — shields
|
||||
* the distributed cache from burst traffic within a single node.
|
||||
* 2. Distributed cache (TTL: {@see self::CACHE_TTL_DISTRIBUTED} s) — shared
|
||||
* across all cluster nodes; populated on cold path, backfilled to local
|
||||
* tier on hit to short-circuit subsequent intra-process calls.
|
||||
*
|
||||
* @return list<string> Fully-qualified class names of authorized settings
|
||||
* @throws Exception When the database query fails
|
||||
*/
|
||||
public function findAllClassesForUser(IUser $user): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$uid = $user->getUID();
|
||||
$cacheKey = 'user_' . $uid;
|
||||
|
||||
/** @var list<string>|null $local */
|
||||
$local = $this->localCache->get($cacheKey);
|
||||
if ($local !== null) {
|
||||
return $local;
|
||||
}
|
||||
|
||||
/** @var list<string>|null $distributed */
|
||||
$distributed = $this->distributedCache->get($cacheKey);
|
||||
if ($distributed !== null) {
|
||||
// Backfill local tier to short-circuit future intra-process calls.
|
||||
$this->localCache->set($cacheKey, $distributed, self::CACHE_TTL_LOCAL);
|
||||
return $distributed;
|
||||
}
|
||||
|
||||
$groupManager = Server::get(IGroupManager::class);
|
||||
$groups = $groupManager->getUserGroups($user);
|
||||
if (count($groups) === 0) {
|
||||
// Cache empty result to avoid repeated backend calls for users
|
||||
// who belong to no groups.
|
||||
$this->localCache->set($cacheKey, [], self::CACHE_TTL_LOCAL);
|
||||
$this->distributedCache->set($cacheKey, [], self::CACHE_TTL_DISTRIBUTED);
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
/** @var list<string> $rows */
|
||||
$rows = $qb->select('class')
|
||||
->from($this->getTableName(), 'auth')
|
||||
->where($qb->expr()->in('group_id', array_map(static fn (IGroup $group) => $qb->createNamedParameter($group->getGID()), $groups), IQueryBuilder::PARAM_STR))
|
||||
->where($qb->expr()->in(
|
||||
'group_id',
|
||||
array_map(
|
||||
static fn (IGroup $group) => $qb->createNamedParameter($group->getGID()),
|
||||
$groups
|
||||
),
|
||||
IQueryBuilder::PARAM_STR
|
||||
))
|
||||
->executeQuery()
|
||||
->fetchFirstColumn();
|
||||
|
||||
$this->localCache->set($cacheKey, $rows, self::CACHE_TTL_LOCAL);
|
||||
$this->distributedCache->set($cacheKey, $rows, self::CACHE_TTL_DISTRIBUTED);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
* @throws MultipleObjectsReturnedException
|
||||
* Clears the cached authorized-classes entry for a specific user.
|
||||
*
|
||||
* Must be called whenever a group delegation is added or removed so that
|
||||
* the next request re-evaluates the user's authorizations.
|
||||
*/
|
||||
public function clearUserCache(string $userId): void {
|
||||
$key = 'user_' . $userId;
|
||||
$this->localCache->remove($key);
|
||||
$this->distributedCache->remove($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all cached authorized-classes entries across both cache tiers.
|
||||
*/
|
||||
public function clearCache(): void {
|
||||
$this->localCache->clear();
|
||||
$this->distributedCache->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function find(int $id): AuthorizedGroup {
|
||||
|
||||
@@ -1351,10 +1351,20 @@ class DefaultShareProvider implements
|
||||
$userGroups = $this->groupManager->getUserGroupIds($user);
|
||||
$userGroups = array_diff($userGroups, $this->shareManager->shareWithGroupMembersOnlyExcludeGroupsList());
|
||||
|
||||
// Method-local cache: avoids repeated backend lookups for the same
|
||||
// UID across both loops. Uses array_key_exists() (not isset()) so
|
||||
// that UIDs resolving to null are cached and not re-queried.
|
||||
/** @var array<string, \OCP\IUser|null> $userCache */
|
||||
$userCache = [];
|
||||
|
||||
// Delete user shares received by the user from users in the group.
|
||||
$userReceivedShares = $this->shareManager->getSharedWith($uid, IShare::TYPE_USER, null, -1);
|
||||
foreach ($userReceivedShares as $share) {
|
||||
$owner = $this->userManager->get($share->getSharedBy());
|
||||
$ownerUid = $share->getSharedBy();
|
||||
if (!array_key_exists($ownerUid, $userCache)) {
|
||||
$userCache[$ownerUid] = $this->userManager->get($ownerUid);
|
||||
}
|
||||
$owner = $userCache[$ownerUid];
|
||||
if ($owner === null) {
|
||||
continue;
|
||||
}
|
||||
@@ -1369,7 +1379,11 @@ class DefaultShareProvider implements
|
||||
// Delete user shares from the user to users in the group.
|
||||
$userEmittedShares = $this->shareManager->getSharesBy($uid, IShare::TYPE_USER, null, true, -1);
|
||||
foreach ($userEmittedShares as $share) {
|
||||
$recipient = $this->userManager->get($share->getSharedWith());
|
||||
$recipientUid = $share->getSharedWith();
|
||||
if (!array_key_exists($recipientUid, $userCache)) {
|
||||
$userCache[$recipientUid] = $this->userManager->get($recipientUid);
|
||||
}
|
||||
$recipient = $userCache[$recipientUid];
|
||||
if ($recipient === null) {
|
||||
continue;
|
||||
}
|
||||
@@ -1423,6 +1437,13 @@ class DefaultShareProvider implements
|
||||
|
||||
$users = [];
|
||||
$link = false;
|
||||
// Deduplicate group->getUsers() calls within this invocation.
|
||||
// A group can appear multiple times when multiple nodes in $nodes are
|
||||
// covered by the same group share (e.g. nested folder tree).
|
||||
// getUsers() on LDAP / large DB backends is expensive; cache per GID.
|
||||
/** @var array<string, list<\OCP\IUser>> $groupUsersCache */
|
||||
$groupUsersCache = [];
|
||||
|
||||
while ($row = $cursor->fetch()) {
|
||||
$type = (int)$row['share_type'];
|
||||
if ($type === IShare::TYPE_USER) {
|
||||
@@ -1431,14 +1452,14 @@ class DefaultShareProvider implements
|
||||
$users[$uid][$row['id']] = $row;
|
||||
} elseif ($type === IShare::TYPE_GROUP) {
|
||||
$gid = $row['share_with'];
|
||||
$group = $this->groupManager->get($gid);
|
||||
|
||||
if ($group === null) {
|
||||
continue;
|
||||
if (!isset($groupUsersCache[$gid])) {
|
||||
$group = $this->groupManager->get($gid);
|
||||
// Null means the group was deleted after the share was created.
|
||||
$groupUsersCache[$gid] = $group?->getUsers() ?? [];
|
||||
}
|
||||
|
||||
$userList = $group->getUsers();
|
||||
foreach ($userList as $user) {
|
||||
foreach ($groupUsersCache[$gid] as $user) {
|
||||
$uid = $user->getUID();
|
||||
$users[$uid] = $users[$uid] ?? [];
|
||||
$users[$uid][$row['id']] = $row;
|
||||
|
||||
@@ -10,6 +10,7 @@ declare(strict_types=1);
|
||||
namespace Test\AppFramework\Middleware\Security\Mock;
|
||||
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
|
||||
use OCP\AppFramework\Http\Attribute\ExAppRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
@@ -168,4 +169,9 @@ class SecurityMiddlewareController extends Controller {
|
||||
#[ExAppRequired]
|
||||
public function testAttributeExAppRequired() {
|
||||
}
|
||||
|
||||
#[AuthorizedAdminSetting(settings: 'OCA\Settings\Admin\Security')]
|
||||
#[NoCSRFRequired]
|
||||
public function testAttributeAuthorizedAdminSetting(): void {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,12 +410,6 @@ class SecurityMiddlewareTest extends \Test\TestCase {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $controllerClass
|
||||
* @param bool $hasOcsApiHeader
|
||||
* @param bool $hasBearerAuth
|
||||
* @param bool $exception
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('dataCsrfOcsController')]
|
||||
public function testCsrfOcsController(string $controllerClass, bool $hasOcsApiHeader, bool $hasBearerAuth, bool $exception): void {
|
||||
$this->request
|
||||
@@ -621,9 +615,6 @@ class SecurityMiddlewareTest extends \Test\TestCase {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SecurityException $exception
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('exceptionProvider')]
|
||||
public function testAfterExceptionReturnsTemplateResponse(SecurityException $exception): void {
|
||||
$this->request = new Request(
|
||||
@@ -689,4 +680,66 @@ class SecurityMiddlewareTest extends \Test\TestCase {
|
||||
$this->expectException(ExAppRequiredException::class);
|
||||
$middleware->beforeController($this->controller, $method);
|
||||
}
|
||||
|
||||
public function testFindAllClassesForUserIsCalledOnceWhenAuthorizedAdminSettingIsEvaluated(): void {
|
||||
$middleware = $this->getMiddleware(
|
||||
isLoggedIn: true,
|
||||
isAdminUser: false,
|
||||
isSubAdmin: false,
|
||||
);
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn('delegated_user');
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
// Key assertion: even if beforeController() is called twice on the
|
||||
// same instance, findAllClassesForUser() must fire only once.
|
||||
$this->authorizedGroupMapper
|
||||
->expects($this->once())
|
||||
->method('findAllClassesForUser')
|
||||
->with($user)
|
||||
->willReturn(['OCA\Settings\Admin\Security']);
|
||||
|
||||
$this->reader->reflect($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
$middleware->beforeController($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
// Second call on the same instance
|
||||
$middleware->beforeController($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
}
|
||||
|
||||
public function testFindAllClassesForUserIsNotCalledWhenUserIsNull(): void {
|
||||
$middleware = $this->getMiddleware(
|
||||
isLoggedIn: false,
|
||||
isAdminUser: false,
|
||||
isSubAdmin: false,
|
||||
);
|
||||
|
||||
$this->authorizedGroupMapper
|
||||
->expects($this->never())
|
||||
->method('findAllClassesForUser');
|
||||
|
||||
$this->expectException(NotLoggedInException::class);
|
||||
$this->reader->reflect($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
$middleware->beforeController($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin user takes the isAdminUser() early-return path and must never
|
||||
* reach findAllClassesForUser().
|
||||
*/
|
||||
public function testFindAllClassesForUserIsNotCalledWhenUserIsAlreadyAdmin(): void {
|
||||
$middleware = $this->getMiddleware(
|
||||
isLoggedIn: true,
|
||||
isAdminUser: true,
|
||||
isSubAdmin: false,
|
||||
);
|
||||
|
||||
$this->authorizedGroupMapper
|
||||
->expects($this->never())
|
||||
->method('findAllClassesForUser');
|
||||
|
||||
// Should not throw as admin is authorized unconditionally.
|
||||
$this->reader->reflect($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
$middleware->beforeController($this->controller, 'testAttributeAuthorizedAdminSetting');
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user