CVE-2024-23344: Insufficient Information (NVD-CWE-noinfo)
value: Tuleap is an Open Source Suite to improve management of software developments and collaboration. Some users might get access to restricted information when a process validates the permissions of multiple users (e.g. mail notifications). This issue has been patched in version 15.4.99.140 of Tuleap Community Edition.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 6.5
Published: 2024-02-06T16:15Z
Repository: https://github.com/Enalean/tuleap
Language: PHP
Files changed: plugins/agiledashboard/tests/unit/AgileDashboard/Milestone/Sidebar/PromotedMilestoneBuilderTest.php, plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetBareMilestoneByArtifactIdTest.php, plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetMilestoneTest.php, plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetPaginatedMilestonesTest.php, plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetTopMilestonesTest.php, plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryTest.php, plugins/roadmap/tests/unit/Roadmap/REST/v1/IterationsRetrieverTest.php, plugins/roadmap/tests/unit/Roadmap/REST/v1/RoadmapTasksRetrieverTest.php, plugins/tracker/include/Tracker/Artifact/Artifact.php, plugins/tracker/tests/unit/Builders/ArtifactTestBuilder.php, plugins/tracker/tests/unit/Tracker/Artifact/ArtifactTest.php, plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeNotConfiguredTest.php, plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithDurationTest.php, plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithEndDateTest.php
Commit date: 2024-01-19 18:50:01 +0100
Commit message: fix: CCE user cannot update artifact sometimes

How to reproduce:
Have an artifact A with a comment containing a cross reference to another
artifact B like `See art #123`.
In the tracker of the artifact A, upload a Custom Code Execution module
in the administration.

There is an example module if you need one:
```
cd plugins/tracker_cce/wasm_modules/post-action-add-comment
nix-shell
cargo build --target wasm32-wasi --release
```
Then you can upload the generated file:
`plugins/tracker_cce/wasm_modules/post-action-add-comment/target/wasm32-wasi/release/post-action-add-comment.wasm`

The example module take an integer field `field_a`, an integer field
`field_b`, and fill a text field `field_sum`. You will need them for the
test. Or use your own module.

Now update the artifact A. The (async) execution of the module should be a
success and a new comment is added to the artifact by CCE user.

Closes request #35862: Artifact userCanView not properly cached
Part of story #35093: execute custom code as artifact post action

Change-Id: Id08ce6570c1dd2c7ebedcd70339f9d9d6b008d58

Diff: diff --git a/plugins/agiledashboard/tests/unit/AgileDashboard/Milestone/Sidebar/PromotedMilestoneBuilderTest.php b/plugins/agiledashboard/tests/unit/AgileDashboard/Milestone/Sidebar/PromotedMilestoneBuilderTest.php
index f30925ad80fc..56c8610d47f5 100644
--- a/plugins/agiledashboard/tests/unit/AgileDashboard/Milestone/Sidebar/PromotedMilestoneBuilderTest.php
+++ b/plugins/agiledashboard/tests/unit/AgileDashboard/Milestone/Sidebar/PromotedMilestoneBuilderTest.php
@@ -65,11 +65,11 @@ final class PromotedMilestoneBuilderTest extends TestCase
         $this->user        = UserTestBuilder::anActiveUser()->build();
         $this->project     = ProjectTestBuilder::aProject()->withId(101)->build();
         $this->tracker     = TrackerTestBuilder::aTracker()->build();
         $this->artifact    = ArtifactTestBuilder::anArtifact(1)
             ->inTracker($this->tracker)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withChangesets($this->changeset)
             ->build();
         $this->title_field = TrackerFormElementStringFieldBuilder::aStringField(301)->build();
 
         $this->timeframe_builder = $this->createMock(SemanticTimeframeBuilder::class);
@@ -91,11 +91,11 @@ final class PromotedMilestoneBuilderTest extends TestCase
 
     public function testItReturnNothingOptionWhenUserCanNotViewArtifact(): void
     {
         $artifact = ArtifactTestBuilder::anArtifact(1)
             ->inTracker($this->tracker)
-            ->userCanView(false)
+            ->userCannotView($this->user)
             ->build();
 
         self::assertTrue($this->builder->build($artifact, $this->user, $this->project)->isNothing());
     }
 
