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 113ca83337 [ZEPPELIN-6684] Clone the requested note and check writer 
access
113ca83337 is described below

commit 113ca8333727de07214746c55fab1c03f4cd10b4
Author: renechoi <[email protected]>
AuthorDate: Thu Sep 10 00:54:03 2026 +0900

    [ZEPPELIN-6684] Clone the requested note and check writer access
    
    ### What is this PR for?
    
    `NotebookServer.cloneNote` ignored the `id` in the message and cloned the 
socket's note, skipping the writer check REST applies. It now clones the 
requested note, and that check moved from REST into 
`NotebookService.cloneNote`, keeping REST's `hasWritePermission`.
    
    Correction: revision 1 said the paths shared one rule. They did not. Unlike 
`checkPermission`, `hasWritePermission` allows everyone when `conf/shiro.ini` 
is absent, so it refused anonymous clones REST allows.
    
    ### What is the Jira issue?
    https://issues.apache.org/jira/browse/ZEPPELIN-6684
    
    ### How should this be tested?
    `NotebookServiceTest`: forbidden with shiro.ini, allowed without. 
`NotebookServerTest`: the requested note is cloned. 
`NotebookSecurityRestApiTest`: REST still 403s non-writers.
    
    ### Questions:
    No license or doc change.
    
    
    Closes #5443 from renechoi/ZEPPELIN-6684.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .../org/apache/zeppelin/rest/NotebookRestApi.java  |  1 -
 .../apache/zeppelin/service/NotebookService.java   | 10 +++
 .../org/apache/zeppelin/socket/NotebookServer.java |  5 +-
 .../zeppelin/rest/NotebookSecurityRestApiTest.java | 27 +++++++
 .../zeppelin/service/NotebookServiceTest.java      | 36 +++++++++-
 .../apache/zeppelin/socket/NotebookServerTest.java | 84 ++++++++++++++++++++++
 6 files changed, 159 insertions(+), 4 deletions(-)

diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
index 3c09a612f4..0df06056e8 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
@@ -566,7 +566,6 @@ public class NotebookRestApi extends AbstractRestApi {
       throws IOException, IllegalArgumentException {
 
     LOGGER.info("Clone note by JSON {}", message);
-    checkIfUserCanWrite(noteId, "Insufficient privileges you cannot clone this 
note");
     NewNoteRequest request = GSON.fromJson(message, NewNoteRequest.class);
     String newNoteName = null;
     String revisionId = null;
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 eacd970eb0..a0e7eb592f 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
@@ -339,6 +339,16 @@ public class NotebookService {
                           String newNotePath,
                           ServiceContext context,
                           ServiceCallback<Note> callback) throws IOException {
+    // NotebookRestApi used to check write access here with 
hasWritePermission, which also grants
+    // access when Zeppelin runs without conf/shiro.ini. checkPermission does 
not, so it would
+    // reject a WebSocket clone that the REST endpoint still allows on an 
anonymous deployment.
+    if (!authorizationService.hasWritePermission(context.getUserAndRoles(), 
noteId)) {
+      callback.onFailure(new ForbiddenException("Insufficient privileges to 
clone note " + noteId
+          + ".\nAllowed users or roles: " + 
authorizationService.getWriters(noteId)
+          + "\nBut the user " + context.getAutheInfo().getUser() + " belongs 
to: "
+          + context.getUserAndRoles()), context);
+      return null;
+    }
     //TODO(zjffdu) move these to Notebook
     if (StringUtils.isBlank(newNotePath)) {
       newNotePath = "/Cloned Note_" + noteId;
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
index 6ab2ef711e..fcf76a8105 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
@@ -1222,7 +1222,10 @@ public class NotebookServer implements 
AngularObjectRegistryListener,
   private void cloneNote(NotebookSocket conn,
                          ServiceContext context,
                          Message fromMessage) throws IOException {
-    String noteId = connectionManager.getAssociatedNoteId(conn);
+    String noteId = (String) fromMessage.get("id");
+    if (noteId == null) {
+      return;
+    }
     String name = (String) fromMessage.get("name");
     getNotebookService().cloneNote(noteId, name, context,
         new WebSocketServiceCallback<Note>(conn) {
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookSecurityRestApiTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookSecurityRestApiTest.java
index 130ea38270..4e36b9c80e 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookSecurityRestApiTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookSecurityRestApiTest.java
@@ -98,6 +98,25 @@ public class NotebookSecurityRestApiTest extends 
AbstractTestRestApi {
     deleteNoteForUser(noteId, "admin", "password1");
   }
 
+  @Test
+  void testThatOtherUserCannotCloneNoteIfPermissionSet() throws IOException {
+    String noteId = createNoteForUser("test_5", "admin", "password1");
+    try {
+      //set permission
+      String payload = "{ \"owners\": [\"admin\"], \"readers\": [\"user2\"], " 
+
+              "\"runners\": [\"user2\"], \"writers\": [\"user2\"] }";
+      CloseableHttpResponse put = httpPut("/notebook/" + noteId + 
"/permissions", payload , "admin", "password1");
+      assertThat("test set note permission method:", put, isAllowed());
+      put.close();
+
+      userTryCloneNote(noteId, "clone_of_test_5", "user1", "password2", 
isForbidden());
+    } finally {
+      // the notes of this class all land on the same default path, so a 
leftover note would
+      // make the next test fail on a name conflict instead of on its own 
assertion
+      deleteNoteForUser(noteId, "admin", "password1");
+    }
+  }
+
   @Test
   void testThatWriterCannotRemoveNote() throws IOException {
     String noteId = createNoteForUser("test_4", "admin", "password1");
@@ -126,6 +145,14 @@ public class NotebookSecurityRestApiTest extends 
AbstractTestRestApi {
     delete.close();
   }
 
+  private void userTryCloneNote(String noteId, String newNoteName, String 
user, String pwd,
+          Matcher<? super CloseableHttpResponse> m) throws IOException {
+    String jsonRequest = "{\"notePath\":\"" + newNoteName + "\"}";
+    CloseableHttpResponse post = httpPost("/notebook/" + noteId, jsonRequest, 
user, pwd);
+    assertThat(post, m);
+    post.close();
+  }
+
   private void userTryGetNote(String noteId, String user, String pwd,
           Matcher<? super CloseableHttpResponse> m) throws IOException {
     CloseableHttpResponse get = httpGet("/notebook/" + noteId, user, pwd);
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 1c4f554d50..549b6299b8 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
@@ -111,9 +111,11 @@ class NotebookServiceTest {
     zConf = ZeppelinConfiguration.load();
     
zConf.setProperty(ZeppelinConfiguration.ConfVars.ZEPPELIN_NOTEBOOK_DIR.getVarName(),
         notebookDir.getAbsolutePath());
-    // enable cron for the tests that update a note's cron settings
+    // The cron tests need cron enabled, and the protected clone test needs 
conf/shiro.ini so that
+    // Zeppelin does not treat the caller as an anonymous deployment.
     if ("testNoteUpdate()".equals(testInfo.getDisplayName())
-        || 
"testCronRefreshedOnlyWhenTheExpressionChanges()".equals(testInfo.getDisplayName()))
 {
+        || 
"testCronRefreshedOnlyWhenTheExpressionChanges()".equals(testInfo.getDisplayName())
+        || 
"testCloneNoteForbiddenWhenShiroIsConfigured()".equals(testInfo.getDisplayName()))
 {
       confDir = Files.createTempDirectory("confDir").toAbsolutePath().toFile();
       
zConf.setProperty(ZeppelinConfiguration.ConfVars.ZEPPELIN_CONF_DIR.getVarName(),
             confDir.getAbsolutePath());
@@ -519,6 +521,36 @@ class NotebookServiceTest {
     verify(schedulerService).refreshCron(noteId);
   }
 
+  @Test
+  void testCloneNoteForbiddenWhenShiroIsConfigured() throws IOException {
+    String noteId = notebookService.createNote("/clone_protected", "test", 
true, context, callback);
+    HashSet<String> otherUser = new HashSet<>();
+    otherUser.add("other_user");
+    authorizationService.setOwners(noteId, otherUser);
+    authorizationService.setWriters(noteId, otherUser);
+
+    reset(callback);
+    assertNull(notebookService.cloneNote(noteId, "/clone_protected_target", 
context, callback));
+    verify(callback).onFailure(any(ForbiddenException.class), eq(context));
+  }
+
+  @Test
+  void testCloneNoteAllowedForEverybodyWithoutShiro() throws IOException {
+    String noteId = notebookService.createNote("/clone_anonymous", "test", 
true, context, callback);
+    HashSet<String> otherUser = new HashSet<>();
+    otherUser.add("other_user");
+    authorizationService.setOwners(noteId, otherUser);
+    authorizationService.setWriters(noteId, otherUser);
+
+    // this setup has no conf/shiro.ini, so hasWritePermission grants write 
access to everybody.
+    // NotebookRestApi applied that same predicate before delegating here
+    reset(callback);
+    String clonedNoteId =
+        notebookService.cloneNote(noteId, "/clone_anonymous_target", context, 
callback);
+    assertNotNull(clonedNoteId);
+    verify(callback).onSuccess(any(Note.class), eq(context));
+  }
+
   @Test
   void testRenameNoteRejectsDuplicate() throws IOException {
     String note1Id = notebookService.createNote("/folder/note1", "test", true, 
context, callback);
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
index 325cb9ee85..d288851fbf 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
@@ -41,11 +41,13 @@ import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.stream.Collectors;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.thrift.TException;
@@ -906,6 +908,88 @@ class NotebookServerTest extends AbstractTestRestApi {
       });
   }
 
+  @Test
+  void testCloneNoteClonesTheRequestedSourceNote() throws IOException {
+    String sourceNoteId = null;
+    String associatedNoteId = null;
+    String clonedNoteId = null;
+
+    try {
+      sourceNoteId = notebook.createNote("/clone_source", anonymous);
+      notebook.processNote(sourceNoteId,
+        note -> {
+          Paragraph paragraph = note.addNewParagraph(anonymous);
+          paragraph.setText("%md source note");
+          paragraph.setAuthenticationInfo(anonymous);
+          notebook.saveNote(note, anonymous);
+          return null;
+        });
+      associatedNoteId = notebook.createNote("/clone_associated", anonymous);
+
+      NotebookSocket sock = createWebSocket();
+      // the socket is looking at another note, which used to decide what 
CLONE_NOTE copied
+      notebookServer.onMessage(sock, new Message(OP.GET_NOTE).put("id", 
associatedNoteId).toJson());
+
+      String clonedNotePath = "/clone_target_" + System.currentTimeMillis();
+      notebookServer.onMessage(sock, new Message(OP.CLONE_NOTE)
+          .put("id", sourceNoteId)
+          .put("name", clonedNotePath)
+          .toJson());
+
+      clonedNoteId = notebook.getNoteIdByPath(clonedNotePath);
+      assertNotNull(clonedNoteId, "the requested source note should have been 
cloned");
+      List<String> clonedTexts = notebook.processNote(clonedNoteId,
+        note -> 
note.getParagraphs().stream().map(Paragraph::getText).collect(Collectors.toList()));
+      assertEquals(Collections.singletonList("%md source note"), clonedTexts,
+          "CLONE_NOTE should copy the note in the request, not the one the 
socket is looking at");
+    } finally {
+      for (String noteId : new String[] {clonedNoteId, associatedNoteId, 
sourceNoteId}) {
+        if (noteId != null) {
+          notebook.removeNote(noteId, anonymous);
+        }
+      }
+    }
+  }
+
+  @Test
+  void testCloneNoteAppliesTheWriteRuleOfTheRestEndpoint() throws IOException {
+    String sourceNoteId = null;
+    String associatedNoteId = null;
+    String clonedNoteId = null;
+
+    try {
+      sourceNoteId = notebook.createNote("/clone_protected_source", anonymous);
+      authorizationService.setOwners(sourceNoteId, new 
HashSet<>(Arrays.asList("someone_else")));
+      authorizationService.setWriters(sourceNoteId, new 
HashSet<>(Arrays.asList("someone_else")));
+      associatedNoteId = notebook.createNote("/clone_protected_associated", 
anonymous);
+
+      NotebookSocket sock = createWebSocket();
+      notebookServer.onMessage(sock, new Message(OP.GET_NOTE).put("id", 
associatedNoteId).toJson());
+
+      String clonedNotePath = "/clone_protected_target_" + 
System.currentTimeMillis();
+      notebookServer.onMessage(sock, new Message(OP.CLONE_NOTE)
+          .put("id", sourceNoteId)
+          .put("name", clonedNotePath)
+          .toJson());
+
+      // this server has no conf/shiro.ini, so hasWritePermission grants write 
access to everybody
+      // and the REST clone endpoint accepts this note. The WebSocket path has 
to accept it too
+      clonedNoteId = notebook.getNoteIdByPath(clonedNotePath);
+      assertNotNull(clonedNoteId,
+          "an explicit ACL must not block the clone while Zeppelin runs in 
anonymous mode");
+    } finally {
+      if (sourceNoteId != null) {
+        authorizationService.setOwners(sourceNoteId, new HashSet<>());
+        authorizationService.setWriters(sourceNoteId, new HashSet<>());
+      }
+      for (String noteId : new String[] {clonedNoteId, associatedNoteId, 
sourceNoteId}) {
+        if (noteId != null) {
+          notebook.removeNote(noteId, anonymous);
+        }
+      }
+    }
+  }
+
   @Test
   void testGetParagraphList() throws IOException {
     String noteId = null;

Reply via email to