This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 1f80e7a767 [ZEPPELIN-6556] Personalized mode leaks a non-owner's 
paragraph edits into the shared master paragraph
1f80e7a767 is described below

commit 1f80e7a7678cb3d580a49c0637ade7ecc04c9d3c
Author: Lee SuJung <[email protected]>
AuthorDate: Mon Aug 10 23:58:56 2026 +0900

    [ZEPPELIN-6556] Personalized mode leaks a non-owner's paragraph edits into 
the shared master paragraph
    
    ### What is this PR for?
    A note can be switched to personalized mode so that each user gets their 
own copy of a paragraph and one user's form values and results do not affect 
another's.
    
    However, `NotebookService.runParagraph` writes the caller's `params`, 
`text`, `title` and `config` into the shared master paragraph *before* it 
checks whether the note is personalized:
    
    ```java
    p.setText(text);
    p.setTitle(title);
    p.setAuthenticationInfo(context.getAutheInfo());
    if (params != null && !params.isEmpty()) {
      p.settings.setParams(params);       // master paragraph
    }
    if (config != null && !config.isEmpty()) {
      p.mergeConfig(config);              // master paragraph
    }
    
    if (note.isPersonalizedMode()) {
      p = p.getUserParagraph(context.getAutheInfo().getUser());
      ...                                 // the user copy gets the same values
    }
    ```
    
    `notebook.saveNote(...)` then persists the polluted master. The same 
ordering exists in `updateParagraph`, and `setParagraphUsingMessage` is worse: 
its personalized branch re-fetches the master via 
`note.getParagraph(paragraphId)` instead of resolving the user copy, so it 
writes the same values into the master twice and never touches the user copy at 
all.
    
    So in personalized mode, a non-owner who edits a dynamic form and runs the 
paragraph silently overwrites the shared original.
    
    Because `Paragraph.userParagraphMap` is transient, the per-user copies do 
not survive a restart. The corruption stays invisible while the copies exist 
and surfaces later:
    
    - after a server restart the user copies are gone and every user sees 
whatever the last runner wrote
    - turning personalized mode off (`Note.clearUserParagraphs`) exposes the 
polluted master
    - a user opening the note for the first time clones the polluted master
    
    **The fix**: resolve the target paragraph *first* — when the note is 
personalized, switch to `getUserParagraph(user)` before any write — so the 
master paragraph is never mutated by another user's run or update. Applied to 
`runParagraph`, `updateParagraph` and `setParagraphUsingMessage`. Since the two 
branches wrote identical values, this also removes the duplicated write blocks.
    
    The only caller of `setParagraphUsingMessage` is `spell()`, which now 
records the spell result on the user copy in personalized mode — the intended 
behavior — instead of on the shared master.
    
    Note: `PersonalizeActionsIT.testDynamicFormAction` asserts the correct 