diff --git a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetBareMilestoneByArtifactIdTest.php b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetBareMilestoneByArtifactIdTest.php
index 077786017bda..14b82480cf29 100644
--- a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetBareMilestoneByArtifactIdTest.php
+++ b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetBareMilestoneByArtifactIdTest.php
@@ -116,11 +116,11 @@ final class MilestoneFactoryGetBareMilestoneByArtifactIdTest extends \Tuleap\Tes
 
         $artifact = ArtifactTestBuilder::anArtifact($this->artifact_id)
             ->withTitle('title')
             ->inTracker($planning_tracker)
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
 
diff --git a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetMilestoneTest.php b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetMilestoneTest.php
index 766ca1dd14ce..b56cf872588b 100644
--- a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetMilestoneTest.php
+++ b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetMilestoneTest.php
@@ -138,38 +138,38 @@ final class MilestoneFactoryGetMilestoneTest extends \Tuleap\Test\PHPUnit\TestCa
         );
 
         $release_1_0   = ArtifactTestBuilder::anArtifact(1)
             ->withTitle('release_1_0')
             ->withChangesets(ChangesetTestBuilder::aChangeset('1')->build())
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
         $sprint_1      = ArtifactTestBuilder::anArtifact(101)
             ->withTitle('sprint_1')
             ->withChangesets(ChangesetTestBuilder::aChangeset('2')->build())
             ->inTracker($sprints_tracker)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
         $sprint_2      = ArtifactTestBuilder::anArtifact(102)
             ->withTitle('sprint_2')
             ->withChangesets(ChangesetTestBuilder::aChangeset('3')->build())
             ->inTracker($sprints_tracker)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
         $hackfest_2012 = ArtifactTestBuilder::anArtifact(102)
             ->withTitle('hackfest_2012')
             ->withChangesets(ChangesetTestBuilder::aChangeset('4')->build())
             ->inTracker($hackfests_tracker)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
 
@@ -226,11 +226,11 @@ final class MilestoneFactoryGetMilestoneTest extends \Tuleap\Test\PHPUnit\TestCa
 
         $artifact = ArtifactTestBuilder::anArtifact(100)
             ->withTitle('release_1_0')
             ->withChangesets(ChangesetTestBuilder::aChangeset('1')->build())
             ->inTracker($tracker)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
 
diff --git a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetPaginatedMilestonesTest.php b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetPaginatedMilestonesTest.php
index 159c21611eaa..5c8b513f20a5 100644
--- a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetPaginatedMilestonesTest.php
+++ b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetPaginatedMilestonesTest.php
@@ -395,11 +395,11 @@ final class MilestoneFactoryGetPaginatedMilestonesTest extends \Tuleap\Test\PHPU
 
         return ArtifactTestBuilder::anArtifact($artifact_id)
             ->withTitle('title')
             ->inTracker($milestone_tracker)
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
     }
diff --git a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetTopMilestonesTest.php b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetTopMilestonesTest.php
index 9fd3c1724a3f..98eba09d5c29 100644
--- a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetTopMilestonesTest.php
+++ b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryGetTopMilestonesTest.php
@@ -119,19 +119,19 @@ final class MilestoneFactoryGetTopMilestonesTest extends \Tuleap\Test\PHPUnit\Te
     public function testItReturnsMilestonePerArtifact(): void
     {
         $artifact_1 = ArtifactTestBuilder::anArtifact(1)
             ->inTracker($this->tracker)
             ->withChangesets(ChangesetTestBuilder::aChangeset('1')->build())
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
         $artifact_2 = ArtifactTestBuilder::anArtifact(2)
             ->inTracker($this->tracker)
             ->withChangesets(ChangesetTestBuilder::aChangeset('2')->build())
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
 
@@ -174,11 +174,11 @@ final class MilestoneFactoryGetTopMilestonesTest extends \Tuleap\Test\PHPUnit\Te
         $artifact_1->shouldReceive('getTracker')->andReturn($this->tracker);
 
         $artifact_2 = ArtifactTestBuilder::anArtifact(2)
             ->inTracker($this->tracker)
             ->withChangesets(ChangesetTestBuilder::aChangeset('1')->build())
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->withAncestors([])
             ->build();
 
diff --git a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryTest.php b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryTest.php
index 65c893739c76..723f49443089 100644
--- a/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryTest.php
+++ b/plugins/agiledashboard/tests/unit/AgileDashboard/Planning/MilestoneFactoryTest.php
@@ -207,11 +207,11 @@ class MilestoneFactoryTest extends \Tuleap\Test\PHPUnit\TestCase
     private function getAnArtifact(int $id, bool $is_open, string $status, \Tracker $tracker): Artifact
     {
         return ArtifactTestBuilder::anArtifact($id)
             ->inTracker($tracker)
             ->withChangesets(ChangesetTestBuilder::aChangeset('1')->build())
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->withStatus($status)
             ->isOpen($is_open)
             ->withAncestors([])
             ->build();
diff --git a/plugins/roadmap/tests/unit/Roadmap/REST/v1/IterationsRetrieverTest.php b/plugins/roadmap/tests/unit/Roadmap/REST/v1/IterationsRetrieverTest.php
index 981703fdaae3..df8aed974a37 100644
--- a/plugins/roadmap/tests/unit/Roadmap/REST/v1/IterationsRetrieverTest.php
+++ b/plugins/roadmap/tests/unit/Roadmap/REST/v1/IterationsRetrieverTest.php
@@ -920,11 +920,11 @@ final class IterationsRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
                         $end_date_field,
                     )
                 )
             );
 
-        $iteration = $this->anArtifactWithoutChangesetValue(201, 'Title', $this->tracker, true);
+        $iteration = $this->anArtifactWithoutChangesetValue(201, 'Title', $this->tracker);
 
         $this->artifact_factory
             ->expects(self::once())
             ->method('getPaginatedArtifactsByTrackerId')
             ->with(self::ITERATION_TRACKER_ID, 0, 10, false)
@@ -1291,11 +1291,11 @@ final class IterationsRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
                         $end_date_field,
                     )
                 )
             );
 
-        $iteration = $this->anArtifact(123, "Sprint W42", $this->tracker, true);
+        $iteration = $this->anArtifact(123, "Sprint W42", $this->tracker);
 
         $this->artifact_factory
             ->expects(self::once())
             ->method('getPaginatedArtifactsByTrackerId')
             ->with(self::ITERATION_TRACKER_ID, 0, 10, false)
@@ -1351,39 +1351,39 @@ final class IterationsRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
             ->method('searchById')
             ->with(self::ROADMAP_ID)
             ->willReturn($db_result);
     }
 
-    private function anArtifact(int $id, string $title, Tracker $tracker, bool $readable): Artifact
+    private function anArtifact(int $id, string $title, Tracker $tracker): Artifact
     {
         $changeset       = $this->createMock(\Tracker_Artifact_Changeset::class);
         $changeset_value = $this->createMock(\Tracker_Artifact_ChangesetValue_Date::class);
         $changeset->method('getValue')->willReturn($changeset_value);
         $changeset_value->method('getTimestamp')->willReturn(1234567890);
 
         return ArtifactTestBuilder::anArtifact($id)
             ->withTitle($title)
             ->inTracker($tracker)
             ->withChangesets($changeset)
-            ->userCanView($readable)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
-    private function anArtifactWithoutChangesetValue(int $id, string $title, Tracker $tracker, bool $readable): Artifact
+    private function anArtifactWithoutChangesetValue(int $id, string $title, Tracker $tracker): Artifact
     {
         $changeset       = $this->createMock(\Tracker_Artifact_Changeset::class);
         $changeset_value = $this->createMock(\Tracker_Artifact_ChangesetValue_Date::class);
         $changeset->method('getValue')->willReturn($changeset_value);
         $changeset_value->method('getTimestamp')->willReturn(null);
 
         return ArtifactTestBuilder::anArtifact($id)
             ->withTitle($title)
             ->inTracker($tracker)
             ->withChangesets($changeset)
-            ->userCanView($readable)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 }
diff --git a/plugins/roadmap/tests/unit/Roadmap/REST/v1/RoadmapTasksRetrieverTest.php b/plugins/roadmap/tests/unit/Roadmap/REST/v1/RoadmapTasksRetrieverTest.php
index 54405143658b..042d64d4f1d8 100644
--- a/plugins/roadmap/tests/unit/Roadmap/REST/v1/RoadmapTasksRetrieverTest.php
+++ b/plugins/roadmap/tests/unit/Roadmap/REST/v1/RoadmapTasksRetrieverTest.php
@@ -664,11 +664,11 @@ final class RoadmapTasksRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
                 )
             );
 
         $task_201 = $this->anArtifact(201, 'Do this', $tracker, true, $semantic_timeframe);
         $task_202 = $this->anArtifact(202, 'Do that', $tracker, false, $semantic_timeframe);
-        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, true, $semantic_timeframe);
+        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, $semantic_timeframe);
         $task_204 = $this->anArtifact(204, 'Done more than 1 year ago', $tracker, true, $semantic_timeframe);
 
         $this->mockEffort($total_effort_field, [201 => 8, 203 => 3]);
         $this->mockEffort($remaining_effort_field, [201 => 5, 203 => 0.75]);
 
@@ -804,11 +804,11 @@ final class RoadmapTasksRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
                 )
             );
 
         $task_201 = $this->anArtifact(201, 'Do this', $tracker, true, $semantic_timeframe);
         $task_202 = $this->anArtifact(202, 'Do that', $tracker, false, $semantic_timeframe);
-        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, true, $semantic_timeframe);
+        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, $semantic_timeframe);
         $task_204 = $this->anArtifact(204, 'Done more than 1 year ago', $tracker, true, $semantic_timeframe);
 
         $this->mockEffort($total_effort_field, [201 => 8, 203 => 3]);
         $this->mockEffort($remaining_effort_field, [201 => 5, 203 => 0.75]);
 
@@ -1077,11 +1077,11 @@ final class RoadmapTasksRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
                 $tracker         => new SemanticProgress($tracker, new MethodNotConfigured()),
                 $another_tracker => new SemanticProgress($another_tracker, new MethodNotConfigured()),
             });
 
         $task_201 = $this->anArtifact(201, 'Do this', $tracker, true, $semantic_timeframe_tracker);
-        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $another_tracker, true, $semantic_timeframe_another_tracker);
+        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $another_tracker, $semantic_timeframe_another_tracker);
 
 
         $artifacts = [$task_201, $task_203];
         $this->artifact_factory
             ->expects(self::once())
@@ -1220,21 +1220,23 @@ final class RoadmapTasksRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
                     ->withTimestamp(1234567890)
                     ->build()
             );
         }
 
-        return ArtifactTestBuilder::anArtifact($id)
+        $artifact_test_builder = ArtifactTestBuilder::anArtifact($id)
             ->withTitle($title)
             ->inTracker($tracker)
             ->withChangesets($changeset)
-            ->userCanView($readable)
             ->withParent(null)
-            ->isOpen(true)
-            ->build();
+            ->isOpen(true);
+
+        return $readable
+            ? $artifact_test_builder->userCanView($this->user)->build()
+            : $artifact_test_builder->userCannotView($this->user)->build();
     }
 
-    private function anArtifactWithoutStartDate(int $id, string $title, Tracker $tracker, bool $readable, SemanticTimeframe $semantic_timeframe): Artifact
+    private function anArtifactWithoutStartDate(int $id, string $title, Tracker $tracker, SemanticTimeframe $semantic_timeframe): Artifact
     {
         $changeset   = ChangesetTestBuilder::aChangeset('1')->build();
         $start_field = $semantic_timeframe->getStartDateField();
         if ($start_field !== null) {
             $changeset->setFieldValue($start_field, null);
@@ -1250,11 +1252,11 @@ final class RoadmapTasksRetrieverTest extends \Tuleap\Test\PHPUnit\TestCase
         }
         return ArtifactTestBuilder::anArtifact($id)
             ->withTitle($title)
             ->inTracker($tracker)
             ->withChangesets($changeset)
-            ->userCanView($readable)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 }
diff --git a/plugins/tracker/include/Tracker/Artifact/Artifact.php b/plugins/tracker/include/Tracker/Artifact/Artifact.php
index cb145af2c1f0..15541be98ba8 100644
--- a/plugins/tracker/include/Tracker/Artifact/Artifact.php
+++ b/plugins/tracker/include/Tracker/Artifact/Artifact.php
@@ -240,14 +240,17 @@ class Artifact implements Recent_Element_Interface, Tracker_Dispatchable_Interfa
     private $authorized_ugroups;
     /**
      * @var DBTransactionExecutor
      */
     private $transaction_executor;
-    private bool | null $user_permission_cache = null;
-    private ?Artifact $parent                  = null;
-    private bool $has_set_parent               = false;
-    private bool | null $is_open               = null;
+    /**
+     * @var array<int, bool>
+     */
+    private array $user_permission_cache = [];
+    private ?Artifact $parent            = null;
+    private bool $has_set_parent         = false;
+    private bool | null $is_open         = null;
 
     /**
      * Constructor
      *
      * @param int $id The Id of the artifact
@@ -307,11 +310,14 @@ class Artifact implements Recent_Element_Interface, Tracker_Dispatchable_Interfa
     public function useArtifactPermissions()
     {
         return $this->use_artifact_permissions;
     }
 
-    public function setUserCanView(bool $user_can_view): void
+    /**
+     * @param array<int, bool> $user_can_view
+     */
+    public function setUserCanView(array $user_can_view): void
     {
         $this->user_permission_cache = $user_can_view;
     }
 
     /**
@@ -321,12 +327,12 @@ class Artifact implements Recent_Element_Interface, Tracker_Dispatchable_Interfa
      *
      * @return bool user can view the artifact
      */
     public function userCanView(?PFUser $user = null)
     {
-        if ($this->user_permission_cache !== null) {
-            return $this->user_permission_cache;
+        if ($user && isset($this->user_permission_cache[(int) $user->getId()])) {
+            return $this->user_permission_cache[(int) $user->getId()];
         }
 
         $user_manager       = $this->getUserManager();
         $permission_checker = new Tracker_Permission_PermissionChecker(
             $user_manager,
@@ -340,14 +346,15 @@ class Artifact implements Recent_Element_Interface, Tracker_Dispatchable_Interfa
         );
 
         if ($user === null) {
             $user = $user_manager->getCurrentUser();
         }
+        if (! isset($this->user_permission_cache[(int) $user->getId()])) {
+            $this->user_permission_cache[(int) $user->getId()] = PermissionsCache::userCanView($this, $user, $permission_checker);
+        }
 
-        $this->user_permission_cache = PermissionsCache::userCanView($this, $user, $permission_checker);
-
-        return $this->user_permission_cache;
+        return $this->user_permission_cache[(int) $user->getId()];
     }
 
     public function userCanUpdate(PFUser $user): bool
     {
         if ($user->isAnonymous() || ! $this->userCanView($user)) {
diff --git a/plugins/tracker/tests/unit/Builders/ArtifactTestBuilder.php b/plugins/tracker/tests/unit/Builders/ArtifactTestBuilder.php
index 299d60e72a0e..4198f3a2165f 100644
--- a/plugins/tracker/tests/unit/Builders/ArtifactTestBuilder.php
+++ b/plugins/tracker/tests/unit/Builders/ArtifactTestBuilder.php
@@ -45,11 +45,15 @@ final class ArtifactTestBuilder
     private \Tracker_Artifact_Changeset|null $last_changeset = null;
     /**
      * @var \Tracker_ArtifactFactory | null
      */
     private $artifact_factory;
-    private bool|null $user_can_view = null;
+
+    /**
+     * @var array<int, bool>
+     */
+    private array $user_can_view = [];
 
     /**
      * @var \Project|null
      */
     private $project;
@@ -134,13 +138,19 @@ final class ArtifactTestBuilder
         $this->last_changeset = $last_changeset;
 
         return $this;
     }
 
-    public function userCanView(bool $user_can_view): self
+    public function userCanView(PFUser $user): self
+    {
+        $this->user_can_view[$user->getId()] = true;
+        return $this;
+    }
+
+    public function userCannotView(PFUser $user): self
     {
-        $this->user_can_view = $user_can_view;
+        $this->user_can_view[$user->getId()] = false;
         return $this;
     }
 
     public function withParent(?Artifact $artifact): self
     {
@@ -204,13 +214,11 @@ final class ArtifactTestBuilder
         }
 
         if ($this->last_changeset) {
             $artifact->setLastChangeset($this->last_changeset);
         }
-        if ($this->user_can_view !== null) {
-            $artifact->setUserCanView($this->user_can_view);
-        }
+        $artifact->setUserCanView($this->user_can_view);
         if ($this->has_parent) {
             $artifact->setParent($this->parent);
         }
         if ($this->is_open !== null) {
             $artifact->setIsOpen($this->is_open);
diff --git a/plugins/tracker/tests/unit/Tracker/Artifact/ArtifactTest.php b/plugins/tracker/tests/unit/Tracker/Artifact/ArtifactTest.php
new file mode 100644
index 000000000000..ad7a656394c2
--- /dev/null
+++ b/plugins/tracker/tests/unit/Tracker/Artifact/ArtifactTest.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2024 - Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\Tracker\Artifact;
+
+use Tuleap\Test\Builders\UserTestBuilder;
+use Tuleap\Test\PHPUnit\TestCase;
+
+final class ArtifactTest extends TestCase
+{
+    public function testUserCanViewCache(): void
+    {
+        $artifact = new Artifact(1, 2, 3, 4, false);
+
+        $bob   = UserTestBuilder::anActiveUser()->withId(1)->build();
+        $alice = UserTestBuilder::anActiveUser()->withId(2)->build();
+
+        $artifact->setUserCanView([
+            $bob->getId() => false,
+            $alice->getId() => true,
+        ]);
+
+        self::assertFalse($artifact->userCanView($bob));
+        self::assertTrue($artifact->userCanView($alice));
+    }
+}
diff --git a/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeNotConfiguredTest.php b/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeNotConfiguredTest.php
index a184ef439c3c..f1750ec9fd68 100644
--- a/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeNotConfiguredTest.php
+++ b/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeNotConfiguredTest.php
@@ -115,22 +115,24 @@ final class TimeframeNotConfiguredTest extends TestCase
         );
     }
 
     public function testItReturnsAnEmptyDatePeriodWithAnErrorMessageForArtifactREST(): void
     {
+        $user = UserTestBuilder::anActiveUser()->build();
+
         $changeset = $this->createMock(\Tracker_Artifact_Changeset::class);
         $artifact  = ArtifactTestBuilder::anArtifact(1)
             ->withTitle('release_1_0')
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($user)
             ->build();
         $tracker   = TrackerTestBuilder::aTracker()->withName('User story')->build();
         $changeset->expects(self::once())->method('getTracker')->willReturn($tracker);
 
         $date_period = $this->timeframe->buildDatePeriodWithoutWeekendForChangesetForREST(
             $artifact->getLastChangeset(),
-            UserTestBuilder::anActiveUser()->build(),
+            $user,
             new NullLogger()
         );
 
         self::assertNull($date_period->getStartDate());
         self::assertNull($date_period->getDuration());
@@ -141,24 +143,26 @@ final class TimeframeNotConfiguredTest extends TestCase
         );
     }
 
     public function testItThrowsAnExceptionWhenInChartContext(): void
     {
+        $user = UserTestBuilder::anActiveUser()->build();
+
         $changeset = $this->createMock(\Tracker_Artifact_Changeset::class);
         $artifact  = ArtifactTestBuilder::anArtifact(1)
             ->withTitle('release_1_0')
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($user)
             ->build();
         $tracker   = TrackerTestBuilder::aTracker()->withName('User story')->build();
 
         $changeset->expects(self::once())->method('getTracker')->willReturn($tracker);
 
         self::expectException(\Tracker_FormElement_Chart_Field_Exception::class);
 
         $this->timeframe->buildDatePeriodWithoutWeekendForChangesetChartRendering(
             $artifact->getLastChangeset(),
-            UserTestBuilder::anActiveUser()->build(),
+            $user,
             new NullLogger()
         );
     }
 }
