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
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'NCU\\Resource\\Permission\\AccessEntry' => $baseDir . '/lib/unstable/Resource/Permission/AccessEntry.php',
'NCU\\Resource\\Permission\\CorePermission' => $baseDir . '/lib/unstable/Resource/Permission/CorePermission.php',
'NCU\\Resource\\Permission\\EffectivePermissions' => $baseDir . '/lib/unstable/Resource/Permission/EffectivePermissions.php',
'NCU\\Resource\\Permission\\IAccessManager' => $baseDir . '/lib/unstable/Resource/Permission/IAccessManager.php',
'NCU\\Resource\\Permission\\IPermission' => $baseDir . '/lib/unstable/Resource/Permission/IPermission.php',
'NCU\\Resource\\Permission\\IPermissionManager' => $baseDir . '/lib/unstable/Resource/Permission/IPermissionManager.php',
'NCU\\Resource\\Permission\\IRestrictionProvider' => $baseDir . '/lib/unstable/Resource/Permission/IRestrictionProvider.php',
Expand Down Expand Up @@ -2317,6 +2318,7 @@
'OC\\Repair\\RepairLogoDimension' => $baseDir . '/lib/private/Repair/RepairLogoDimension.php',
'OC\\Repair\\RepairMimeTypes' => $baseDir . '/lib/private/Repair/RepairMimeTypes.php',
'OC\\Repair\\RepairSanitizeSystemTags' => $baseDir . '/lib/private/Repair/RepairSanitizeSystemTags.php',
'OC\\Resource\\Permission\\AccessManager' => $baseDir . '/lib/private/Resource/Permission/AccessManager.php',
'OC\\Resource\\Permission\\PermissionManager' => $baseDir . '/lib/private/Resource/Permission/PermissionManager.php',
'OC\\RichObjectStrings\\RichTextFormatter' => $baseDir . '/lib/private/RichObjectStrings/RichTextFormatter.php',
'OC\\RichObjectStrings\\Validator' => $baseDir . '/lib/private/RichObjectStrings/Validator.php',
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'NCU\\Resource\\Permission\\AccessEntry' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/AccessEntry.php',
'NCU\\Resource\\Permission\\CorePermission' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/CorePermission.php',
'NCU\\Resource\\Permission\\EffectivePermissions' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/EffectivePermissions.php',
'NCU\\Resource\\Permission\\IAccessManager' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/IAccessManager.php',
'NCU\\Resource\\Permission\\IPermission' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/IPermission.php',
'NCU\\Resource\\Permission\\IPermissionManager' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/IPermissionManager.php',
'NCU\\Resource\\Permission\\IRestrictionProvider' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/IRestrictionProvider.php',
Expand Down Expand Up @@ -2358,6 +2359,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Repair\\RepairLogoDimension' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairLogoDimension.php',
'OC\\Repair\\RepairMimeTypes' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairMimeTypes.php',
'OC\\Repair\\RepairSanitizeSystemTags' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairSanitizeSystemTags.php',
'OC\\Resource\\Permission\\AccessManager' => __DIR__ . '/../../..' . '/lib/private/Resource/Permission/AccessManager.php',
'OC\\Resource\\Permission\\PermissionManager' => __DIR__ . '/../../..' . '/lib/private/Resource/Permission/PermissionManager.php',
'OC\\RichObjectStrings\\RichTextFormatter' => __DIR__ . '/../../..' . '/lib/private/RichObjectStrings/RichTextFormatter.php',
'OC\\RichObjectStrings\\Validator' => __DIR__ . '/../../..' . '/lib/private/RichObjectStrings/Validator.php',
Expand Down
99 changes: 99 additions & 0 deletions lib/private/Resource/Permission/AccessManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OC\Resource\Permission;

use InvalidArgumentException;
use NCU\Resource\Permission\AccessEntry;
use NCU\Resource\Permission\IAccessManager;
use NCU\Resource\Permission\IPermissionManager;
use NCU\Resource\ResourceReference;
use OCP\Actor\Actor;
use OCP\Cache\CappedMemoryCache;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* Asks the resource types which access an actor has to resources.
*/
final class AccessManager implements IAccessManager {
/** @var CappedMemoryCache<list<AccessEntry>> access entries by actor key and resource key */
private CappedMemoryCache $cache;

public function __construct(
private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
) {
$this->cache = new CappedMemoryCache(2048);
}

#[\Override]
public function getAccess(Actor $actor, array $resources): array {
return array_filter($this->resolve($actor, $resources));
}

/**
* Get the access entries of the actor for every resource, the resources can be of different types.
*
* @param list<ResourceReference> $resources
* @return array<non-empty-string, ?list<AccessEntry>> keyed by {@see ResourceReference::key},
* an empty list if the actor has no access and null if the resource type failed
* @throws InvalidArgumentException if a resource type is not registered
*/
public function resolve(Actor $actor, array $resources): array {
// Resolved lazily: the permission manager depends on this class.
$permissionManager = $this->container->get(IPermissionManager::class);
$actorKey = $actor->key();

$results = [];
/** @var array<non-empty-string, list<ResourceReference>> $misses */
$misses = [];
foreach ($resources as $resource) {
if ($permissionManager->getResourceType($resource->type) === null) {
throw new InvalidArgumentException('The resource type ' . $resource->type . ' is not registered');
}

$resourceKey = $resource->key();
if (array_key_exists($resourceKey, $results)) {
continue;
}

$cached = $this->cache->get($actorKey . "\0" . $resourceKey);
$results[$resourceKey] = $cached;
if ($cached === null) {
$misses[$resource->type][] = $resource;
}
}

foreach ($misses as $type => $typeResources) {
$resourceType = $permissionManager->getResourceType($type);
try {
$access = $resourceType->getAccess($actor, $typeResources);
} catch (Throwable $e) {
// Not cached: the resource type may succeed on the next call.
$this->logger->error('The resource type {type} failed to get the access, no access is assumed', ['type' => $type, 'exception' => $e]);
continue;
}

foreach ($typeResources as $resource) {
$resourceKey = $resource->key();
$entries = array_values($access[$resourceKey] ?? []);
$this->cache->set($actorKey . "\0" . $resourceKey, $entries);
$results[$resourceKey] = $entries;
}
}

return $results;
}

public function clearCache(): void {
$this->cache->clear();
}
}
68 changes: 40 additions & 28 deletions lib/private/Resource/Permission/PermissionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function __construct(
private readonly Coordinator $coordinator,
private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
private readonly AccessManager $accessManager,
) {
$this->cache = new CappedMemoryCache(2048);
}
Expand Down Expand Up @@ -140,27 +141,46 @@ public function getResourceType(string $id): ?IResourceType {
}

