Skip to content

[ZEPPELIN-5934] Check folder permissions before rename, trash and remove - #5383

Open
sylee6529 wants to merge 1 commit into
apache:masterfrom
sylee6529:ZEPPELIN-5934-folder-permission-check
Open

[ZEPPELIN-5934] Check folder permissions before rename, trash and remove#5383
sylee6529 wants to merge 1 commit into
apache:masterfrom
sylee6529:ZEPPELIN-5934-folder-permission-check

Conversation

@sylee6529

Copy link
Copy Markdown
Contributor

What is this PR for?

Folder level operations in NotebookService ran without any permission check. renameFolder and moveFolderToTrash carried a //TODO(zjffdu) folder permission check, and removeFolder and restoreFolder had no check at all. A user who could only read one note in a folder was able to rename, trash or permanently delete that whole folder, even though the same user could not touch the note itself.

Deriving a folder permission. Zeppelin has no folder level ACL. AuthorizationService is keyed by note id only and the word "folder" does not appear in it; a folder is just the in-memory tree NoteManager builds out of note paths. So checkFolderPermission derives the permission of a folder from the notes it holds, walked recursively, and allows the operation only when the caller holds the required permission on every one of them. No new ACL concept, no storage or migration change.

Permission levels follow the note level policy they mirror, so this PR extends an existing rule rather than inventing one:

Folder operation Level Note level counterpart
FOLDER_RENAME OWNER NOTE_RENAME = OWNER
MOVE_FOLDER_TO_TRASH OWNER MOVE_NOTE_TO_TRASH = OWNER
REMOVE_FOLDER OWNER DEL_NOTE = OWNER
RESTORE_FOLDER WRITER RESTORE_NOTE = WRITER

Restore being lower than trash looks asymmetric, but it is exactly the asymmetry the note level policy already has.

All-or-nothing. The check completes before any repository call, so a refused operation leaves the folder exactly as it was instead of half applied. Applying a move or a remove only to the subset the caller owns would leave a partially emptied folder that the caller can neither see nor undo.

A folder holding no note is allowed. Removing the last note leaves the folder node in the tree, and there is nothing left to protect. Refusing instead would leave empty folders nobody can ever clean up.

What the error message reveals. It names the folder and how many notes blocked the call, but not which ones, since the caller may not be allowed to read them. Those paths go to the server log instead. This is the same split NotebookRestApi.ownerPermissionError and NotebookServer.permissionError already use, and it matches the existing note level message, which also does not name the note. It is the one place the folder message deliberately differs from the note one: it omits Allowed users or roles, because a folder has many notes and the union of their owners would leak exactly what the message is trying to withhold.

checkPermission was split into hasPermission and getAllowedEntities so the folder check can reuse the level dispatch as a plain predicate. The user facing message and behaviour of the note level check are unchanged.

Relation to earlier work. PR #4624 (ZEPPELIN-5934, closed by the stale bot after inactivity, never merged) took the same "derive from the notes" approach, and this PR keeps that idea. It differs in four ways: restoreFolder is WRITER rather than OWNER so it lines up with RESTORE_NOTE; NoteManager.getFolder stays private, since the already public getNoteInfoRecursively is enough and NoteManager.Folder need not reach the service layer; the unrelated FolderPathAlreadyExistsException commit is left out; and the tests the reviewer asked for are included.

Scope

Limited to the four operations that act on one named folder. EMPTY_TRASH and RESTORE_ALL act on the shared trash as a whole and are untouched, on purpose.

emptyTrash does destroy trashed notes without a check, so it can still remove a folder that removeFolder now refuses. That gap is not introduced here: removeNote already requires OWNER for a single note today, and emptyTrash already bypasses it. It belongs to note level enforcement, and deciding who may empty a trash that every user shares is a product question worth its own issue rather than a rider on this fix. Happy to file it as a follow-up.