diff --git a/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithDurationTest.php b/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithDurationTest.php
index d72297077fa5..b592df43af02 100644
--- a/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithDurationTest.php
+++ b/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithDurationTest.php
@@ -480,11 +480,11 @@ final class TimeframeWithDurationTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('1')
             ->withTitle('title')
             ->inTracker($this->tracker)
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
@@ -501,11 +501,11 @@ final class TimeframeWithDurationTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('1')
             ->withTitle('title')
             ->inTracker($this->tracker)
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
@@ -522,11 +522,11 @@ final class TimeframeWithDurationTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('1')
             ->withTitle('title')
             ->inTracker($this->tracker)
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
@@ -538,11 +538,11 @@ final class TimeframeWithDurationTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('1')
             ->withTitle('title')
             ->inTracker($this->tracker)
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 }
diff --git a/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithEndDateTest.php b/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithEndDateTest.php
index 364d5e012ae1..3b90ec934d4f 100644
--- a/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithEndDateTest.php
+++ b/plugins/tracker/tests/unit/Tracker/Semantic/Timeframe/TimeframeWithEndDateTest.php
@@ -480,11 +480,11 @@ final class TimeframeWithEndDateTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('4')
             ->withTitle('title')
             ->inTracker(TrackerTestBuilder::aTracker()->build())
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
@@ -501,11 +501,11 @@ final class TimeframeWithEndDateTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('4')
             ->withTitle('title')
             ->inTracker(TrackerTestBuilder::aTracker()->build())
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
@@ -522,11 +522,11 @@ final class TimeframeWithEndDateTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('4')
             ->withTitle('title')
             ->inTracker(TrackerTestBuilder::aTracker()->build())
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 
@@ -538,11 +538,11 @@ final class TimeframeWithEndDateTest extends \Tuleap\Test\PHPUnit\TestCase
 
         return ArtifactTestBuilder::anArtifact('4')
             ->withTitle('title')
             ->inTracker(TrackerTestBuilder::aTracker()->build())
             ->withChangesets($changeset)
-            ->userCanView(true)
+            ->userCanView($this->user)
             ->withParent(null)
             ->isOpen(true)
             ->build();
     }
 }