behavior (a non-owner's edit must not leak) but was passing against the old 
server behavior only because a late WebSocket broadcast reverted the typed form 
value before the run.
    
    ### What type of PR is it?
    Bug Fix
    
    ### Todos
    * [x] Resolve the user paragraph before writing params/text/title/config in 
`runParagraph`, `updateParagraph` and `setParagraphUsingMessage`
    * [x] Fix `setParagraphUsingMessage` re-fetching the master in its 
personalized branch
    * [x] Add a unit test that asserts master paragraph integrity in 
personalized mode
    
    ### What is the Jira issue?
    * [ZEPPELIN-6556](https://issues.apache.org/jira/browse/ZEPPELIN-6556)
    
    ### How should this be tested?
    New test 
`NotebookServiceTest#testRunParagraphInPersonalizedModeDoesNotPolluteMasterParagraph`:
 on a personalized note, it runs and then updates a paragraph as `user1` with 
new params/title, and asserts that the master paragraph's params and title are 
unchanged while `getUserParagraph("user1")` picks up the new values.
    
    ```
    ./mvnw -pl zeppelin-server test -Dtest=NotebookServiceTest 
-DfailIfNoTests=false
    ```
    
    Result with the fix: `Tests run: 6, Failures: 0, Errors: 0`.
    
    Manual verification: create a note with `%md echo "hello, 
${name=original}"`, run it, enable personalized mode, log in as a second user, 
change the form value and run. Then turn personalized mode off (or restart the 
server): the paragraph must still show `original`.
    
    ### Screenshots (if appropriate)
    N/A
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5360 from xhaktm00/ZEPPELIN-6556.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .../apache/zeppelin/service/NotebookService.java   | 52 ++++++++-----
 .../zeppelin/service/NotebookServiceTest.java      | 87 +++++++++++++++++++++-
 2 files changed, 119 insertions(+), 20 deletions(-)

diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
 
b/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
index 554b85f4de..38ce280ad6 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
@@ -33,6 +33,7 @@ import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
@@ -452,14 +453,19 @@ public class NotebookService {
       callback.onFailure(new IOException("paragraph is disabled."), context);
       return false;
     }
-    p.setText(text);
-    p.setTitle(title);
-    p.setAuthenticationInfo(context.getAutheInfo());
-    if (params != null && !params.isEmpty()) {
-      p.settings.setParams(params);
-    }
-    if (config != null && !config.isEmpty()) {
-      p.mergeConfig(config);
+    // In personalized mode only the note owner may update the master 
paragraph, so that
+    // new users inherit the owner's changes while a non-owner's changes stay 
in their copy.
+    if (!note.isPersonalizedMode()
+        || authorizationService.isOwner(note.getId(), 
context.getUserAndRoles())) {
+      p.setText(text);
+      p.setTitle(title);
+      p.setAuthenticationInfo(context.getAutheInfo());
+      if (params != null && !params.isEmpty()) {
+        p.settings.setParams(params);
+      }
+      if (config != null && !config.isEmpty()) {
+        p.mergeConfig(config);
+      }
     }
 
     if (note.isPersonalizedMode()) {
@@ -761,10 +767,15 @@ public class NotebookService {
           callback.onFailure(new ParagraphNotFoundException(paragraphId), 
context);
           return null;
         }
-        p.settings.setParams(params);
-        p.mergeConfig(config);
-        p.setTitle(title);
-        p.setText(text);
+        // In personalized mode only the note owner may update the master 
paragraph, so that
+        // new users inherit the owner's changes while a non-owner's changes 
stay in their copy.
+        if (!note.isPersonalizedMode()
+            || authorizationService.isOwner(noteId, 
context.getUserAndRoles())) {
+          p.settings.setParams(params);
+          p.mergeConfig(config);
+          p.setTitle(title);
+          p.setText(text);
+        }
         if (note.isPersonalizedMode()) {
           p = p.getUserParagraph(context.getAutheInfo().getUser());
           p.settings.setParams(params);
@@ -1393,16 +1404,21 @@ public class NotebookService {
                                              String text, String title, 
Map<String, Object> params,
                                              Map<String, Object> config) {
     Paragraph p = note.getParagraph(paragraphId);
-    p.setText(text);
-    p.setTitle(title);
     AuthenticationInfo subject =
         new AuthenticationInfo(fromMessage.principal, fromMessage.roles, 
fromMessage.ticket);
-    p.setAuthenticationInfo(subject);
-    p.settings.setParams(params);
-    p.setConfig(config);
+    // In personalized mode only the note owner may update the master 
paragraph, so that
+    // new users inherit the owner's changes while a non-owner's changes stay 
in their copy.
+    if (!note.isPersonalizedMode()
+        || authorizationService.isOwner(note.getId(), new 
HashSet<>(subject.getUsersAndRoles()))) {
+      p.setText(text);
+      p.setTitle(title);
+      p.setAuthenticationInfo(subject);
+      p.settings.setParams(params);
+      p.setConfig(config);
+    }
 
     if (note.isPersonalizedMode()) {
-      p = note.getParagraph(paragraphId);
+      p = p.getUserParagraph(subject.getUser());
       p.setText(text);
       p.setTitle(title);
       p.setAuthenticationInfo(subject);
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/service/NotebookServiceTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/service/NotebookServiceTest.java
index 0a176ac8b4..2d53f34dec 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/service/NotebookServiceTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/service/NotebookServiceTest.java
@@ -36,6 +36,7 @@ import java.io.File;
 import java.io.FileWriter;
 import java.io.IOException;
 import java.nio.file.Files;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -88,6 +89,7 @@ class NotebookServiceTest {
   private File confDir;
   private SearchService searchService;
   private Notebook notebook;
+  private AuthorizationService authorizationService;
   private ServiceContext context =
       new ServiceContext(AuthenticationInfo.ANONYMOUS, new HashSet<>());
 
@@ -136,8 +138,7 @@ class NotebookServiceTest {
     
when(mockInterpreterSetting.getStatus()).thenReturn(InterpreterSetting.Status.READY);
     Credentials credentials = new Credentials();
     NoteManager noteManager = new NoteManager(notebookRepo, zConf);
-    AuthorizationService authorizationService =
-        new AuthorizationService(noteManager, zConf, storage);
+    authorizationService = new AuthorizationService(noteManager, zConf, 
storage);
     notebook =
         new Notebook(
             zConf,
@@ -588,6 +589,88 @@ class NotebookServiceTest {
     verify(callback).onSuccess(p, context);
   }
 
+  @Test
+  void testRunParagraphInPersonalizedModeDoesNotPolluteMasterParagraph() 
throws IOException {
+    String note1Id = notebookService.createNote("/note_personalized", "test", 
true, context, callback);
+    // make "admin" the note owner so that "user1" below is a non-owner
+    authorizationService.setOwners(note1Id, Collections.singleton("admin"));
+    Map<String, Object> masterParams = new HashMap<>();
+    masterParams.put("name", "master");
+    String paragraphId = notebook.processNote(note1Id,
+      note1 -> {
+        note1.setPersonalizedMode(true);
+        Paragraph p = note1.getParagraph(0);
+        p.setText("1+1");
+        p.settings.setParams(masterParams);
+        return p.getId();
+      });
+
+    ServiceContext user1Context = new ServiceContext(new 
AuthenticationInfo("user1"),
+        new HashSet<>(Collections.singleton("user1")));
+    Map<String, Object> user1Params = new HashMap<>();
+    user1Params.put("name", "user1");
+
+    reset(callback);
+    boolean runStatus = notebook.processNote(note1Id,
+      note1 -> {
+        return notebookService.runParagraph(note1, paragraphId, "user1_title", 
"1+1",
+          user1Params, new HashMap<>(), null, false, true, user1Context, 
callback);
+      });
+    assertTrue(runStatus);
+
+    notebook.processNote(note1Id,
+      note1 -> {
+        Paragraph master = note1.getParagraph(paragraphId);
+        assertEquals(masterParams, master.settings.getParams());
+        assertNull(master.getTitle());
+        Paragraph user1Paragraph = master.getUserParagraph("user1");
+        assertEquals(user1Params, user1Paragraph.settings.getParams());
+        assertEquals("user1_title", user1Paragraph.getTitle());
+        return null;
+      });
+
+    // updateParagraph must not pollute the master paragraph either
+    reset(callback);
+    Map<String, Object> user1UpdatedParams = new HashMap<>();
+    user1UpdatedParams.put("name", "user1_updated");
+    notebookService.updateParagraph(note1Id, paragraphId, 
"user1_updated_title", "1+1",
+        user1UpdatedParams, new HashMap<>(), user1Context, callback);
+
+    notebook.processNote(note1Id,
+      note1 -> {
+        Paragraph master = note1.getParagraph(paragraphId);
+        assertEquals(masterParams, master.settings.getParams());
+        assertNull(master.getTitle());
+        Paragraph user1Paragraph = master.getUserParagraph("user1");
+        assertEquals(user1UpdatedParams, user1Paragraph.settings.getParams());
+        assertEquals("user1_updated_title", user1Paragraph.getTitle());
+        return null;
+      });
+
+    // the note owner's changes must reach the master paragraph so new users 
inherit them
+    reset(callback);
+    ServiceContext adminContext = new ServiceContext(new 
AuthenticationInfo("admin"),
+        new HashSet<>(Collections.singleton("admin")));
+    Map<String, Object> adminParams = new HashMap<>();
+    adminParams.put("name", "admin");
+    notebookService.updateParagraph(note1Id, paragraphId, "admin_title", "1+1",
+        adminParams, new HashMap<>(), adminContext, callback);
+
+    notebook.processNote(note1Id,
+      note1 -> {
+        Paragraph master = note1.getParagraph(paragraphId);
+        assertEquals(adminParams, master.settings.getParams());
+        assertEquals("admin_title", master.getTitle());
+        Paragraph adminParagraph = master.getUserParagraph("admin");
+        assertEquals(adminParams, adminParagraph.settings.getParams());
+        assertEquals("admin_title", adminParagraph.getTitle());
+        // the non-owner's personal copy must keep their own values
+        Paragraph user1Paragraph = master.getUserParagraph("user1");
+        assertEquals(user1UpdatedParams, user1Paragraph.settings.getParams());
+        return null;
+      });
+  }
+
   @Test
   void testNormalizeNotePath() throws IOException {
     assertEquals("/Untitled Note", notebookService.normalizeNotePath(" "));

Reply via email to