Two adjacent issues are also left alone: renameFolder checks the source folder but not the destination, so renaming onto an existing folder path is still unguarded (ZEPPELIN-5333), and moveFolderToTrash detects trash name conflicts with containsNote on a folder path, which never matches.

Known limitation

The check and the operation are not atomic. If another user adds a note to the folder between them, that note is included in the operation without having been checked. Every existing note level checkPermission call has the same shape, so closing it would mean introducing locking across the whole permission layer rather than in this path alone.

What type of PR is it?

Bug Fix

What is the Jira issue?

How should this be tested?

Automated. mvn test -pl zeppelin-server -Dtest='NotebookServiceTest,NotebookServerTest,NotebookSecurityRestApiTest'

NotebookServiceTest

  • testFolderOperationsRequirePermissionOnEveryNote — a folder holds two notes, the caller owns one of them, and the blocking note sits in a sub folder so the check is only reached by walking recursively. renameFolder, moveFolderToTrash and removeFolder are all refused with a ForbiddenException, both notes are still at their original paths afterwards, and the message names the folder but not the blocking note. The owner of both notes then performs the same operations successfully.
  • testRestoreFolderRequiresWriterPermission — a reader is refused, a writer succeeds and the note is back at its original path.
  • testRemoveFolderHoldingNoNoteIsAllowed — pins the empty folder decision.

NotebookServerTest#testRemoveFolderRequiresOwnerOnEveryNote covers the real entry point. There is no REST endpoint for folder operations; every caller of these service methods is in NotebookServer, so this drives a real MiniZeppelinServer with an actual REMOVE_FOLDER WebSocket message carrying a TicketContainer principal. It asserts the notes survive and the client receives AUTH_INFO, then that the same message succeeds once the caller owns everything.

Without the production change, two of the NotebookServiceTest cases and the NotebookServerTest case fail; the WebSocket one fails on the folder actually having been deleted by a non-owner.

NotebookSecurityRestApiTest (a real server with Shiro over HTTP) passes unchanged, covering the checkPermission split.

Manual: with two users, give user A owner of a note inside a folder and leave user B as reader only, then have B rename, trash and delete the folder from the notebook list. Each is refused and the folder stays.

Questions

  • Does the license files need to update? No.
  • Is there breaking changes for older versions? Behaviour changes on purpose: folder rename, trash, remove and restore now fail for users who lack the permission on every note in the folder, where they previously succeeded. Deployments that relied on the missing check will see refusals. No API, config or storage format change.
  • Does this needs documentation? No. It brings folder operations in line with the note permission model the docs already describe.

NotebookService applied folder level operations without looking at
permissions at all: renameFolder and moveFolderToTrash carried a
"TODO(zjffdu) folder permission check", and removeFolder and
restoreFolder had no check to begin with. A user who could only read a
note was able to rename, trash or permanently delete the whole folder
holding it, even though that same user could not touch the note itself.

Zeppelin has no folder level ACL. A folder is only the in-memory tree
NoteManager builds out of note paths, so checkFolderPermission derives
the permission of a folder from the notes under it, recursively, and
allows the operation only when the caller holds the required permission
on every one of them. The levels follow the note level policy they
mirror: OWNER for rename, trash and remove, matching NOTE_RENAME,
MOVE_NOTE_TO_TRASH and DEL_NOTE, and WRITER for restore, matching
RESTORE_NOTE.

The check is all-or-nothing and runs before any repository call, so a
refused operation leaves the folder exactly as it was rather than half
applied. The error message names the folder and how many notes blocked
the call, but not which ones, because the caller may not be allowed to
read them; those paths go to the server log instead, the same split
NotebookRestApi.ownerPermissionError already uses.

Scope is the four operations that act on one named folder. EMPTY_TRASH
and RESTORE_ALL act on the shared trash as a whole and stay untouched:
emptyTrash already bypasses the OWNER check that removeNote performs on
a single note, so that gap belongs to note level enforcement and needs
its own decision about who may empty a trash that every user shares.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant