From e4179914b73b7300bd69b02448808a69bf1b389c Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 6 Oct 2026 15:25:27 +0200 Subject: [PATCH] [FEATURE] ResourceStorage: confidential resources are redacted in the resource overview --- components/ILIAS/ResourceStorage/PRIVACY.md | 1 + components/ILIAS/ResourceStorage/README.md | 36 +++++ .../classes/ResourceDBRepository.php | 13 +- .../DataSource/AllResourcesDataSource.php | 15 +- .../UI/Actions/OverviewActionGenerator.php | 25 ++-- .../Resources/UI/ConfidentialityPolicy.php | 51 +++++++ .../Resources/UI/ResourceListingUI.php | 6 +- .../Resources/UI/ResourceToComponent.php | 7 +- .../Resources/UI/RevisionListingUI.php | 23 +-- .../Resources/UI/RevisionToComponent.php | 42 ++++-- .../Resources/class.ilResourceOverviewGUI.php | 16 +- .../classes/Setup/DB/UpdateStepsV12.php | 12 ++ .../src/Manager/BaseManager.php | 25 ++++ .../ResourceStorage/src/Manager/Manager.php | 1 + .../src/Resource/AbstractStorableResource.php | 11 ++ .../src/Resource/ResourceBuilder.php | 1 + .../src/Resource/StorableResource.php | 8 + .../Stakeholder/ConfidentialStakeholder.php | 32 ++++ .../ConfidentialResourceCreationTest.php | 138 ++++++++++++++++++ .../Manager/ConfidentialResourceTest.php | 101 +++++++++++++ .../Resource/ResourceDBRepositoryTest.php | 105 +++++++++++++ .../Resources/ConfidentialityPolicyTest.php | 66 +++++++++ lang/ilias_de.lang | 3 + lang/ilias_en.lang | 3 + 24 files changed, 697 insertions(+), 44 deletions(-) create mode 100644 components/ILIAS/ResourceStorage/classes/Resources/UI/ConfidentialityPolicy.php create mode 100644 components/ILIAS/ResourceStorage/src/Stakeholder/ConfidentialStakeholder.php create mode 100644 components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceCreationTest.php create mode 100644 components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceTest.php create mode 100644 components/ILIAS/ResourceStorage/tests/Resource/ResourceDBRepositoryTest.php create mode 100644 components/ILIAS/ResourceStorage/tests/Resources/ConfidentialityPolicyTest.php diff --git a/components/ILIAS/ResourceStorage/PRIVACY.md b/components/ILIAS/ResourceStorage/PRIVACY.md index e3598f3ee9d8..7e9df2094f9f 100755 --- a/components/ILIAS/ResourceStorage/PRIVACY.md +++ b/components/ILIAS/ResourceStorage/PRIVACY.md @@ -18,6 +18,7 @@ Disclaimer: This documentation does not warrant completeness or correctness. Ple ## Data being presented - Persons with "Read" permission for the FileServices object are presented with the date and time of the resource creation in the Resource Overview. - The Resource Overview is located under "Administration" > "System Settings and Maintenance" > "File Services" > "Resource Overview". +- For resources marked as confidential, file names and revision titles are only presented to the owner of the current revision. Persons with "Read" permission for the FileServices object can neither see them nor download the resource in the Resource Overview. ## Data being deleted - Personal data that is stored regarding resources or revisions is deleted by the IRSS when directed to do so by the component who created the resource or revision. diff --git a/components/ILIAS/ResourceStorage/README.md b/components/ILIAS/ResourceStorage/README.md index 78565ea0dac0..d40c23a952b6 100755 --- a/components/ILIAS/ResourceStorage/README.md +++ b/components/ILIAS/ResourceStorage/README.md @@ -227,6 +227,42 @@ $latest_published_revision = $irss->manage()->getCurrentRevision($rid); // First $latest_draft_revision = $irss->manage()->getCurrentRevisionIncludingDraft($rid); // Second Upload ``` +# Confidential Resources + +All resources are listed in the Resource Overview of the administration ("Administration" > "System Settings and +Maintenance" > "File Services" > "Resource Overview"). Resources containing sensitive files (e.g. private uploads of a +user) can be marked as confidential. For confidential resources the Resource Overview +- shows `[Confidential]` instead of the file name and the revision titles, +- does not offer a download (neither as action nor via a direct link), +- does not find them via the title filter, + +unless the person looking at the overview is the owner of the current revision of the resource. Size, file type, +creation date and stakeholders are still displayed. + +Confidentiality only affects the Resource Overview. Delivering resources via `consume()` is still controlled by the +consuming component and its stakeholder. + +A stakeholder can mark all resources created for it as confidential by implementing `ConfidentialStakeholder`: + +```php +class ilMyComponentResourceStakeholder extends AbstractResourceStakeholder implements ConfidentialStakeholder +{ + public function areNewResourcesConfidential(): bool + { + return true; + } +} +``` + +The flag can also be set or removed for an existing resource: + +```php +$irss->manage()->setConfidential($rid, true); +$irss->manage()->isConfidential($rid); // true +``` + +Cloned resources keep the flag of the original resource. + # Collections In many cases a component does not only need a single resource to be stored, but wants to be able to use a collection of diff --git a/components/ILIAS/ResourceStorage/classes/ResourceDBRepository.php b/components/ILIAS/ResourceStorage/classes/ResourceDBRepository.php index c25532fa8927..474f04f2eb0e 100755 --- a/components/ILIAS/ResourceStorage/classes/ResourceDBRepository.php +++ b/components/ILIAS/ResourceStorage/classes/ResourceDBRepository.php @@ -75,12 +75,13 @@ public function get(ResourceIdentification $identification): StorableResource return $this->cache[$rid]; } - $q = "SELECT storage_id, rtype FROM " . self::TABLE_NAME . " WHERE " . self::IDENTIFICATION . " = %s"; + $q = "SELECT storage_id, rtype, confidential FROM " . self::TABLE_NAME . " WHERE " . self::IDENTIFICATION . " = %s"; $r = $this->db->queryF($q, ['text'], [$rid]); $d = $this->db->fetchObject($r); - $resource = $this->blank($identification, ResourceType::from($d->rtype)); + $resource = $this->blank($identification, ResourceType::from((int) $d->rtype)); $resource->setStorageID($d->storage_id); + $resource->setConfidential((bool) $d->confidential); $this->cache[$rid] = $resource; @@ -119,7 +120,8 @@ public function store(StorableResource $resource): void ], [ 'storage_id' => ['text', $resource->getStorageID()], - 'rtype' => ['text', $resource->getType()->value], + 'rtype' => ['integer', $resource->getType()->value], + 'confidential' => ['integer', (int) $resource->isConfidential()], ] ); @@ -151,7 +153,7 @@ public function getAll(): \Generator public function preload(array $identification_strings): void { $r = $this->db->query( - "SELECT rid, storage_id, rtype FROM " . self::TABLE_NAME . " WHERE " + "SELECT rid, storage_id, rtype, confidential FROM " . self::TABLE_NAME . " WHERE " . $this->db->in(self::IDENTIFICATION, $identification_strings, false, 'text') ); while ($d = $this->db->fetchAssoc($r)) { @@ -163,9 +165,10 @@ public function populateFromArray(array $data): void { $resource = $this->blank( new ResourceIdentification($data['rid']), - ResourceType::from($data['rtype'] ?? ResourceType::SINGLE_FILE->value) + ResourceType::from((int) ($data['rtype'] ?? ResourceType::SINGLE_FILE->value)) ); $resource->setStorageID($data['storage_id']); + $resource->setConfidential((bool) ($data['confidential'] ?? false)); $this->cache[$data['rid']] = $resource; } } diff --git a/components/ILIAS/ResourceStorage/classes/Resources/DataSource/AllResourcesDataSource.php b/components/ILIAS/ResourceStorage/classes/Resources/DataSource/AllResourcesDataSource.php index 94d99946cbc5..6f05c2af760b 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/DataSource/AllResourcesDataSource.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/DataSource/AllResourcesDataSource.php @@ -34,8 +34,13 @@ */ class AllResourcesDataSource extends BaseTableDataSource implements TableDataSource { - public function __construct() - { + /** + * @param int|null $acting_user_id Owner whose confidential resources may be found by title, see + * {@see \ILIAS\components\ResourceStorage\Resources\UI\ConfidentialityPolicy} + */ + public function __construct( + private readonly ?int $acting_user_id = null + ) { global $DIC; parent::__construct( $DIC->resourceStorage()->collection()->id() // ad hoc collection @@ -135,7 +140,8 @@ private function getBaseQuery(bool $count_only): string $q .= " FROM il_resource_revision JOIN il_resource_info ON il_resource_revision.rid = il_resource_info.rid AND il_resource_info.version_number = il_resource_revision.version_number - JOIN il_resource_stkh_u ON il_resource_revision.rid = il_resource_stkh_u.rid"; + JOIN il_resource_stkh_u ON il_resource_revision.rid = il_resource_stkh_u.rid + JOIN il_resource ON il_resource_revision.rid = il_resource.rid"; // WHERE $q .= $this->buildQueryFilter(); @@ -184,6 +190,9 @@ private function buildQueryFilter(): string if (isset($this->filter_values['title']) && $this->filter_values['title'] !== '') { $filter_values[] = $this->db->quote("%" . $this->filter_values['title'] . "%", 'text'); $filters[] = " il_resource_revision.title LIKE %s "; + // titles of confidential resources must not be searchable, except for their owner + $filter_values[] = $this->db->quote($this->acting_user_id ?? 0, 'integer'); + $filters[] = " (il_resource.confidential = 0 OR il_resource_revision.owner_id = %s) "; } if (isset($this->filter_values['size']) && $this->filter_values['size'] !== '') { $greater_than = (int) $this->filter_values['size'] * BaseToComponent::SIZE_FACTOR * BaseToComponent::SIZE_FACTOR; diff --git a/components/ILIAS/ResourceStorage/classes/Resources/UI/Actions/OverviewActionGenerator.php b/components/ILIAS/ResourceStorage/classes/Resources/UI/Actions/OverviewActionGenerator.php index d3ea5c81baaa..24a2b5a510ce 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/UI/Actions/OverviewActionGenerator.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/UI/Actions/OverviewActionGenerator.php @@ -24,6 +24,7 @@ use ILIAS\ResourceStorage\Services; use ILIAS\ResourceStorage\Revision\Revision; use ILIAS\UI\Component\Modal\Modal; +use ILIAS\components\ResourceStorage\Resources\UI\ConfidentialityPolicy; /** * @author Fabian Schmid @@ -38,8 +39,9 @@ class OverviewActionGenerator implements ActionGenerator private array $collected_modals = []; private Services $irss; - public function __construct() - { + public function __construct( + private ConfidentialityPolicy $confidentiality_policy + ) { global $DIC; $this->ui_factory = $DIC->ui()->factory(); $this->language = $DIC->language(); @@ -55,6 +57,8 @@ public function getActionsForRevision( \ilResourceOverviewGUI::P_RESOURCE_ID, $revision->getIdentification()->serialize() ); + $resource = $this->irss->manage()->getResource($revision->getIdentification()); + $redacted = $this->confidentiality_policy->isRedacted($resource); $actions = [ $this->ui_factory->button()->shy( $this->language->txt('action_show_revisions'), @@ -70,17 +74,18 @@ public function getActionsForRevision( \ilResourceOverviewGUI::CMD_GOTO_RESOURCE ) ), - $this->ui_factory->button()->shy( + ]; + if (!$redacted) { + $actions[] = $this->ui_factory->button()->shy( $this->language->txt('action_download'), $this->ctrl->getLinkTargetByClass( \ilResourceOverviewGUI::class, \ilResourceOverviewGUI::CMD_DOWNLOAD ) - ), - ]; - $resource = $this->irss->manage()->getResource($revision->getIdentification()); + ); + } if ($resource->getStakeholders() === []) { - $this->collected_modals[] = $modal = $this->getRemoveConfirmationModal($revision); + $this->collected_modals[] = $modal = $this->getRemoveConfirmationModal($revision, $redacted); $actions[] = $this->ui_factory->button()->shy( $this->language->txt('action_remove_resource'), '#' @@ -97,7 +102,7 @@ public function getCollectedModals(): array } - private function getRemoveConfirmationModal(Revision $revision): Modal + private function getRemoveConfirmationModal(Revision $revision, bool $redacted): Modal { $action = $this->ctrl->getLinkTargetByClass( \ilResourceOverviewGUI::class, @@ -110,7 +115,9 @@ private function getRemoveConfirmationModal(Revision $revision): Modal )->withAffectedItems([ $this->ui_factory->modal()->interruptiveItem()->standard( $revision->getIdentification()->serialize(), - $revision->getInformation()->getTitle(), + $redacted + ? $this->language->txt('confidential_redacted') + : $revision->getInformation()->getTitle(), ) ]); } diff --git a/components/ILIAS/ResourceStorage/classes/Resources/UI/ConfidentialityPolicy.php b/components/ILIAS/ResourceStorage/classes/Resources/UI/ConfidentialityPolicy.php new file mode 100644 index 000000000000..f056db39982f --- /dev/null +++ b/components/ILIAS/ResourceStorage/classes/Resources/UI/ConfidentialityPolicy.php @@ -0,0 +1,51 @@ + + */ +class ConfidentialityPolicy +{ + public function __construct( + private readonly int $acting_user_id + ) { + } + + public function isRedacted(StorableResource $resource): bool + { + if (!$resource->isConfidential()) { + return false; + } + + return $resource->getCurrentRevisionIncludingDraft()->getOwnerId() !== $this->acting_user_id; + } + + public function getActingUserId(): int + { + return $this->acting_user_id; + } +} diff --git a/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceListingUI.php b/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceListingUI.php index 043be335c500..8fd689bcad5e 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceListingUI.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceListingUI.php @@ -53,7 +53,8 @@ class ResourceListingUI public function __construct( private ViewDefinition $view_definition, private TableDataSource $data_source, - private ActionGenerator $action_generator = new NullActionGenerator() + private ActionGenerator $action_generator = new NullActionGenerator(), + private ?ConfidentialityPolicy $confidentiality_policy = null ) { global $DIC; $this->ctrl = $DIC->ctrl(); @@ -125,7 +126,8 @@ public function getRowMapping(): \Closure $resource = $this->irss->manage()->getResource($resource_identification); $resource_to_component = new ResourceToComponent( $resource, - $this->action_generator + $this->action_generator, + $this->confidentiality_policy ); return $resource_to_component->getAsRowMapping()($row, $resource_identification); }; diff --git a/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceToComponent.php b/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceToComponent.php index 17d27f4ad499..d04cbcafa747 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceToComponent.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/UI/ResourceToComponent.php @@ -39,13 +39,16 @@ class ResourceToComponent extends BaseToComponent implements ToComponent public function __construct( protected StorableResource $resource, - ?ActionGenerator $action_generator = null + ?ActionGenerator $action_generator = null, + ?ConfidentialityPolicy $confidentiality_policy = null ) { parent::__construct($action_generator); $this->current_revision = $this->resource->getCurrentRevision(); $this->revision_to_component = new RevisionToComponent( $this->current_revision, - $action_generator + $action_generator, + $this->resource->isConfidential(), + $confidentiality_policy?->isRedacted($this->resource) ?? $this->resource->isConfidential() ); } diff --git a/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionListingUI.php b/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionListingUI.php index 8aef729390a8..038d04489897 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionListingUI.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionListingUI.php @@ -40,6 +40,7 @@ class RevisionListingUI public function __construct( private ViewDefinition $view_definition, protected StorableResource $resource, + private ?ConfidentialityPolicy $confidentiality_policy = null, ) { global $DIC; $this->language = $DIC->language(); @@ -56,9 +57,7 @@ public function __construct( private function initItems(): void { $this->components = array_map(function (Revision $revision): Item { - $revision_to_component = new RevisionToComponent( - $revision - ); + $revision_to_component = $this->getRevisionToComponent($revision); $item = $revision_to_component->getAsItem(false); return $item->withLeadText($this->language->txt('revision') . ' ' . $revision->getVersionNumber()); }, array_reverse($this->resource->getAllRevisionsIncludingDraft())); @@ -69,9 +68,7 @@ private function initDeck(): void $this->components[] = $this->ui_factory->deck( array_map( function (Revision $revision): Card { - $revision_to_component = new RevisionToComponent( - $revision - ); + $revision_to_component = $this->getRevisionToComponent($revision); $card = $revision_to_component->getAsCard(); return $card->withTitle($this->prependRevisionNumberToTitle($revision, $card->getTitle())); }, @@ -98,9 +95,7 @@ public function getRowMapping(): \Closure PresentationRow $row, Revision $revision ): PresentationRow { - $revision_to_component = new RevisionToComponent( - $revision - ); + $revision_to_component = $this->getRevisionToComponent($revision); $row = $revision_to_component->getAsRowMapping()($row, $revision->getIdentification()); /** @var PresentationRow $row */ $title = $row->getHeadline(); @@ -111,6 +106,16 @@ public function getRowMapping(): \Closure } + private function getRevisionToComponent(Revision $revision): RevisionToComponent + { + return new RevisionToComponent( + $revision, + null, + $this->resource->isConfidential(), + $this->confidentiality_policy?->isRedacted($this->resource) ?? $this->resource->isConfidential() + ); + } + public function getComponents(): array { return $this->components; diff --git a/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionToComponent.php b/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionToComponent.php index e8d310655329..273b232cabfc 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionToComponent.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/UI/RevisionToComponent.php @@ -42,7 +42,9 @@ class RevisionToComponent extends BaseToComponent implements ToComponent public function __construct( private Revision $revision, - ?ActionGenerator $action_generator = null + ?ActionGenerator $action_generator = null, + private bool $confidential = false, + private bool $redacted = false ) { global $DIC; parent::__construct($action_generator); @@ -57,8 +59,8 @@ public function getAsItem(bool $with_image): Standard $this->getCommonProperties(), $this->getDetailedProperties() ); - $item = $this->ui_factory->item()->standard($this->revision->getTitle()) - ->withDescription($this->information->getTitle()) + $item = $this->ui_factory->item()->standard($this->getRevisionTitle()) + ->withDescription($this->getInformationTitle()) ->withProperties($properties); if ($with_image) { @@ -70,7 +72,7 @@ public function getAsItem(bool $with_image): Standard public function getAsCard(): Card { return $this->ui_factory->card()->repositoryObject( - $this->information->getTitle(), + $this->getInformationTitle(), $this->getImage() )->withSections([$this->ui_factory->listing()->descriptive($this->getCommonProperties())]); } @@ -91,8 +93,8 @@ public function getAsRowMapping(): \Closure } return $row - ->withHeadline($this->information->getTitle()) - ->withSubheadline($this->revision->getTitle()) + ->withHeadline($this->getInformationTitle()) + ->withSubheadline($this->getRevisionTitle()) ->withImportantFields($this->getImportantProperties()) ->withContent( $this->ui_factory->listing()->descriptive($this->getCommonProperties()) @@ -107,15 +109,29 @@ private function getImage(): Image { // We could use Flavours in the Future $src = null; - if ($this->irss->flavours()->possible($this->revision->getIdentification(), $this->preview_definition)) { + if (!$this->redacted && $this->irss->flavours()->possible($this->revision->getIdentification(), $this->preview_definition)) { $flavour = $this->irss->flavours()->get($this->revision->getIdentification(), $this->preview_definition); $src = $this->irss->consume()->flavourUrls($flavour)->getURLsAsArray()[0] ?? null; } return $this->ui_factory->image()->responsive( $src ?? $this->getPlaceholderImage(), - $this->information->getTitle() - )->withAlt($this->information->getTitle()); + $this->getInformationTitle() + )->withAlt($this->getInformationTitle()); + } + + private function getInformationTitle(): string + { + return $this->redacted + ? $this->language->txt('confidential_redacted') + : $this->information->getTitle(); + } + + private function getRevisionTitle(): string + { + return $this->redacted + ? $this->language->txt('confidential_redacted') + : $this->revision->getTitle(); } protected function getPlaceholderImage(): string @@ -133,11 +149,15 @@ public function getImportantProperties(): array public function getCommonProperties(): array { - return [ + $properties = [ $this->language->txt('file_size') => $this->formatSize($this->information->getSize()), $this->language->txt('type') => $this->information->getMimeType(), - ]; + if ($this->confidential) { + $properties[$this->language->txt('confidential')] = $this->language->txt('yes'); + } + + return $properties; } public function getDetailedProperties(): array diff --git a/components/ILIAS/ResourceStorage/classes/Resources/class.ilResourceOverviewGUI.php b/components/ILIAS/ResourceStorage/classes/Resources/class.ilResourceOverviewGUI.php index bc0f4665b9ed..eddb8308f37d 100755 --- a/components/ILIAS/ResourceStorage/classes/Resources/class.ilResourceOverviewGUI.php +++ b/components/ILIAS/ResourceStorage/classes/Resources/class.ilResourceOverviewGUI.php @@ -31,6 +31,7 @@ use ILIAS\components\ResourceStorage\Resources\UI\Actions\OverviewActionGenerator; use ILIAS\components\ResourceStorage\Resources\UI\ResourceListingUI; use ILIAS\components\ResourceStorage\Resources\UI\RevisionListingUI; +use ILIAS\components\ResourceStorage\Resources\UI\ConfidentialityPolicy; /** * @author Fabian Schmid @@ -65,6 +66,7 @@ class ilResourceOverviewGUI protected ArrayBasedRequestWrapper $query; private WrapperFactory $wrapper; private ilTabsGUI $tabs; + private ConfidentialityPolicy $confidentiality_policy; final public function __construct() { @@ -80,6 +82,7 @@ final public function __construct() $this->refinery = $DIC->refinery(); $this->ui_renderer = $DIC->ui()->renderer(); $this->tabs = $DIC->tabs(); + $this->confidentiality_policy = new ConfidentialityPolicy($DIC->user()->getId()); } /** @@ -120,8 +123,9 @@ private function index(): void self::CMD_INDEX, $this->language->txt('resource_overview') ), - new AllResourcesDataSource(), - new OverviewActionGenerator() + new AllResourcesDataSource($this->confidentiality_policy->getActingUserId()), + new OverviewActionGenerator($this->confidentiality_policy), + $this->confidentiality_policy ); $this->main_tpl->setContent( @@ -170,7 +174,8 @@ private function showRevisions(): void $view_definition->setMode(ViewDefinition::MODE_AS_TABLE); $listing = new RevisionListingUI( $view_definition, - $resource + $resource, + $this->confidentiality_policy ); @@ -188,6 +193,11 @@ private function download(): void $this->ctrl->redirect($this, self::CMD_INDEX); return; } + if ($this->confidentiality_policy->isRedacted($this->irss->manage()->getResource($rid))) { + $this->main_tpl->setOnScreenMessage('failure', $this->language->txt('confidential_no_download'), true); + $this->ctrl->redirect($this, self::CMD_INDEX); + return; + } $this->irss->consume()->download($rid)->run(); } diff --git a/components/ILIAS/ResourceStorage/classes/Setup/DB/UpdateStepsV12.php b/components/ILIAS/ResourceStorage/classes/Setup/DB/UpdateStepsV12.php index ae7dfa257866..63db448fae26 100644 --- a/components/ILIAS/ResourceStorage/classes/Setup/DB/UpdateStepsV12.php +++ b/components/ILIAS/ResourceStorage/classes/Setup/DB/UpdateStepsV12.php @@ -36,4 +36,16 @@ public function step_1(): void 'type' => \ilDBConstants::T_TEXT, ]); } + + public function step_2(): void + { + if (!$this->db->tableColumnExists('il_resource', 'confidential')) { + $this->db->addTableColumn('il_resource', 'confidential', [ + 'type' => \ilDBConstants::T_INTEGER, + 'length' => 1, + 'notnull' => true, + 'default' => 0, + ]); + } + } } diff --git a/components/ILIAS/ResourceStorage/src/Manager/BaseManager.php b/components/ILIAS/ResourceStorage/src/Manager/BaseManager.php index a7bc58eeaa59..57397ac7bb4a 100755 --- a/components/ILIAS/ResourceStorage/src/Manager/BaseManager.php +++ b/components/ILIAS/ResourceStorage/src/Manager/BaseManager.php @@ -32,6 +32,7 @@ use ILIAS\ResourceStorage\Resource\StorableResource; use ILIAS\ResourceStorage\Revision\Revision; use ILIAS\ResourceStorage\Stakeholder\ResourceStakeholder; +use ILIAS\ResourceStorage\Stakeholder\ConfidentialStakeholder; use ILIAS\ResourceStorage\Resource\ResourceType; use ILIAS\ResourceStorage\Revision\RevisionStatus; @@ -96,11 +97,19 @@ protected function newStreamBased( $type ); $resource->addStakeholder($stakeholder); + $this->applyConfidentiality($resource, $stakeholder); $this->resource_builder->store($resource); return $resource->getIdentification(); } + protected function applyConfidentiality(StorableResource $resource, ResourceStakeholder $stakeholder): void + { + if ($stakeholder instanceof ConfidentialStakeholder && $stakeholder->areNewResourcesConfidential()) { + $resource->setConfidential(true); + } + } + public function find(string $identification): ?ResourceIdentification { $resource_identification = new ResourceIdentification($identification); @@ -128,6 +137,22 @@ public function remove(ResourceIdentification $identification, ResourceStakehold } } + /** + * @description Mark a resource as confidential (or remove the mark). Confidential resources are redacted in the + * administration for everyone except the owner of the current revision and cannot be downloaded there. + */ + public function setConfidential(ResourceIdentification $identification, bool $confidential): void + { + $resource = $this->resource_builder->get($identification); + $resource->setConfidential($confidential); + $this->resource_builder->store($resource); + } + + public function isConfidential(ResourceIdentification $identification): bool + { + return $this->resource_builder->get($identification)->isConfidential(); + } + public function clone(ResourceIdentification $identification): ResourceIdentification { $resource = $this->resource_builder->clone($this->resource_builder->get($identification)); diff --git a/components/ILIAS/ResourceStorage/src/Manager/Manager.php b/components/ILIAS/ResourceStorage/src/Manager/Manager.php index 3f9acdc8e2ec..ea6169000511 100755 --- a/components/ILIAS/ResourceStorage/src/Manager/Manager.php +++ b/components/ILIAS/ResourceStorage/src/Manager/Manager.php @@ -53,6 +53,7 @@ public function upload( $info_resolver ); $resource->addStakeholder($stakeholder); + $this->applyConfidentiality($resource, $stakeholder); $this->resource_builder->store($resource); return $resource->getIdentification(); diff --git a/components/ILIAS/ResourceStorage/src/Resource/AbstractStorableResource.php b/components/ILIAS/ResourceStorage/src/Resource/AbstractStorableResource.php index 6ecf14d289b5..c537249413b8 100755 --- a/components/ILIAS/ResourceStorage/src/Resource/AbstractStorableResource.php +++ b/components/ILIAS/ResourceStorage/src/Resource/AbstractStorableResource.php @@ -36,6 +36,7 @@ abstract class AbstractStorableResource implements StorableResource */ protected array $stakeholders = []; protected string $storage_id = ''; + protected bool $confidential = false; /** * StorableFileResource constructor. @@ -173,4 +174,14 @@ public function getFullSize(): int } abstract public function getType(): ResourceType; + + public function isConfidential(): bool + { + return $this->confidential; + } + + public function setConfidential(bool $confidential): void + { + $this->confidential = $confidential; + } } diff --git a/components/ILIAS/ResourceStorage/src/Resource/ResourceBuilder.php b/components/ILIAS/ResourceStorage/src/Resource/ResourceBuilder.php index 811b91aba581..10fa65cb5fd5 100755 --- a/components/ILIAS/ResourceStorage/src/Resource/ResourceBuilder.php +++ b/components/ILIAS/ResourceStorage/src/Resource/ResourceBuilder.php @@ -386,6 +386,7 @@ function () use ($resource): void { public function clone(StorableResource $resource): StorableResource { $new_resource = $this->newBlank($resource->getType()); + $new_resource->setConfidential($resource->isConfidential()); foreach ($resource->getStakeholders() as $stakeholder) { $stakeholder = clone $stakeholder; $new_resource->addStakeholder($stakeholder); diff --git a/components/ILIAS/ResourceStorage/src/Resource/StorableResource.php b/components/ILIAS/ResourceStorage/src/Resource/StorableResource.php index 7fcf769b9e87..9a1d95855573 100755 --- a/components/ILIAS/ResourceStorage/src/Resource/StorableResource.php +++ b/components/ILIAS/ResourceStorage/src/Resource/StorableResource.php @@ -74,4 +74,12 @@ public function getMaxRevision(bool $including_drafts = false): int; public function getFullSize(): int; public function getType(): ResourceType; + + /** + * Confidential resources are redacted in the administration for everyone + * except the owner of the current revision and cannot be downloaded there. + */ + public function isConfidential(): bool; + + public function setConfidential(bool $confidential): void; } diff --git a/components/ILIAS/ResourceStorage/src/Stakeholder/ConfidentialStakeholder.php b/components/ILIAS/ResourceStorage/src/Stakeholder/ConfidentialStakeholder.php new file mode 100644 index 000000000000..b14a1b13375f --- /dev/null +++ b/components/ILIAS/ResourceStorage/src/Stakeholder/ConfidentialStakeholder.php @@ -0,0 +1,32 @@ + + */ +interface ConfidentialStakeholder extends ResourceStakeholder +{ + public function areNewResourcesConfidential(): bool; +} diff --git a/components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceCreationTest.php b/components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceCreationTest.php new file mode 100644 index 000000000000..c83ade1c2669 --- /dev/null +++ b/components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceCreationTest.php @@ -0,0 +1,138 @@ + + */ +#[AllowMockObjectsWithoutExpectations] +final class ConfidentialResourceCreationTest extends AbstractBaseResourceBuilderTestCase +{ + #[DataProvider('stakeholderProvider')] + public function testUploadAppliesStakeholderDefault(string $stakeholder_type, bool $expected): void + { + $builder = $this->getResourceBuilderExpectingStore($expected); + $builder->expects($this->once()) + ->method('new') + ->willReturn(new StorableFileResource(new ResourceIdentification('rid'))); + + $this->getManager($builder)->upload( + $this->getDummyUploadResult('info.xml', 'text/xml', 128), + $this->getStakeholder($stakeholder_type) + ); + } + + #[DataProvider('stakeholderProvider')] + public function testStreamAppliesStakeholderDefault(string $stakeholder_type, bool $expected): void + { + $builder = $this->getResourceBuilderExpectingStore($expected); + $builder->expects($this->once()) + ->method('newFromStream') + ->willReturn(new StorableFileResource(new ResourceIdentification('rid'))); + + $this->getManager($builder)->stream( + $this->getDummyStream(), + $this->getStakeholder($stakeholder_type), + 'info.txt' + ); + } + + #[DataProvider('cloneProvider')] + public function testCloneKeepsConfidentialFlag(bool $confidential): void + { + $this->storage_handler->method('getIdentificationGenerator')->willReturn($this->id_generator); + $this->storage_handler->method('getID')->willReturn('fsv2'); + $this->resource_repository->method('blank')->willReturnCallback( + static fn(ResourceIdentification $rid): StorableResource => new StorableFileResource($rid) + ); + $this->locking->method('lockTables')->willReturn($this->createStub(LockHandlerResult::class)); + + $original = new StorableFileResource(new ResourceIdentification('original')); + $original->setConfidential($confidential); + + $clone = (new ResourceBuilder( + $this->storage_handler_factory, + $this->repositories, + $this->locking, + $this->stream_access + ))->clone($original); + + $this->assertNotSame($original->getIdentification()->serialize(), $clone->getIdentification()->serialize()); + $this->assertSame($confidential, $clone->isConfidential()); + } + + public static function stakeholderProvider(): \Iterator + { + yield 'confidential stakeholder' => ['confidential', true]; + yield 'confidential stakeholder, disabled' => ['confidential_disabled', false]; + yield 'regular stakeholder' => ['regular', false]; + } + + public static function cloneProvider(): \Iterator + { + yield 'confidential' => [true]; + yield 'not confidential' => [false]; + } + + private function getStakeholder(string $type): ResourceStakeholder + { + if ($type === 'regular') { + return $this->createStub(ResourceStakeholder::class); + } + $stakeholder = $this->createStub(ConfidentialStakeholder::class); + $stakeholder->method('areNewResourcesConfidential')->willReturn($type === 'confidential'); + + return $stakeholder; + } + + private function getResourceBuilderExpectingStore(bool $expected): ResourceBuilder + { + $builder = $this->createMock(ResourceBuilder::class); + $builder->expects($this->once()) + ->method('store') + ->with($this->callback( + static fn(StorableResource $resource): bool => $resource->isConfidential() === $expected + )); + + return $builder; + } + + private function getManager(ResourceBuilder $builder): Manager + { + return new Manager( + $builder, + $this->createStub(CollectionBuilder::class), + $this->createStub(RepositoryPreloader::class) + ); + } +} diff --git a/components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceTest.php b/components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceTest.php new file mode 100644 index 000000000000..65184164c5f6 --- /dev/null +++ b/components/ILIAS/ResourceStorage/tests/Manager/ConfidentialResourceTest.php @@ -0,0 +1,101 @@ + + */ +final class ConfidentialResourceTest extends TestCase +{ + public function testResourcesAreNotConfidentialByDefault(): void + { + $resource = new StorableFileResource(new ResourceIdentification('rid')); + + $this->assertFalse($resource->isConfidential()); + } + + #[DataProvider('confidentialProvider')] + public function testStakeholderDefaultIsApplied(bool $stakeholder_default): void + { + $resource = new StorableFileResource(new ResourceIdentification('rid')); + $stakeholder = $this->createStub(ConfidentialStakeholder::class); + $stakeholder->method('areNewResourcesConfidential')->willReturn($stakeholder_default); + + $manager = (new \ReflectionClass(Manager::class))->newInstanceWithoutConstructor(); + (new \ReflectionMethod(BaseManager::class, 'applyConfidentiality'))->invoke($manager, $resource, $stakeholder); + + $this->assertSame($stakeholder_default, $resource->isConfidential()); + } + + public function testRegularStakeholderDoesNotMarkResourcesAsConfidential(): void + { + $resource = new StorableFileResource(new ResourceIdentification('rid')); + + $manager = (new \ReflectionClass(Manager::class))->newInstanceWithoutConstructor(); + (new \ReflectionMethod(BaseManager::class, 'applyConfidentiality'))->invoke( + $manager, + $resource, + $this->createStub(ResourceStakeholder::class) + ); + + $this->assertFalse($resource->isConfidential()); + } + + #[DataProvider('confidentialProvider')] + public function testSetConfidentialStoresResource(bool $confidential): void + { + $rid = new ResourceIdentification('rid'); + $resource = new StorableFileResource($rid); + $resource->setConfidential(!$confidential); + + $builder = $this->createMock(ResourceBuilder::class); + $builder->expects($this->exactly(2))->method('get')->with($rid)->willReturn($resource); + $builder->expects($this->once()) + ->method('store') + ->with($this->callback( + static fn(StorableFileResource $r): bool => $r->isConfidential() === $confidential + )); + + $manager = new Manager( + $builder, + $this->createStub(CollectionBuilder::class), + $this->createStub(RepositoryPreloader::class) + ); + $manager->setConfidential($rid, $confidential); + + $this->assertSame($confidential, $manager->isConfidential($rid)); + } + + public static function confidentialProvider(): \Iterator + { + yield 'confidential' => [true]; + yield 'not confidential' => [false]; + } +} diff --git a/components/ILIAS/ResourceStorage/tests/Resource/ResourceDBRepositoryTest.php b/components/ILIAS/ResourceStorage/tests/Resource/ResourceDBRepositoryTest.php new file mode 100644 index 000000000000..1aca4dd79d9b --- /dev/null +++ b/components/ILIAS/ResourceStorage/tests/Resource/ResourceDBRepositoryTest.php @@ -0,0 +1,105 @@ + + */ +final class ResourceDBRepositoryTest extends TestCase +{ + #[DataProvider('confidentialProvider')] + public function testStoreWritesConfidentialFlag(bool $confidential): void + { + $resource = new StorableFileResource(new ResourceIdentification('rid')); + $resource->setStorageID('fsv2'); + $resource->setConfidential($confidential); + + $db = $this->createMock(\ilDBInterface::class); + $db->expects($this->once()) + ->method('replace') + ->with( + 'il_resource', + ['rid' => ['text', 'rid']], + [ + 'storage_id' => ['text', 'fsv2'], + 'rtype' => ['integer', 1], + 'confidential' => ['integer', (int) $confidential], + ] + ); + + (new ResourceDBRepository($db))->store($resource); + } + + #[DataProvider('confidentialProvider')] + public function testGetReadsConfidentialFlag(bool $confidential): void + { + $db = $this->createStub(\ilDBInterface::class); + $db->method('queryF')->willReturn($this->createStub(\ilDBStatement::class)); + $db->method('fetchObject')->willReturn((object) [ + 'storage_id' => 'fsv2', + 'rtype' => '1', + 'confidential' => $confidential ? '1' : '0', + ]); + + $resource = (new ResourceDBRepository($db))->get(new ResourceIdentification('rid')); + + $this->assertSame($confidential, $resource->isConfidential()); + } + + #[DataProvider('confidentialProvider')] + public function testPopulateFromArrayReadsConfidentialFlag(bool $confidential): void + { + $db = $this->createMock(\ilDBInterface::class); + $db->expects($this->never())->method('queryF'); + + $repository = new ResourceDBRepository($db); + $repository->populateFromArray([ + 'rid' => 'rid', + 'storage_id' => 'fsv2', + 'rtype' => '1', + 'confidential' => $confidential ? '1' : '0', + ]); + + $this->assertSame($confidential, $repository->get(new ResourceIdentification('rid'))->isConfidential()); + } + + public function testPopulateFromArrayWithoutConfidentialColumnIsNotConfidential(): void + { + $repository = new ResourceDBRepository($this->createStub(\ilDBInterface::class)); + $repository->populateFromArray([ + 'rid' => 'rid', + 'storage_id' => 'fsv2', + 'rtype' => '1', + ]); + + $this->assertFalse($repository->get(new ResourceIdentification('rid'))->isConfidential()); + } + + public static function confidentialProvider(): \Iterator + { + yield 'confidential' => [true]; + yield 'not confidential' => [false]; + } +} diff --git a/components/ILIAS/ResourceStorage/tests/Resources/ConfidentialityPolicyTest.php b/components/ILIAS/ResourceStorage/tests/Resources/ConfidentialityPolicyTest.php new file mode 100644 index 000000000000..0038afec0e6d --- /dev/null +++ b/components/ILIAS/ResourceStorage/tests/Resources/ConfidentialityPolicyTest.php @@ -0,0 +1,66 @@ + + */ +final class ConfidentialityPolicyTest extends TestCase +{ + private const OWNER_ID = 42; + private const OTHER_USER_ID = 6; + + public function testNonConfidentialResourceIsNeverRedacted(): void + { + $policy = new ConfidentialityPolicy(self::OTHER_USER_ID); + + $this->assertFalse($policy->isRedacted($this->getResource(false))); + } + + public function testConfidentialResourceIsRedactedForOtherUsers(): void + { + $policy = new ConfidentialityPolicy(self::OTHER_USER_ID); + + $this->assertTrue($policy->isRedacted($this->getResource(true))); + } + + public function testConfidentialResourceIsNotRedactedForOwnerOfCurrentRevision(): void + { + $policy = new ConfidentialityPolicy(self::OWNER_ID); + + $this->assertFalse($policy->isRedacted($this->getResource(true))); + } + + private function getResource(bool $confidential): StorableResource + { + $revision = $this->createStub(Revision::class); + $revision->method('getOwnerId')->willReturn(self::OWNER_ID); + + $resource = $this->createStub(StorableResource::class); + $resource->method('isConfidential')->willReturn($confidential); + $resource->method('getCurrentRevisionIncludingDraft')->willReturn($revision); + + return $resource; + } +} diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index dcb66a5c1591..4d60ffb08d25 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -10774,6 +10774,9 @@ irss#:#by_size_asc#:#Dateigröße (aufsteigend) irss#:#by_size_desc#:#Dateigröße (absteigend) irss#:#by_title_asc#:#Titel (aufsteigend) irss#:#by_title_desc#:#Titel (absteigend) +irss#:#confidential#:#Vertraulich +irss#:#confidential_no_download#:#Diese Ressource ist vertraulich und kann nur von ihrem Besitzer heruntergeladen werden. +irss#:#confidential_redacted#:#[Vertraulich] irss#:#create_directory#:#Verzeichnis erstellen irss#:#creation_date#:#Erstellungsdatum (Diese Revision) irss#:#directory_name#:#Verzeichnisname diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index b88393331fde..580ed1b0da9b 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -10748,6 +10748,9 @@ irss#:#by_size_asc#:#By File Size (Ascending) irss#:#by_size_desc#:#By File Size (Descending) irss#:#by_title_asc#:#By Title (Ascending) irss#:#by_title_desc#:#By Title (Descending) +irss#:#confidential#:#Confidential +irss#:#confidential_no_download#:#This resource is confidential and can only be downloaded by its owner. +irss#:#confidential_redacted#:#[Confidential] irss#:#create_directory#:#Create Directory irss#:#creation_date#:#Creation Date (This Revision) irss#:#directory_name#:#Directory Name