Vulnerable code:             ->userCanView(true)
            ->userCanView(false)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
        $iteration = $this->anArtifactWithoutChangesetValue(201, 'Title', $this->tracker, true);
        $iteration = $this->anArtifact(123, "Sprint W42", $this->tracker, true);
    private function anArtifact(int $id, string $title, Tracker $tracker, bool $readable): Artifact
            ->userCanView($readable)
    private function anArtifactWithoutChangesetValue(int $id, string $title, Tracker $tracker, bool $readable): Artifact
            ->userCanView($readable)
        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, true, $semantic_timeframe);
        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, true, $semantic_timeframe);
        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $another_tracker, true, $semantic_timeframe_another_tracker);
        return ArtifactTestBuilder::anArtifact($id)
            ->userCanView($readable)
            ->isOpen(true)
            ->build();
    private function anArtifactWithoutStartDate(int $id, string $title, Tracker $tracker, bool $readable, SemanticTimeframe $semantic_timeframe): Artifact
            ->userCanView($readable)
    private bool | null $user_permission_cache = null;
    private ?Artifact $parent                  = null;
    private bool $has_set_parent               = false;
    private bool | null $is_open               = null;
    public function setUserCanView(bool $user_can_view): void
        if ($this->user_permission_cache !== null) {
            return $this->user_permission_cache;
        $this->user_permission_cache = PermissionsCache::userCanView($this, $user, $permission_checker);

        return $this->user_permission_cache;
    private bool|null $user_can_view = null;
    public function userCanView(bool $user_can_view): self
        $this->user_can_view = $user_can_view;
        if ($this->user_can_view !== null) {
            $artifact->setUserCanView($this->user_can_view);
        }
            ->userCanView(true)
            UserTestBuilder::anActiveUser()->build(),
            ->userCanView(true)
            UserTestBuilder::anActiveUser()->build(),
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
            ->userCanView(true)
Fixed code:             ->userCanView($this->user)
            ->userCannotView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
        $iteration = $this->anArtifactWithoutChangesetValue(201, 'Title', $this->tracker);
        $iteration = $this->anArtifact(123, "Sprint W42", $this->tracker);
    private function anArtifact(int $id, string $title, Tracker $tracker): Artifact
            ->userCanView($this->user)
    private function anArtifactWithoutChangesetValue(int $id, string $title, Tracker $tracker): Artifact
            ->userCanView($this->user)
        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, $semantic_timeframe);
        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $tracker, $semantic_timeframe);
        $task_203 = $this->anArtifactWithoutStartDate(203, 'Do those', $another_tracker, $semantic_timeframe_another_tracker);
        $artifact_test_builder = ArtifactTestBuilder::anArtifact($id)
            ->isOpen(true);

        return $readable
            ? $artifact_test_builder->userCanView($this->user)->build()
            : $artifact_test_builder->userCannotView($this->user)->build();
    private function anArtifactWithoutStartDate(int $id, string $title, Tracker $tracker, SemanticTimeframe $semantic_timeframe): Artifact
            ->userCanView($this->user)
    /**
     * @var array<int, bool>
     */
    private array $user_permission_cache = [];
    private ?Artifact $parent            = null;
    private bool $has_set_parent         = false;
    private bool | null $is_open         = null;
    /**
     * @param array<int, bool> $user_can_view
     */
    public function setUserCanView(array $user_can_view): void
        if ($user && isset($this->user_permission_cache[(int) $user->getId()])) {
            return $this->user_permission_cache[(int) $user->getId()];
        if (! isset($this->user_permission_cache[(int) $user->getId()])) {
            $this->user_permission_cache[(int) $user->getId()] = PermissionsCache::userCanView($this, $user, $permission_checker);
        }
        return $this->user_permission_cache[(int) $user->getId()];

    /**
     * @var array<int, bool>
     */
    private array $user_can_view = [];
    public function userCanView(PFUser $user): self
    {
        $this->user_can_view[$user->getId()] = true;
        return $this;
    }

    public function userCannotView(PFUser $user): self
        $this->user_can_view[$user->getId()] = false;
        $artifact->setUserCanView($this->user_can_view);
<?php
/**
 * Copyright (c) Enalean, 2024 - Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\Tracker\Artifact;

use Tuleap\Test\Builders\UserTestBuilder;
use Tuleap\Test\PHPUnit\TestCase;

final class ArtifactTest extends TestCase
{
    public function testUserCanViewCache(): void
    {
        $artifact = new Artifact(1, 2, 3, 4, false);

        $bob   = UserTestBuilder::anActiveUser()->withId(1)->build();
        $alice = UserTestBuilder::anActiveUser()->withId(2)->build();

        $artifact->setUserCanView([
            $bob->getId() => false,
            $alice->getId() => true,
        ]);

        self::assertFalse($artifact->userCanView($bob));
        self::assertTrue($artifact->userCanView($alice));
    }
}
        $user = UserTestBuilder::anActiveUser()->build();

            ->userCanView($user)
            $user,
        $user = UserTestBuilder::anActiveUser()->build();

            ->userCanView($user)
            $user,
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)
            ->userCanView($this->user)