#[\Override]
public function check(Actor $actor, ResourceReference $resource, array $entries = []): EffectivePermissions {
public function check(Actor $actor, ResourceReference $resource, ?array $entries = null): EffectivePermissions {
return $this->checkAll($actor, [new ResourceAccess($resource, $entries)])[$resource->key()];
}

#[\Override]
public function checkAll(Actor $actor, array $accesses): array {
$actorKey = $actor->key();
$nothing = new EffectivePermissions([]);
/** @var array<non-empty-string, EffectivePermissions> $results in the order of the accesses */
$results = [];
/** @var array<non-empty-string, ResourceAccess> $misses */
$misses = [];
$cacheKeys = [];
$unresolved = [];
foreach ($accesses as $access) {
$resourceKey = $access->resource->key();
if ($this->getResourceType($access->resource->type) === null) {
throw new InvalidArgumentException('The resource type ' . $access->resource->type . ' is not registered');
}
if (isset($results[$resourceKey]) || isset($misses[$resourceKey])) {
if (isset($results[$resourceKey])) {
throw new InvalidArgumentException('The resource ' . $resourceKey . ' is passed more than once');
}

$cacheKeys[$resourceKey] = $actorKey . "\0" . $resourceKey . "\0" . $this->hashEntries($access->entries);
$results[$resourceKey] = $nothing;
if ($access->entries === null) {
$unresolved[] = $access->resource;
}
}
$resolved = $unresolved === [] ? [] : $this->accessManager->resolve($actor, $unresolved);

/** @var array<non-empty-string, ResourceAccess> $misses */
$misses = [];
$cacheKeys = [];
foreach ($accesses as $access) {
$resourceKey = $access->resource->key();
// No entries are left if the resource type failed, this is not cached as it may succeed on the next check.
$entries = $access->entries ?? $resolved[$resourceKey] ?? [];
if ($entries === []) {
continue;
}

$access = new ResourceAccess($access->resource, $entries);
$cacheKeys[$resourceKey] = $actorKey . "\0" . $resourceKey . "\0" . $this->hashEntries($entries);
$cached = $this->cache->get($cacheKeys[$resourceKey]);
if ($cached !== null) {
$results[$resourceKey] = $cached;
Expand All @@ -178,7 +198,6 @@ public function checkAll(Actor $actor, array $accesses): array {
foreach ($misses as $resourceKey => $access) {
if ($restrictions === null) {
// Not cached: the provider that failed may succeed on the next check.
$results[$resourceKey] = new EffectivePermissions([]);
continue;
}

Expand All @@ -194,13 +213,14 @@ public function checkAll(Actor $actor, array $accesses): array {
#[\Override]
public function clearCache(): void {
$this->cache->clear();
$this->accessManager->clearCache();
}

/**
* Get the restricted permission IDs by resource key and recipient,
* the empty recipient is the resource as a whole.
*
* @param list<ResourceAccess> $accesses
* @param list<ResourceAccess> $accesses with the access entries set
* @return ?array<string, array<string, array<string, true>>> null if a provider failed
*/
private function collectRestrictions(Actor $actor, array $accesses): ?array {
Expand Down Expand Up @@ -246,22 +266,18 @@ private function getApplicablePermissionIds(string $resourceType): array {
* @param array<string, array<string, true>> $restrictions restricted permission IDs by recipient
*/
private function evaluate(ResourceAccess $access, array $applicable, array $restrictions): EffectivePermissions {
if ($access->entries === []) {
$allowed = $applicable;
} else {
$allowed = [];
foreach ($access->entries as $entry) {
$granted = array_fill_keys($entry->permissionIds, true);
$notApplicable = array_diff_key($granted, $applicable);
if ($notApplicable !== []) {
$this->logger->warning('Ignoring permissions that do not apply to the resource type {type}: {permissions}', ['type' => $access->resource->type, 'permissions' => implode(', ', array_keys($notApplicable))]);
}

$allowed += array_diff_key(
array_intersect_key($granted, $applicable),
$restrictions[$this->recipientKey($entry->recipientKind, $entry->recipientId)] ?? [],
);
$allowed = [];
foreach ($access->entries ?? [] as $entry) {
$granted = array_fill_keys($entry->permissionIds, true);
$notApplicable = array_diff_key($granted, $applicable);
if ($notApplicable !== []) {
$this->logger->warning('Ignoring permissions that do not apply to the resource type {type}: {permissions}', ['type' => $access->resource->type, 'permissions' => implode(', ', array_keys($notApplicable))]);
}

$allowed += array_diff_key(
array_intersect_key($granted, $applicable),
$restrictions[$this->recipientKey($entry->recipientKind, $entry->recipientId)] ?? [],
);
}

return new EffectivePermissions(array_keys(array_diff_key($allowed, $restrictions[''] ?? [])));
Expand Down Expand Up @@ -344,13 +360,9 @@ private function recipientKey(string $kind, string $id): string {
}

/**
* @param list<AccessEntry> $entries
* @param non-empty-list<AccessEntry> $entries
*/
private function hashEntries(array $entries): string {
if ($entries === []) {
return '';
}

return hash('xxh128', json_encode(
array_map(static fn (AccessEntry $entry): array => [$entry->recipientKind, $entry->recipientId, $entry->permissionIds], $entries),
JSON_THROW_ON_ERROR,
Expand Down
1 change: 1 addition & 0 deletions lib/private/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,7 @@ public function runInitialRegistrations(): void {
$this->registerAlias(\NCU\Search\IAccountScopedSearchProviderRegistry::class, AccountScopedSearchProviderRegistry::class);

$this->registerAlias(\NCU\Resource\Permission\IPermissionManager::class, \OC\Resource\Permission\PermissionManager::class);
$this->registerAlias(\NCU\Resource\Permission\IAccessManager::class, \OC\Resource\Permission\AccessManager::class);

$this->registerService(IGlobalScaleService::class, static function (ContainerInterface $c): IGlobalScaleService {
/** @var Coordinator $coordinator */
Expand Down
20 changes: 20 additions & 0 deletions lib/unstable/Resource/IResourceType.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

namespace NCU\Resource;

use NCU\Resource\Permission\AccessEntry;
use OCP\Actor\Actor;
use OCP\AppFramework\Attribute\Implementable;

/**
Expand Down Expand Up @@ -43,4 +45,22 @@ public function getDisplayName(): string;
* @experimental 36.0.0
*/
public function getPermissionIds(): array;

/**
* Get the ways the actor has access to the resources, e.g. as the owner or through shares.
*
* Every way is returned as stored without resolving memberships or roles,
* e.g. a share with a team is returned as recipient kind `team` and the ID of the team.
* The owner is returned as an entry with all permissions of the resource.
* If this method throws, the actor is treated as having no access to any of the resources.
*
* Results are remembered, call {@see \NCU\Resource\Permission\IPermissionManager::clearCache}
* whenever the access to a resource changes.
*
* @param list<ResourceReference> $resources Resources of this type
* @return array<non-empty-string, list<AccessEntry>> keyed by {@see ResourceReference::key},
* resources the actor has no access to are left out
* @experimental 36.0.0
*/
public function getAccess(Actor $actor, array $resources): array;
}
38 changes: 38 additions & 0 deletions lib/unstable/Resource/Permission/IAccessManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace NCU\Resource\Permission;

use NCU\Resource\IResourceType;
use NCU\Resource\ResourceReference;
use OCP\Actor\Actor;
use OCP\AppFramework\Attribute\Consumable;

/**
* Tells which access an actor was given to resources, before any restriction.
*
* Use {@see IPermissionManager::check} to get the permissions that are left after the restrictions.
*
* @experimental 36.0.0
*/
#[Consumable(since: '36.0.0')]
interface IAccessManager {
/**
* Get the ways the actor has access to the resources, the resources can be of different types.
*
* Ask this instead of {@see IResourceType::getAccess}, the resource type is not the only source of access.
*
* @param list<ResourceReference> $resources
* @return array<non-empty-string, non-empty-list<AccessEntry>> keyed by {@see ResourceReference::key},
* resources the actor has no access to are left out
* @throws \InvalidArgumentException if a resource type is not registered
* @experimental 36.0.0
*/
public function getAccess(Actor $actor, array $resources): array;
}
22 changes: 12 additions & 10 deletions lib/unstable/Resource/Permission/IPermissionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
* Gives access to the registered permissions and resource types
* and checks which permissions are restricted by other apps.
*
* The check does not decide which access exists, this is up to the app owning the resource.
* Which access exists is up to the app owning the resource, it either passes the access
* or is asked for it through {@see IResourceType::getAccess}.
* That app also has to enforce the result.
*
* @experimental 36.0.0
Expand Down Expand Up @@ -50,16 +51,16 @@ public function getResourceType(string $id): ?IResourceType;
/**
* Get the permissions of the actor on the resource that are not restricted.
*
* With access entries the result is what is left of the permissions granted by them.
* Without access entries the result is what is left of all permissions that apply to
* the resource type, the caller has to intersect it with the access it grants itself.
* If a restriction provider fails or cannot be loaded, no permission is allowed.
* The result is what is left of the permissions granted by the access entries.
* If no entries are passed, the resource type is asked for the access of the actor.
* With an empty list of entries the actor has no access and no permission is allowed.
* If a restriction provider or the resource type fails, no permission is allowed.
*
* @param list<AccessEntry> $entries
* @param ?list<AccessEntry> $entries
* @throws \InvalidArgumentException if the resource type is not registered
* @experimental 36.0.0
*/
public function check(Actor $actor, ResourceReference $resource, array $entries = []): EffectivePermissions;
public function check(Actor $actor, ResourceReference $resource, ?array $entries = null): EffectivePermissions;

/**
* Same as {@see self::check} for multiple resources at once, the resources can be of different types.
Expand All @@ -72,10 +73,11 @@ public function check(Actor $actor, ResourceReference $resource, array $entries
public function checkAll(Actor $actor, array $accesses): array;

/**
* Forget the results of previous checks.
* Forget the results of previous checks and the access returned by the resource types.
*
* Results are remembered while the process runs. An {@see IRestrictionProvider} has to call this
* whenever its restrictions change, so that later checks in the same process see the change.
* Both are remembered while the process runs. An {@see IRestrictionProvider} has to call this
* whenever its restrictions change and the app owning a resource whenever the access to it changes,
* so that later checks in the same process see the change.
*
* @experimental 36.0.0
*/
Expand Down
Loading
Loading