From 4c3daa22d53b0858eb56739262b7d2c6341a949f Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 4 Aug 2026 03:36:30 +0900 Subject: [PATCH 1/5] [ZEPPELIN-4495] Unify REST and WebSocket authentication with Shiro --- conf/shiro.ini.template | 11 +- conf/zeppelin-site.xml.template | 10 +- docs/setup/operation/configuration.md | 10 +- docs/setup/operation/upgrading.md | 40 ++ docs/setup/security/shiro_authentication.md | 55 +- docs/usage/rest_api/configuration.md | 2 +- docs/usage/zeppelin_sdk/client_api.md | 55 +- pom.xml | 11 +- .../examples/ZeppelinClientExample.java | 74 +-- .../examples/ZeppelinClientExample2.java | 60 ++- .../org/apache/zeppelin/client/ZSession.java | 153 +++++- .../zeppelin/client/ZeppelinClient.java | 165 ++++-- .../websocket/ZeppelinWebSocketClient.java | 41 +- .../apache/zeppelin/client/ZSessionTest.java | 121 +++++ .../zeppelin/client/ZeppelinClientTest.java | 164 ++++++ .../ZeppelinWebSocketClientTest.java | 41 ++ .../ZeppelinClientIntegrationTest.java | 8 +- ...ZeppelinClientWithAuthIntegrationTest.java | 9 +- .../notebook/repo/GitHubNotebookRepo.java | 8 +- .../zeppelin/conf/ZeppelinConfiguration.java | 33 +- .../notebook/AuthorizationService.java | 203 ++++++-- .../org/apache/zeppelin/notebook/Note.java | 2 +- .../apache/zeppelin/notebook/NoteAuth.java | 177 +++++-- .../apache/zeppelin/notebook/NoteManager.java | 337 ++++++++++--- .../apache/zeppelin/notebook/Notebook.java | 88 ++-- .../notebook/repo/GitNotebookRepo.java | 134 ++++- .../notebook/repo/VFSNotebookRepo.java | 15 +- .../realm/ActiveDirectoryGroupRealm.java | 18 +- .../org/apache/zeppelin/realm/LdapRealm.java | 15 +- .../realm/kerberos/KerberosRealm.java | 16 +- .../apache/zeppelin/rest/AbstractRestApi.java | 11 +- .../apache/zeppelin/rest/LoginRestApi.java | 43 +- .../apache/zeppelin/rest/NotebookRestApi.java | 5 +- .../apache/zeppelin/rest/SecurityRestApi.java | 7 +- .../JettyWebSocketUpgradeFilterInstaller.java | 42 ++ .../zeppelin/server/ZeppelinServer.java | 16 +- .../service/AuthenticatedIdentity.java | 72 +++ .../service/AuthenticatedSessionService.java | 216 ++++++++ .../service/AuthenticationService.java | 7 + .../service/ConfigurationService.java | 7 + .../service/NoAuthenticationService.java | 5 + .../zeppelin/service/NotebookService.java | 338 +++++++++++-- .../service/ServiceContextFactory.java | 42 ++ .../SessionAuthenticationException.java | 30 ++ .../service/ShiroAuthenticationService.java | 129 +++-- .../zeppelin/socket/ConnectionManager.java | 73 ++- .../zeppelin/socket/NotebookServer.java | 472 ++++++++++++------ .../zeppelin/socket/NotebookSocket.java | 51 +- .../zeppelin/socket/SessionConfigurator.java | 28 ++ .../zeppelin/ticket/TicketContainer.java | 12 +- .../org/apache/zeppelin/utils/CorsUtils.java | 50 +- .../conf/ZeppelinConfigurationTest.java | 29 ++ .../notebook/AuthorizationServiceTest.java | 203 ++++++++ .../zeppelin/notebook/NoteAuthTest.java | 39 ++ .../zeppelin/notebook/NoteManagerTest.java | 337 +++++++++++++ .../zeppelin/notebook/NotebookTest.java | 5 + .../notebook/repo/GitNotebookRepoTest.java | 232 ++++++++- .../notebook/repo/VFSNotebookRepoTest.java | 30 ++ .../apache/zeppelin/realm/LdapRealmTest.java | 20 + .../realm/kerberos/KerberosRealmTest.java | 52 ++ .../zeppelin/rest/AbstractRestApiTest.java | 60 +++ .../zeppelin/rest/AbstractTestRestApi.java | 29 +- .../zeppelin/server/CorsFilterTest.java | 12 +- ...tyWebSocketUpgradeFilterInstallerTest.java | 68 +++ .../service/AuthenticatedIdentityTest.java | 54 ++ .../AuthenticatedSessionServiceTest.java | 296 +++++++++++ .../service/ConfigurationServiceTest.java | 10 + .../service/NoAuthenticationServiceTest.java | 38 ++ .../zeppelin/service/NotebookServiceTest.java | 186 ++++++- .../service/ServiceContextFactoryTest.java | 39 ++ .../ShiroAuthenticationServiceTest.java | 74 ++- .../service/shiro/AbstractShiroTest.java | 2 +- .../AnonymousWebSocketAuthenticationTest.java | 92 ++++ .../socket/ConnectionManagerTest.java | 71 +++ .../NotebookServerAuthenticationTest.java | 468 +++++++++++++++++ .../zeppelin/socket/NotebookServerTest.java | 58 ++- .../zeppelin/socket/NotebookSocketTest.java | 58 +++ .../socket/SessionConfiguratorTest.java | 107 ++++ .../socket/WebSocketAuthenticationTest.java | 291 +++++++++++ .../apache/zeppelin/utils/CorsUtilsTest.java | 20 +- .../websocket-authentication.spec.ts | 96 ++++ .../classic-websocket-authentication.spec.ts | 92 ++++ .../interfaces/websocket-message.interface.ts | 3 - .../projects/zeppelin-sdk/src/message.ts | 31 +- .../src/app/services/message.service.ts | 20 +- .../src/components/login/login.controller.js | 12 +- .../websocket/websocket-event.factory.js | 30 +- .../websocket/websocket-event.factory.test.js | 96 ++++ 88 files changed, 6333 insertions(+), 759 deletions(-) create mode 100644 zeppelin-client/src/test/java/org/apache/zeppelin/client/ZSessionTest.java create mode 100644 zeppelin-client/src/test/java/org/apache/zeppelin/client/ZeppelinClientTest.java create mode 100644 zeppelin-server/src/main/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstaller.java create mode 100644 zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedIdentity.java create mode 100644 zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java create mode 100644 zeppelin-server/src/main/java/org/apache/zeppelin/service/ServiceContextFactory.java create mode 100644 zeppelin-server/src/main/java/org/apache/zeppelin/service/SessionAuthenticationException.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/notebook/AuthorizationServiceTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/realm/kerberos/KerberosRealmTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractRestApiTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstallerTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedIdentityTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/service/NoAuthenticationServiceTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/service/ServiceContextFactoryTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/socket/AnonymousWebSocketAuthenticationTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookSocketTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java create mode 100644 zeppelin-server/src/test/java/org/apache/zeppelin/socket/WebSocketAuthenticationTest.java create mode 100644 zeppelin-web-angular/e2e/tests/authentication/websocket-authentication.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/classic/classic-websocket-authentication.spec.ts create mode 100644 zeppelin-web/src/components/websocket/websocket-event.factory.test.js diff --git a/conf/shiro.ini.template b/conf/shiro.ini.template index 24b18e27109..c5117099062 100644 --- a/conf/shiro.ini.template +++ b/conf/shiro.ini.template @@ -78,6 +78,8 @@ user3 = password4, role2 # authc = org.apache.zeppelin.realm.kerberos.KerberosAuthenticationFilter sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager +### Match URL rules case-insensitively so case variants cannot bypass a protected chain. +filterChainResolver.caseInsensitive = true ### If caching of user is required then uncomment below lines #cacheManager = org.apache.shiro.cache.MemoryConstrainedCacheManager @@ -90,11 +92,13 @@ cookie.httpOnly = true ### Restrict the session cookie to same-site requests by default. Set to NONE only when ### Zeppelin is intentionally embedded into a different origin (and 'cookie.secure = true'). cookie.sameSite = LAX -### Uncomment the below line only when Zeppelin is running over HTTPS -#cookie.secure = true +### Shiro emits Secure only for requests that are HTTPS (including correctly forwarded HTTPS). +cookie.secure = true sessionManager.sessionIdCookie = $cookie securityManager.sessionManager = $sessionManager +### Zeppelin does not use remember-me authentication. Disable it explicitly until Shiro 3. +securityManager.rememberMeManager = null # 86,400,000 milliseconds = 24 hour securityManager.sessionManager.globalSessionTimeout = 86400000 shiro.loginUrl = /api/login @@ -115,12 +119,15 @@ admin = * # # IMPORTANT: Order matters: URL path expressions are evaluated against an incoming request # in the order they are defined and the FIRST MATCH WINS. +# The same Shiro filter handles REST requests and the notebook WebSocket handshake. Keep `/ws` +# authenticated unless anonymous notebook access is intentional; use `/ws = anon` to opt out. # # To allow anonymous access to all but the stated urls, # uncomment the line second last line (/** = anon) and comment the last line (/** = authc) # /api/version = anon /api/cluster/address = anon +/ws = authc # Allow all authenticated users to restart interpreters on a notebook page. # Comment out the following line if you would like to authorize only admin users to restart interpreters. /api/interpreter/setting/restart/** = authc diff --git a/conf/zeppelin-site.xml.template b/conf/zeppelin-site.xml.template index d5e54b91f16..8d85cc0a3dc 100755 --- a/conf/zeppelin-site.xml.template +++ b/conf/zeppelin-site.xml.template @@ -531,8 +531,8 @@ zeppelin.server.allowed.origins - * - Allowed sources for REST and WebSocket requests (i.e. http://onehost:8080,http://otherhost.com). If you leave * you are vulnerable to https://issues.apache.org/jira/browse/ZEPPELIN-173 + + Exact allowed origins for credentialed REST and WebSocket requests (i.e. http://onehost:8080,https://otherhost.com). Empty allows only the configured local server origin. Use * only for an intentionally public deployment. @@ -559,6 +559,12 @@ Size in characters of the maximum text message to be received by websocket. Defaults to 10240000 + + zeppelin.websocket.authorization.roles.refresh.interval.ms + 1000 + Maximum age in milliseconds of a role snapshot reused for WebSocket broadcasts. The Shiro session is still validated before every delivery. Use 0 to refresh roles for every broadcast. + + zeppelin.server.default.dir.allowed false diff --git a/docs/setup/operation/configuration.md b/docs/setup/operation/configuration.md index 9588cd25a5b..d7aa6fcd8b5 100644 --- a/docs/setup/operation/configuration.md +++ b/docs/setup/operation/configuration.md @@ -121,8 +121,8 @@ Sources descending by priority:
ZEPPELIN_ALLOWED_ORIGINS
zeppelin.server.allowed.origins
- * - Enables a way to specify a ',' separated list of allowed origins for REST and websockets.
e.g. http://localhost:8080 + (empty) + Comma-separated exact origins allowed for credentialed REST and WebSocket requests. Empty permits only the configured local server origin. Include scheme and port, e.g. http://localhost:8080.
ZEPPELIN_CREDENTIALS_PERSIST
@@ -406,6 +406,12 @@ Sources descending by priority: 1024000 Size(in characters) of the maximum text message that can be received by websocket. + +
ZEPPELIN_WEBSOCKET_AUTHORIZATION_ROLES_REFRESH_INTERVAL_MS
+
zeppelin.websocket.authorization.roles.refresh.interval.ms
+ 1000 + Maximum age in milliseconds of a role snapshot reused for WebSocket broadcasts. Session logout and expiry are still validated before every delivery. Use 0 to refresh roles for every broadcast. +
ZEPPELIN_SERVER_DEFAULT_DIR_ALLOWED
zeppelin.server.default.dir.allowed
diff --git a/docs/setup/operation/upgrading.md b/docs/setup/operation/upgrading.md index 673fcac59c7..cf9c49abf36 100644 --- a/docs/setup/operation/upgrading.md +++ b/docs/setup/operation/upgrading.md @@ -35,6 +35,46 @@ So, copying `notebook` and `conf` directory should be enough. ## Migration Guide +### WebSocket authentication migration + +This release updates Apache Shiro 1.13 to Shiro 2.2.1's Jakarta artifacts while retaining +Zeppelin's Java 11 baseline. This is the smallest supported bridge for the unified authentication +change. Shiro 3 requires Java 17 and should be handled as a separate runtime upgrade rather than +bundling a JDK transition into this security change. + +Shiro 2.x is now end-of-life, so this bridge is transitional. Zeppelin does not include the +`shiro-guice` module affected by CVE-2026-56091, and the shipped configuration disables the +RememberMe feature affected by CVE-2026-56130. Operators with custom Shiro configuration should +also keep RememberMe disabled. A Java 17 and Shiro 3 migration remains the long-term follow-up. + +When Shiro is enabled, notebook WebSockets now authenticate during the HTTP upgrade with the same +Shiro session cookie as REST. Custom `shiro.ini` files should add an explicit `/ws = authc` rule +before a broader anonymous rule. The shipped template already contains this rule. + +Existing `shiro.ini` files are preserved during upgrades and do not inherit new template defaults. +Under `[main]`, also set `filterChainResolver.caseInsensitive = true`, configure the injected +`JSESSIONID` cookie with `httpOnly = true`, `sameSite = LAX`, and `secure = true`, and set +`securityManager.rememberMeManager = null`. Shiro emits `Secure` only for requests Jetty recognizes +as HTTPS; TLS-terminating proxies must forward the original scheme. Zeppelin does not use +remember-me authentication, so disabling that unused facility reduces the exposed authentication +surface until the separate JDK 17 / Shiro 3 upgrade. + +Browser and Java clients must retain the REST login cookie and send it when opening `/ws`. +WebSocket message fields such as `principal`, `roles`, and `ticket` are no longer authentication +credentials. Proxies must continue forwarding `Cookie`, `Origin`, `Upgrade`, and `Connection` +headers to the `/ws` endpoint. + +The default value of `zeppelin.server.allowed.origins` is now empty instead of `*`. An empty value +permits only the configured local server origin, so deployments accessed through another hostname, +port, or reverse proxy must list each trusted browser origin explicitly before upgrading. + +`ZeppelinClient` now owns an isolated REST session and implements `AutoCloseable`; applications +should close each client after use. `ZSession` reuses that session cookie for `/ws`. HTTPS and WSS +both keep the JVM's normal certificate and hostname verification, including for Knox deployments; +configure the JVM trust store when an internal certificate authority is required. +Process-global `Unirest.config()` and `Unirest.shutDown()` no longer configure or close these +isolated clients; use JVM networking properties and close each `ZeppelinClient` directly. + ### Upgrading from Zeppelin 0.9, 0.10 to 0.11 - From 0.11, The type of `Pegdown` for parsing markdown was deprecated ([ZEPPELIN-5529](https://issues.apache.org/jira/browse/ZEPPELIN-2619)). It will use `Flexmark` instead. diff --git a/docs/setup/security/shiro_authentication.md b/docs/setup/security/shiro_authentication.md index 98cebc937ae..053fa77f351 100644 --- a/docs/setup/security/shiro_authentication.md +++ b/docs/setup/security/shiro_authentication.md @@ -29,7 +29,9 @@ limitations under the License. When you connect to Apache Zeppelin, you will be asked to enter your credentials. Once you logged in, then you have access to all notes including other user's notes. ## Important Note -By default, Zeppelin allows anonymous access. It is strongly recommended that you consider setting up Apache Shiro for authentication (as described in this document, see 2 Secure the Websocket channel), or only deploy and use Zeppelin in a secured and trusted environment. +By default, Zeppelin allows anonymous access. It is strongly recommended that you configure +Apache Shiro for both REST and WebSocket authentication as described below, or only deploy and +use Zeppelin in a secured and trusted environment. ## Security Setup You can setup **Zeppelin notebook authentication** in some simple steps. @@ -67,6 +69,42 @@ user3 = password4, role2 ``` You can set the roles for each users next to the password. +### REST and WebSocket authentication + +REST requests under `/api/*` and the notebook WebSocket handshake at `/ws` pass through the same +Shiro filter and use the same Shiro session cookie. The browser sends the `JSESSIONID` cookie during +the WebSocket HTTP upgrade; identity fields in WebSocket messages are not authentication +credentials. A logout closes WebSockets for that exact session immediately; an expired session is +closed when the next WebSocket frame is validated. + +The `[urls]` section is the include/exclude policy for both transports. Rules use first-match-wins +ordering. For example, the following keeps the version endpoint public while requiring one +authenticated session for all notebook WebSocket connections and remaining REST endpoints: + +``` +[urls] +/api/version = anon +/ws = authc +/** = authc +``` + +Use `anon` only for paths that are deliberately excluded from authentication. To allow an +anonymous notebook WebSocket explicitly, configure `/ws = anon`. Shiro sees `/ws` as one upgrade +URL, so operation-level notebook permissions are still enforced by Zeppelin's notebook ACL and +service authorization rather than separate Shiro URL patterns. + +Origin validation is performed before the WebSocket upgrade. Configure +`zeppelin.server.allowed.origins` with the exact trusted browser origins; avoid `*` in secured +deployments. + +For streamed output, Zeppelin revalidates the Shiro session before every WebSocket delivery while +reusing a recent role snapshot to avoid querying remote realms for every output chunk. The default +`zeppelin.websocket.authorization.roles.refresh.interval.ms` value is `1000`, so Zeppelin's own +snapshot adds at most one second to directory role-revocation handling for passive WebSocket +broadcasts. Realm-specific authorization caches can add their own delay. Set the value to `0` to +refresh roles for every broadcast. This setting does not delay logout or session-expiry detection, +and direct notebook ACL changes are checked on every broadcast. + ## Groups and permissions (optional) In case you want to leverage user groups and permissions, use one of the following configuration for LDAP or AD under `[main]` segment in `shiro.ini`. @@ -293,18 +331,22 @@ chown hdfs:hadoop /etc/security/http_secret chmod 440 /etc/security/http_secret ``` -## Secure Cookie for Zeppelin Sessions (optional) -Zeppelin can be configured to set `HttpOnly` flag in the session cookie. With this configuration, Zeppelin cookies can -not be accessed via client side scripts thus preventing majority of Cross-site scripting (XSS) attacks. +## Secure Cookie for Zeppelin Sessions +Zeppelin configures `HttpOnly`, `SameSite=Lax`, and HTTPS-aware `Secure` handling for the Shiro +session cookie by default. `HttpOnly` prevents client-side scripts from reading the cookie. Shiro +emits the `Secure` attribute only when the servlet request is secure, so local HTTP development +continues to work while HTTPS deployments keep the cookie off clear-text requests. When TLS is +terminated at a reverse proxy, configure forwarded request handling so Jetty sees the original +HTTPS scheme. -To enable secure cookie support via Shiro, add the following lines in `conf/shiro.ini` under `[main]` section, after -defining a `sessionManager`. +The default `conf/shiro.ini.template` contains the following settings under `[main]`: ``` cookie = org.apache.shiro.web.servlet.SimpleCookie cookie.name = JSESSIONID cookie.secure = true cookie.httpOnly = true +cookie.sameSite = LAX sessionManager.sessionIdCookie = $cookie ``` @@ -316,6 +358,7 @@ Since Shiro provides **url-based security**, you can hide the information by com ``` [urls] +/ws = authc /api/interpreter/** = authc, roles[admin] /api/configurations/** = authc, roles[admin] /api/credential/** = authc, roles[admin] diff --git a/docs/usage/rest_api/configuration.md b/docs/usage/rest_api/configuration.md index 249e1ad1077..49c1c34d862 100644 --- a/docs/usage/rest_api/configuration.md +++ b/docs/usage/rest_api/configuration.md @@ -82,7 +82,7 @@ If you work with Apache Zeppelin and find a need for an additional REST API, ple "zeppelin.notebook.homescreen": "", "zeppelin.notebook.storage": "org.apache.zeppelin.notebook.repo.VFSNotebookRepo", "zeppelin.interpreter.connect.timeout": "30000", - "zeppelin.server.allowed.origins":"*", + "zeppelin.server.allowed.origins":"", "zeppelin.encoding": "UTF-8" } } diff --git a/docs/usage/zeppelin_sdk/client_api.md b/docs/usage/zeppelin_sdk/client_api.md index c0d6a37f2c2..3686b11221d 100644 --- a/docs/usage/zeppelin_sdk/client_api.md +++ b/docs/usage/zeppelin_sdk/client_api.md @@ -36,34 +36,39 @@ The entry point of zeppelin client api is class `ZeppelinClient`. All the operat {% highlight java %} ClientConfig clientConfig = new ClientConfig("http://localhost:8080"); -ZeppelinClient zClient = new ZeppelinClient(clientConfig); - -String zeppelinVersion = zClient.getVersion(); -System.out.println("Zeppelin version: " + zeppelinVersion); - -// execute note 2A94M5J1Z paragraph by paragraph -try { - ParagraphResult paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150210-015259_1403135953"); - System.out.println("Execute the 1st spark tutorial paragraph, paragraph result: " + paragraphResult); - - paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150210-015302_1492795503"); - System.out.println("Execute the 2nd spark tutorial paragraph, paragraph result: " + paragraphResult); - - Map parameters = new HashMap<>(); - parameters.put("maxAge", "40"); - paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150212-145404_867439529", parameters); - System.out.println("Execute the 3rd spark tutorial paragraph, paragraph result: " + paragraphResult); - - parameters = new HashMap<>(); - parameters.put("marital", "married"); - paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150213-230422_1600658137", parameters); - System.out.println("Execute the 4th spark tutorial paragraph, paragraph result: " + paragraphResult); -} finally { - // you need to stop interpreter explicitly if you are running paragraph separately. - zClient.stopInterpreter("2A94M5J1Z", "spark"); +try (ZeppelinClient zClient = new ZeppelinClient(clientConfig)) { + String zeppelinVersion = zClient.getVersion(); + System.out.println("Zeppelin version: " + zeppelinVersion); + + // execute note 2A94M5J1Z paragraph by paragraph + try { + ParagraphResult paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150210-015259_1403135953"); + System.out.println("Execute the 1st spark tutorial paragraph, paragraph result: " + paragraphResult); + + paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150210-015302_1492795503"); + System.out.println("Execute the 2nd spark tutorial paragraph, paragraph result: " + paragraphResult); + + Map parameters = new HashMap<>(); + parameters.put("maxAge", "40"); + paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150212-145404_867439529", parameters); + System.out.println("Execute the 3rd spark tutorial paragraph, paragraph result: " + paragraphResult); + + parameters = new HashMap<>(); + parameters.put("marital", "married"); + paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150213-230422_1600658137", parameters); + System.out.println("Execute the 4th spark tutorial paragraph, paragraph result: " + paragraphResult); + } finally { + // you need to stop interpreter explicitly if you are running paragraph separately. + zClient.stopInterpreter("2A94M5J1Z", "spark"); + } } {% endhighlight %} +Each `ZeppelinClient` owns an isolated HTTP session and connection pool. Close it after use, as in +the try-with-resources example above. Its REST session cookie is reused automatically when a +`ZSession` opens the notebook WebSocket. HTTPS and WSS use normal JVM certificate and hostname +verification; configure the JVM trust store for an internal certificate authority. + Here we list some importance apis of ZeppelinClient, for the completed api, please refer its javadoc. {% highlight java %} diff --git a/pom.xml b/pom.xml index 8ddaa73ec78..b0447054037 100644 --- a/pom.xml +++ b/pom.xml @@ -131,8 +131,8 @@ 2.15.1 3.2.2 1.4 - 1.13.0 - 1.80 + 2.2.1 + 1.84 3.6.3 4.2.29 1.14.2 @@ -374,6 +374,13 @@ shiro-web ${shiro.version} jakarta + + + + org.apache.shiro + * + + org.apache.shiro diff --git a/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample.java b/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample.java index 6e6e043c522..62937db0f61 100644 --- a/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample.java +++ b/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample.java @@ -30,49 +30,51 @@ public class ZeppelinClientExample { public static void main(String[] args) throws Exception { ClientConfig clientConfig = new ClientConfig("http://localhost:8080"); - ZeppelinClient zClient = new ZeppelinClient(clientConfig); + try (ZeppelinClient zClient = new ZeppelinClient(clientConfig)) { + String zeppelinVersion = zClient.getVersion(); + System.out.println("Zeppelin version: " + zeppelinVersion); - String zeppelinVersion = zClient.getVersion(); - System.out.println("Zeppelin version: " + zeppelinVersion); + String notePath = "/zeppelin_client_examples/note_1"; + String noteId = null; + try { + noteId = zClient.createNote(notePath); + System.out.println("Created note: " + noteId); - String notePath = "/zeppelin_client_examples/note_1"; - String noteId = null; - try { - noteId = zClient.createNote(notePath); - System.out.println("Created note: " + noteId); + String newNotePath = notePath + "_rename"; + zClient.renameNote(noteId, newNotePath); - String newNotePath = notePath + "_rename"; - zClient.renameNote(noteId, newNotePath); + NoteResult renamedNoteResult = zClient.queryNoteResult(noteId); + System.out.println( + "Rename note: " + noteId + " name to " + renamedNoteResult.getNotePath()); - NoteResult renamedNoteResult = zClient.queryNoteResult(noteId); - System.out.println("Rename note: " + noteId + " name to " + renamedNoteResult.getNotePath()); + String paragraphId = zClient.addParagraph( + noteId, "the first paragraph", "%python print('hello world')"); + ParagraphResult paragraphResult = zClient.executeParagraph(noteId, paragraphId); + System.out.println("Added new paragraph and execute it."); + System.out.println("Paragraph result: " + paragraphResult); - String paragraphId = zClient.addParagraph(noteId, "the first paragraph", "%python print('hello world')"); - ParagraphResult paragraphResult = zClient.executeParagraph(noteId, paragraphId); - System.out.println("Added new paragraph and execute it."); - System.out.println("Paragraph result: " + paragraphResult); + String paragraphId2 = zClient.addParagraph(noteId, "the second paragraph", + "%python\nimport time\ntime.sleep(5)\nprint('done')"); + zClient.submitParagraph(noteId, paragraphId2); + zClient.waitUtilParagraphRunning(noteId, paragraphId2); + // It's also ok here to call zClient.cancelNote(noteId); + // CancelNote() would cancel all paragraphs in the note. + zClient.cancelParagraph(noteId, paragraphId2); + paragraphResult = zClient.waitUtilParagraphFinish(noteId, paragraphId2); + System.out.println("Added new paragraph, submit it then cancel it"); + System.out.println("Paragraph result: " + paragraphResult); - String paragraphId2 = zClient.addParagraph(noteId, "the second paragraph", - "%python\nimport time\ntime.sleep(5)\nprint('done')"); - zClient.submitParagraph(noteId, paragraphId2); - zClient.waitUtilParagraphRunning(noteId, paragraphId2); - // It's also ok here to call zClient.cancelNote(noteId); - // CancelNote() would cancel all paragraphs in the note. - zClient.cancelParagraph(noteId, paragraphId2); - paragraphResult = zClient.waitUtilParagraphFinish(noteId, paragraphId2); - System.out.println("Added new paragraph, submit it then cancel it"); - System.out.println("Paragraph result: " + paragraphResult); + NoteResult noteResult = zClient.executeNote(noteId); + System.out.println("Execute note and the note result: " + noteResult); - NoteResult noteResult = zClient.executeNote(noteId); - System.out.println("Execute note and the note result: " + noteResult); - - zClient.submitNote(noteId); - noteResult = zClient.waitUntilNoteFinished(noteId); - System.out.println("Submit note and the note result: " + noteResult); - } finally { - if (noteId != null) { - zClient.deleteNote(noteId); - System.out.println("Note " + noteId + " is deleted"); + zClient.submitNote(noteId); + noteResult = zClient.waitUntilNoteFinished(noteId); + System.out.println("Submit note and the note result: " + noteResult); + } finally { + if (noteId != null) { + zClient.deleteNote(noteId); + System.out.println("Note " + noteId + " is deleted"); + } } } } diff --git a/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample2.java b/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample2.java index 05e183cc19d..ad05dcef863 100644 --- a/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample2.java +++ b/zeppelin-client-examples/src/main/java/org/apache/zeppelin/client/examples/ZeppelinClientExample2.java @@ -33,39 +33,47 @@ public class ZeppelinClientExample2 { public static void main(String[] args) throws Exception { ClientConfig clientConfig = new ClientConfig("http://localhost:8080"); - ZeppelinClient zClient = new ZeppelinClient(clientConfig); + try (ZeppelinClient zClient = new ZeppelinClient(clientConfig)) { + String zeppelinVersion = zClient.getVersion(); + System.out.println("Zeppelin version: " + zeppelinVersion); - String zeppelinVersion = zClient.getVersion(); - System.out.println("Zeppelin version: " + zeppelinVersion); + // execute note 2A94M5J1Z paragraph by paragraph + try { + ParagraphResult paragraphResult = + zClient.executeParagraph("2A94M5J1Z", "20150210-015259_1403135953"); + System.out.println( + "Execute the 1st spark tutorial paragraph, paragraph result: " + paragraphResult); - // execute note 2A94M5J1Z paragraph by paragraph - try { - ParagraphResult paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150210-015259_1403135953"); - System.out.println("Execute the 1st spark tutorial paragraph, paragraph result: " + paragraphResult); + paragraphResult = + zClient.executeParagraph("2A94M5J1Z", "20150210-015302_1492795503"); + System.out.println( + "Execute the 2nd spark tutorial paragraph, paragraph result: " + paragraphResult); - paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150210-015302_1492795503"); - System.out.println("Execute the 2nd spark tutorial paragraph, paragraph result: " + paragraphResult); + Map parameters = new HashMap<>(); + parameters.put("maxAge", "40"); + paragraphResult = zClient.executeParagraph( + "2A94M5J1Z", "20150212-145404_867439529", parameters); + System.out.println( + "Execute the 3rd spark tutorial paragraph, paragraph result: " + paragraphResult); + parameters = new HashMap<>(); + parameters.put("marital", "married"); + paragraphResult = zClient.executeParagraph( + "2A94M5J1Z", "20150213-230422_1600658137", parameters); + System.out.println( + "Execute the 4th spark tutorial paragraph, paragraph result: " + paragraphResult); + } finally { + // you need to stop interpreter explicitly if you are running paragraph separately. + zClient.stopInterpreter("2A94M5J1Z", "spark"); + } + + // execute this whole note, this note will run under a dedicated interpreter process which + // will be stopped after note execution. Map parameters = new HashMap<>(); parameters.put("maxAge", "40"); - paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150212-145404_867439529", parameters); - System.out.println("Execute the 3rd spark tutorial paragraph, paragraph result: " + paragraphResult); - - parameters = new HashMap<>(); parameters.put("marital", "married"); - paragraphResult = zClient.executeParagraph("2A94M5J1Z", "20150213-230422_1600658137", parameters); - System.out.println("Execute the 4th spark tutorial paragraph, paragraph result: " + paragraphResult); - } finally { - // you need to stop interpreter explicitly if you are running paragraph separately. - zClient.stopInterpreter("2A94M5J1Z", "spark"); + NoteResult noteResult = zClient.executeNote("2A94M5J1Z", parameters); + System.out.println("Execute the spark tutorial note, note result: " + noteResult); } - - // execute this whole note, this note will run under a didicated interpreter process which will be - // stopped after note execution. - Map parameters = new HashMap<>(); - parameters.put("maxAge", "40"); - parameters.put("marital", "married"); - NoteResult noteResult = zClient.executeNote("2A94M5J1Z", parameters); - System.out.println("Execute the spark tutorial note, note result: " + noteResult); } } diff --git a/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZSession.java b/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZSession.java index 8d50debf347..4319dad3524 100644 --- a/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZSession.java +++ b/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZSession.java @@ -26,6 +26,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URI; +import java.net.URISyntaxException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -36,9 +38,15 @@ * There's no Zeppelin concept(like note/paragraph) in ZSession. * */ -public class ZSession { +public class ZSession implements AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(ZSession.class); + /** Authenticates an isolated client before reconnecting an existing Zeppelin session. */ + @FunctionalInterface + public interface ClientAuthenticator { + void authenticate(ZeppelinClient client) throws Exception; + } + private ZeppelinClient zeppelinClient; private String interpreter; private Map intpProperties; @@ -48,6 +56,9 @@ public class ZSession { private SessionInfo sessionInfo; private ZeppelinWebSocketClient webSocketClient; + private boolean closed; + private boolean remoteSessionStopped; + private boolean webSocketStopped; public ZSession(ClientConfig clientConfig, String interpreter) throws Exception { @@ -120,14 +131,7 @@ public void start(MessageHandler messageHandler) throws Exception { this.sessionInfo = zeppelinClient.getSession(getSessionId()); if (messageHandler != null) { - this.webSocketClient = new ZeppelinWebSocketClient(messageHandler); - this.webSocketClient.connect(zeppelinClient.getClientConfig().getZeppelinRestUrl() - .replace("https", "ws").replace("http", "ws") + "/ws"); - - // call GET_NOTE to establish websocket connection between this session and zeppelin-server - Message msg = new Message(Message.OP.GET_NOTE); - msg.put("id", getNoteId()); - this.webSocketClient.send(msg); + connectWebSocket(messageHandler); } } @@ -136,13 +140,49 @@ public void start(MessageHandler messageHandler) throws Exception { * * @throws Exception */ - public void stop() throws Exception { - if (getSessionId() != null) { - zeppelinClient.stopSession(getSessionId()); + public synchronized void stop() throws Exception { + if (closed) { + return; } - if (webSocketClient != null) { - webSocketClient.stop(); + Exception failure = null; + if (!remoteSessionStopped) { + if (getSessionId() == null) { + remoteSessionStopped = true; + } else { + try { + zeppelinClient.stopSession(getSessionId()); + remoteSessionStopped = true; + } catch (Exception e) { + failure = e; + } + } } + if (!webSocketStopped) { + if (webSocketClient == null) { + webSocketStopped = true; + } else { + try { + webSocketClient.stop(); + webSocketStopped = true; + } catch (Exception e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + } + if (failure != null) { + throw failure; + } + zeppelinClient.close(); + closed = true; + } + + @Override + public void close() throws Exception { + stop(); } /** @@ -167,9 +207,46 @@ public static ZSession createFromExistingSession(ClientConfig clientConfig, String interpreter, String sessionId, MessageHandler messageHandler) throws Exception { + return createFromExistingSession( + clientConfig, interpreter, sessionId, client -> { }, messageHandler); + } + + /** + * Reconnect an existing session after authenticating its isolated REST/WebSocket client. + * + *

For Shiro, the authenticator can call {@code client.login(user, password)}. It is invoked + * before both the protected session lookup and WebSocket upgrade. + */ + public static ZSession createFromExistingSession( + ClientConfig clientConfig, + String interpreter, + String sessionId, + ClientAuthenticator authenticator, + MessageHandler messageHandler) throws Exception { ZSession session = new ZSession(clientConfig, interpreter, sessionId); - session.reconnect(messageHandler); - return session; + try { + authenticator.authenticate(session.zeppelinClient); + session.reconnect(messageHandler); + return session; + } catch (Exception e) { + try { + session.closeLocalResources(); + } catch (Exception cleanupFailure) { + e.addSuppressed(cleanupFailure); + } + throw e; + } + } + + private void closeLocalResources() throws Exception { + try { + if (webSocketClient != null) { + webSocketClient.stop(); + } + } finally { + zeppelinClient.close(); + closed = true; + } } private void reconnect(MessageHandler messageHandler) throws Exception { @@ -179,15 +256,43 @@ private void reconnect(MessageHandler messageHandler) throws Exception { } if (messageHandler != null) { - this.webSocketClient = new ZeppelinWebSocketClient(messageHandler); - this.webSocketClient.connect(zeppelinClient.getClientConfig().getZeppelinRestUrl() - .replace("https", "ws").replace("http", "ws") + "/ws"); - - // call GET_NOTE to establish websocket connection between this session and zeppelin-server - Message msg = new Message(Message.OP.GET_NOTE); - msg.put("id", getNoteId()); - this.webSocketClient.send(msg); + connectWebSocket(messageHandler); + } + } + + private void connectWebSocket(MessageHandler messageHandler) throws Exception { + this.webSocketClient = new ZeppelinWebSocketClient(messageHandler); + URI webSocketUri = toWebSocketUri(zeppelinClient.getClientConfig().getZeppelinRestUrl()); + this.webSocketClient.connect(webSocketUri.toString(), + zeppelinClient.getSessionCookieHeader(webSocketUri)); + + // call GET_NOTE to establish websocket connection between this session and zeppelin-server + Message msg = new Message(Message.OP.GET_NOTE); + msg.put("id", getNoteId()); + this.webSocketClient.send(msg); + } + + static URI toWebSocketUri(String zeppelinRestUrl) throws URISyntaxException { + URI restUri = new URI(zeppelinRestUrl); + String webSocketScheme; + if ("https".equalsIgnoreCase(restUri.getScheme())) { + webSocketScheme = "wss"; + } else if ("http".equalsIgnoreCase(restUri.getScheme())) { + webSocketScheme = "ws"; + } else { + throw new IllegalArgumentException("Unsupported Zeppelin REST URL scheme: " + + restUri.getScheme()); + } + if (restUri.getRawAuthority() == null) { + throw new IllegalArgumentException("Zeppelin REST URL must contain an authority"); + } + String rawBasePath = restUri.getRawPath(); + if (rawBasePath != null && rawBasePath.length() > 1 && rawBasePath.endsWith("/")) { + rawBasePath = rawBasePath.substring(0, rawBasePath.length() - 1); } + String webSocketPath = rawBasePath == null || rawBasePath.isEmpty() || "/".equals(rawBasePath) + ? "/ws" : rawBasePath + "/ws"; + return new URI(webSocketScheme + "://" + restUri.getRawAuthority() + webSocketPath); } /** diff --git a/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZeppelinClient.java b/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZeppelinClient.java index f5db6f15ecb..83388a6dd1f 100644 --- a/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZeppelinClient.java +++ b/zeppelin-client/src/main/java/org/apache/zeppelin/client/ZeppelinClient.java @@ -22,53 +22,57 @@ import kong.unirest.HttpResponse; import kong.unirest.JsonNode; import kong.unirest.Unirest; +import kong.unirest.UnirestInstance; import kong.unirest.apache.ApacheClient; import kong.unirest.json.JSONArray; import kong.unirest.json.JSONObject; import org.apache.commons.text.StringEscapeUtils; -import org.apache.http.conn.ssl.NoopHostnameVerifier; -import org.apache.http.conn.ssl.TrustSelfSignedStrategy; -import org.apache.http.ssl.SSLContextBuilder; import org.apache.zeppelin.common.SessionInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import unirest.shaded.org.apache.http.client.CookieStore; import unirest.shaded.org.apache.http.client.HttpClient; +import unirest.shaded.org.apache.http.cookie.ClientCookie; +import unirest.shaded.org.apache.http.cookie.Cookie; +import unirest.shaded.org.apache.http.impl.client.BasicCookieStore; +import unirest.shaded.org.apache.http.impl.client.HttpClientBuilder; import unirest.shaded.org.apache.http.impl.client.HttpClients; -import javax.net.ssl.SSLContext; -import java.security.cert.X509Certificate; +import java.net.URI; import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; /** * Low level api for interacting with Zeppelin. Underneath, it use the zeppelin rest api. * You can use this class to operate Zeppelin note/paragraph, * e.g. get/add/delete/update/execute/cancel */ -public class ZeppelinClient { +public class ZeppelinClient implements AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(ZeppelinClient.class); - private ClientConfig clientConfig; + private final ClientConfig clientConfig; + private final UnirestInstance unirest; + private final CookieStore cookieStore; public ZeppelinClient(ClientConfig clientConfig) throws Exception { this.clientConfig = clientConfig; - Unirest.config().defaultBaseUrl(clientConfig.getZeppelinRestUrl() + "/api"); - - if (clientConfig.isUseKnox()) { - try { - SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy() { - public boolean isTrusted(X509Certificate[] chain, String authType) { - return true; - } - }).build(); - HttpClient customHttpClient = HttpClients.custom().setSSLContext(sslContext) - .setSSLHostnameVerifier(new NoopHostnameVerifier()).build(); - Unirest.config().httpClient(ApacheClient.builder(customHttpClient)); - } catch (Exception e) { - throw new Exception("Fail to setup httpclient of Unirest", e); - } + this.unirest = Unirest.spawnInstance(); + this.cookieStore = new BasicCookieStore(); + this.unirest.config().defaultBaseUrl(clientConfig.getZeppelinRestUrl() + "/api"); + + try { + HttpClientBuilder httpClientBuilder = + HttpClients.custom().useSystemProperties().setDefaultCookieStore(cookieStore); + HttpClient customHttpClient = httpClientBuilder.build(); + this.unirest.config().httpClient(ApacheClient.builder(customHttpClient)); + } catch (Exception e) { + throw new Exception("Fail to setup httpclient of Unirest", e); } } @@ -76,6 +80,75 @@ public ClientConfig getClientConfig() { return clientConfig; } + @Override + public void close() { + unirest.close(); + } + + /** + * Build the Cookie request header for the supplied WebSocket URI from this client's REST + * cookie store. Cookies are scoped to the target scheme, host and path before being exposed. + */ + String getSessionCookieHeader(URI requestUri) { + Date now = new Date(); + String requestHost = requestUri.getHost(); + String requestPath = requestUri.getRawPath(); + boolean secureRequest = "wss".equalsIgnoreCase(requestUri.getScheme()); + if (requestHost == null) { + return ""; + } + if (requestPath == null || requestPath.isEmpty()) { + requestPath = "/"; + } + final String normalizedRequestPath = requestPath; + return cookieStore.getCookies().stream() + .filter(cookie -> !cookie.isExpired(now)) + .filter(cookie -> !cookie.isSecure() || secureRequest) + .filter(cookie -> domainMatches(cookie, requestHost)) + .filter(cookie -> pathMatches(cookie.getPath(), normalizedRequestPath)) + .sorted(Comparator.comparingInt( + (Cookie cookie) -> normalizedCookiePath(cookie.getPath()).length()).reversed()) + .map(cookie -> cookie.getName() + "=" + cookie.getValue()) + .collect(Collectors.joining("; ")); + } + + private boolean domainMatches(Cookie cookie, String requestHost) { + String cookieDomain = cookie.getDomain(); + if (cookieDomain == null || cookieDomain.isEmpty()) { + return false; + } + + String normalizedHost = requestHost.toLowerCase(Locale.ROOT); + String normalizedDomain = cookieDomain.toLowerCase(Locale.ROOT); + if (normalizedDomain.startsWith(".")) { + normalizedDomain = normalizedDomain.substring(1); + } + + boolean domainAttributePresent = cookie instanceof ClientCookie + && ((ClientCookie) cookie).containsAttribute(ClientCookie.DOMAIN_ATTR); + if (!domainAttributePresent) { + return normalizedHost.equals(normalizedDomain); + } + return normalizedHost.equals(normalizedDomain) + || normalizedHost.endsWith("." + normalizedDomain); + } + + private boolean pathMatches(String cookiePath, String requestPath) { + String normalizedCookiePath = normalizedCookiePath(cookiePath); + if (requestPath.equals(normalizedCookiePath)) { + return true; + } + if (!requestPath.startsWith(normalizedCookiePath)) { + return false; + } + return normalizedCookiePath.endsWith("/") + || requestPath.charAt(normalizedCookiePath.length()) == '/'; + } + + private String normalizedCookiePath(String cookiePath) { + return cookiePath == null || cookiePath.isEmpty() ? "/" : cookiePath; + } + /** * Throw exception if the status code is not 200. * @@ -119,7 +192,7 @@ private void checkJsonNodeStatus(JsonNode jsonNode) throws Exception { * @throws Exception */ public String getVersion() throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .get("/version") .asJson(); checkResponse(response); @@ -137,7 +210,7 @@ public String getVersion() throws Exception { * @throws Exception */ public SessionInfo newSession(String interpreter) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .post("/session") .header("Content-Type", "application/json") .queryString("interpreter", interpreter) @@ -155,7 +228,7 @@ public SessionInfo newSession(String interpreter) throws Exception { * @throws Exception */ public void stopSession(String sessionId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .delete("/session/{sessionId}") .routeParam("sessionId", sessionId) .asJson(); @@ -172,7 +245,7 @@ public void stopSession(String sessionId) throws Exception { * @throws Exception */ public SessionInfo getSession(String sessionId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .get("/session/{sessionId}") .routeParam("sessionId", sessionId) .asJson(); @@ -211,7 +284,7 @@ public List listSessions() throws Exception { * @throws Exception */ public List listSessions(String interpreter) throws Exception { - GetRequest getRequest = Unirest.get("/session"); + GetRequest getRequest = unirest.get("/session"); if (interpreter != null) { getRequest.queryString("interpreter", interpreter); } @@ -260,7 +333,7 @@ private SessionInfo createSessionInfoFromJson(JSONObject sessionJson) { */ public void login(String userName, String password) throws Exception { if (clientConfig.isUseKnox()) { - HttpResponse response = Unirest.get(clientConfig.getKnoxSSOUrl() + + HttpResponse response = unirest.get(clientConfig.getKnoxSSOUrl() + "?originalUrl=" + clientConfig.getZeppelinRestUrl()) .basicAuth(userName, password) .asString(); @@ -269,7 +342,7 @@ public void login(String userName, String password) throws Exception { response.getStatus(), response.getStatusText())); } - response = Unirest.get("/security/ticket") + response = unirest.get("/security/ticket") .asString(); if (response.getStatus() != 200) { throw new Exception(String.format("Fail to get ticket after Knox SSO, status: %s, statusText: %s", @@ -277,7 +350,7 @@ public void login(String userName, String password) throws Exception { response.getStatusText())); } } else { - HttpResponse response = Unirest + HttpResponse response = unirest .post("/login") .field("userName", userName) .field("password", password) @@ -306,7 +379,7 @@ public String createNote(String notePath, String defaultInterpreterGroup) throws JSONObject bodyObject = new JSONObject(); bodyObject.put("notePath", notePath); bodyObject.put("defaultInterpreterGroup", defaultInterpreterGroup); - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook") .header("Content-Type", "application/json") .body(bodyObject.toString()) @@ -325,7 +398,7 @@ public String createNote(String notePath, String defaultInterpreterGroup) throws * @throws Exception */ public void deleteNote(String noteId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .delete("/notebook/{noteId}") .routeParam("noteId", noteId) .asJson(); @@ -345,7 +418,7 @@ public void deleteNote(String noteId) throws Exception { public String cloneNote(String noteId, String destPath) throws Exception { JSONObject bodyObject = new JSONObject(); bodyObject.put("name", destPath); - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook/{noteId}") .routeParam("noteId", noteId) .header("Content-Type", "application/json") @@ -362,7 +435,7 @@ public void renameNote(String noteId, String newNotePath) throws Exception { JSONObject bodyObject = new JSONObject(); bodyObject.put("name", newNotePath); - HttpResponse response = Unirest + HttpResponse response = unirest .put("/notebook/{noteId}/rename") .routeParam("noteId", noteId) .header("Content-Type", "application/json") @@ -382,7 +455,7 @@ public void renameNote(String noteId, String newNotePath) throws Exception { * @throws Exception */ public NoteResult queryNoteResult(String noteId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .get("/notebook/{noteId}") .routeParam("noteId", noteId) .asJson(); @@ -399,7 +472,7 @@ public NoteResult queryNoteResult(String noteId) throws Exception { public NoteResult queryNoteResultByPath(String notePath) throws Exception { JSONObject bodyObject = new JSONObject(); bodyObject.put("notePath", notePath); - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook/getByPath") .header("Content-Type", "application/json") .body(bodyObject) @@ -494,7 +567,7 @@ public NoteResult submitNote(String noteId, Map parameters) thro JSONObject bodyObject = new JSONObject(); bodyObject.put("params", parameters); // run note in non-blocking and isolated way. - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook/job/{noteId}") .routeParam("noteId", noteId) .queryString("blocking", "false") @@ -515,7 +588,7 @@ public NoteResult submitNote(String noteId, Map parameters) thro * @throws Exception */ public void cancelNote(String noteId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .delete("/notebook/job/{noteId}") .routeParam("noteId", noteId) .asJson(); @@ -534,7 +607,7 @@ public void cancelNote(String noteId) throws Exception { */ public String importNote(String notePath, String noteContent) throws Exception { JSONObject bodyObject = new JSONObject(noteContent); - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook/import") .queryString("notePath", notePath) .header("Content-Type", "application/json") @@ -597,7 +670,7 @@ public String addParagraph(String noteId, String title, String text) throws Exce JSONObject bodyObject = new JSONObject(); bodyObject.put("title", title); bodyObject.put("text", text); - HttpResponse response = Unirest.post("/notebook/{noteId}/paragraph") + HttpResponse response = unirest.post("/notebook/{noteId}/paragraph") .routeParam("noteId", noteId) .header("Content-Type", "application/json") .body(bodyObject.toString()) @@ -622,7 +695,7 @@ public void updateParagraph(String noteId, String paragraphId, String title, Str JSONObject bodyObject = new JSONObject(); bodyObject.put("title", title); bodyObject.put("text", text); - HttpResponse response = Unirest.put("/notebook/{noteId}/paragraph/{paragraphId}") + HttpResponse response = unirest.put("/notebook/{noteId}/paragraph/{paragraphId}") .routeParam("noteId", noteId) .routeParam("paragraphId", paragraphId) .header("Content-Type", "application/json") @@ -712,7 +785,7 @@ public ParagraphResult submitParagraph(String noteId, Map parameters) throws Exception { JSONObject bodyObject = new JSONObject(); bodyObject.put("params", parameters); - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook/job/{noteId}/{paragraphId}") .routeParam("noteId", noteId) .routeParam("paragraphId", paragraphId) @@ -780,7 +853,7 @@ public ParagraphResult submitParagraph(String noteId, String paragraphId) throws * @throws Exception */ public String nextSessionParagraph(String noteId, int maxParagraph) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .post("/notebook/{noteId}/paragraph/next") .routeParam("noteId", noteId) .header("Content-Type", "application/json") @@ -801,7 +874,7 @@ public String nextSessionParagraph(String noteId, int maxParagraph) throws Excep * @throws Exception */ public void cancelParagraph(String noteId, String paragraphId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .delete("/notebook/job/{noteId}/{paragraphId}") .routeParam("noteId", noteId) .routeParam("paragraphId", paragraphId) @@ -820,7 +893,7 @@ public void cancelParagraph(String noteId, String paragraphId) throws Exception * @throws Exception */ public ParagraphResult queryParagraphResult(String noteId, String paragraphId) throws Exception { - HttpResponse response = Unirest + HttpResponse response = unirest .get("/notebook/{noteId}/paragraph/{paragraphId}") .routeParam("noteId", noteId) .routeParam("paragraphId", paragraphId) @@ -900,7 +973,7 @@ public ParagraphResult waitUtilParagraphRunning(String noteId, String paragraphI public void stopInterpreter(String noteId, String interpreter) throws Exception { JSONObject bodyObject = new JSONObject(); bodyObject.put("noteId", noteId); - HttpResponse response = Unirest + HttpResponse response = unirest .put("/interpreter/setting/restart/{interpreter}") .routeParam("interpreter", interpreter) .header("Content-Type", "application/json") diff --git a/zeppelin-client/src/main/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClient.java b/zeppelin-client/src/main/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClient.java index c09fb845ad8..19752eb1106 100644 --- a/zeppelin-client/src/main/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClient.java +++ b/zeppelin-client/src/main/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClient.java @@ -33,6 +33,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -61,11 +62,14 @@ public ZeppelinWebSocketClient(MessageHandler messageHandler) { } public void connect(String url) throws Exception { + connect(url, null); + } + + public void connect(String url, String cookieHeader) throws Exception { + URI echoUri = new URI(url); + ClientUpgradeRequest request = createUpgradeRequest(echoUri, cookieHeader); this.wsClient = new WebSocketClient(); wsClient.start(); - URI echoUri = new URI(url); - ClientUpgradeRequest request = new ClientUpgradeRequest(); - request.setHeader("Origin", "*"); CompletableFuture future = wsClient.connect(this, echoUri, request); try { future.get(DEFAULT_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS); @@ -81,6 +85,37 @@ public void connect(String url) throws Exception { LOGGER.info("WebSocket connect established"); } + ClientUpgradeRequest createUpgradeRequest(URI webSocketUri, String cookieHeader) + throws URISyntaxException { + ClientUpgradeRequest request = new ClientUpgradeRequest(); + request.setHeader("Origin", toHttpOrigin(webSocketUri)); + if (cookieHeader != null && !cookieHeader.trim().isEmpty()) { + request.setHeader("Cookie", cookieHeader); + } + return request; + } + + private static String toHttpOrigin(URI webSocketUri) throws URISyntaxException { + String scheme; + if ("wss".equalsIgnoreCase(webSocketUri.getScheme())) { + scheme = "https"; + } else if ("ws".equalsIgnoreCase(webSocketUri.getScheme())) { + scheme = "http"; + } else { + throw new IllegalArgumentException( + "Unsupported WebSocket URL scheme: " + webSocketUri.getScheme()); + } + if (webSocketUri.getHost() == null) { + throw new IllegalArgumentException("WebSocket URL must contain a host"); + } + int port = webSocketUri.getPort(); + if (("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443)) { + port = -1; + } + return new URI(scheme, null, webSocketUri.getHost(), port, + null, null, null).toString(); + } + public void addStatementMessageHandler(String statementId, StatementMessageHandler statementMessageHandler) throws Exception { if (messageHandler instanceof CompositeMessageHandler) { diff --git a/zeppelin-client/src/test/java/org/apache/zeppelin/client/ZSessionTest.java b/zeppelin-client/src/test/java/org/apache/zeppelin/client/ZSessionTest.java new file mode 100644 index 00000000000..d803a03d61c --- /dev/null +++ b/zeppelin-client/src/test/java/org/apache/zeppelin/client/ZSessionTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class ZSessionTest { + + @Test + void websocketUriPreservesGatewayPathAndSecureTransport() throws Exception { + assertEquals("wss://knox.example/gateway/default/zeppelin/ws", + ZSession.toWebSocketUri( + "https://knox.example/gateway/default/zeppelin").toString()); + } + + @Test + void websocketUriNormalizesATrailingGatewaySlash() throws Exception { + assertEquals("wss://knox.example/gateway/default/zeppelin/ws", + ZSession.toWebSocketUri( + "https://knox.example/gateway/default/zeppelin/").toString()); + } + + @Test + void websocketUriPreservesPercentEncodedGatewaySegments() throws Exception { + assertEquals("wss://knox.example/gateway/a%2Fb/ws", + ZSession.toWebSocketUri("https://knox.example/gateway/a%2Fb").toString()); + } + + @Test + void websocketUriUsesPlainWebsocketForHttp() throws Exception { + assertEquals("ws://localhost:8080/ws", + ZSession.toWebSocketUri("http://localhost:8080").toString()); + } + + @Test + void websocketUriRejectsUnsupportedSchemes() { + assertThrows(IllegalArgumentException.class, + () -> ZSession.toWebSocketUri("ftp://localhost:8080")); + } + + @Test + void existingSessionCanAuthenticateBeforeProtectedReconnect() throws Exception { + AtomicBoolean authenticatedLookup = new AtomicBoolean(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/login", exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("Set-Cookie", "JSESSIONID=session-cookie; Path=/"); + writeResponse(exchange, 200, "{}"); + } finally { + exchange.close(); + } + }); + server.createContext("/api/session/session-id", exchange -> { + try { + if ("GET".equals(exchange.getRequestMethod())) { + authenticatedLookup.set( + "JSESSIONID=session-cookie".equals( + exchange.getRequestHeaders().getFirst("Cookie"))); + writeResponse(exchange, 200, + "{\"status\":\"OK\",\"body\":{\"sessionId\":\"session-id\"," + + "\"state\":\"Running\"}}"); + } else { + writeResponse(exchange, 200, "{\"status\":\"OK\",\"body\":{}}"); + } + } finally { + exchange.close(); + } + }); + server.start(); + + try { + ClientConfig config = new ClientConfig( + "http://127.0.0.1:" + server.getAddress().getPort()); + try (ZSession session = ZSession.createFromExistingSession( + config, + "spark", + "session-id", + client -> client.login("user", "password"), + null)) { + assertEquals("session-id", session.getSessionId()); + assertTrue(authenticatedLookup.get()); + } + } finally { + server.stop(0); + } + } + + private static void writeResponse( + com.sun.net.httpserver.HttpExchange exchange, int status, String json) throws IOException { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + } +} diff --git a/zeppelin-client/src/test/java/org/apache/zeppelin/client/ZeppelinClientTest.java b/zeppelin-client/src/test/java/org/apache/zeppelin/client/ZeppelinClientTest.java new file mode 100644 index 00000000000..c241360e691 --- /dev/null +++ b/zeppelin-client/src/test/java/org/apache/zeppelin/client/ZeppelinClientTest.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +class ZeppelinClientTest { + + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void restSessionCookiesAreIsolatedAndAvailableForWebSocketHandshake() throws Exception { + AtomicInteger loginCount = new AtomicInteger(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/login", exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + String sessionId = "session-" + loginCount.incrementAndGet(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("Set-Cookie", + "JSESSIONID=" + sessionId + "; Path=/; HttpOnly"); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + }); + server.start(); + + String serverUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + try (ZeppelinClient firstClient = new ZeppelinClient(new ClientConfig(serverUrl)); + ZeppelinClient secondClient = new ZeppelinClient(new ClientConfig(serverUrl))) { + firstClient.login("first", "password"); + secondClient.login("second", "password"); + + URI webSocketUri = URI.create(serverUrl.replace("http", "ws") + "/ws"); + assertEquals("JSESSIONID=session-1", firstClient.getSessionCookieHeader(webSocketUri)); + assertEquals("JSESSIONID=session-2", secondClient.getSessionCookieHeader(webSocketUri)); + } + } + + @Test + void cookieHeaderIsEmptyForAnotherHost() throws Exception { + server = startServerWithSessionCookie("session-id"); + String serverUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + try (ZeppelinClient client = new ZeppelinClient(new ClientConfig(serverUrl))) { + client.login("user", "password"); + + assertEquals("", client.getSessionCookieHeader(URI.create("ws://localhost/ws"))); + } + } + + @Test + void cookieHeaderHonorsCookiePathAndSecureAttributes() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/login", exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("Set-Cookie", "root-cookie=root; Path=/"); + exchange.getResponseHeaders().add("Set-Cookie", "ws-cookie=websocket; Path=/ws"); + exchange.getResponseHeaders().add("Set-Cookie", "api-cookie=rest; Path=/api"); + exchange.getResponseHeaders().add("Set-Cookie", "secure-cookie=secure; Path=/; Secure"); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + }); + server.start(); + + String authority = "127.0.0.1:" + server.getAddress().getPort(); + try (ZeppelinClient client = new ZeppelinClient( + new ClientConfig("http://" + authority))) { + client.login("user", "password"); + + assertEquals("ws-cookie=websocket; root-cookie=root", + client.getSessionCookieHeader(URI.create("ws://" + authority + "/ws"))); + assertEquals("ws-cookie=websocket; root-cookie=root; secure-cookie=secure", + client.getSessionCookieHeader(URI.create("wss://" + authority + "/ws"))); + } + } + + @Test + void cookieHeaderPreservesPercentEncodedGatewayPath() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/login", exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add( + "Set-Cookie", "JSESSIONID=session-id; Path=/gateway/a%2Fb"); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + }); + server.start(); + + String authority = "127.0.0.1:" + server.getAddress().getPort(); + try (ZeppelinClient client = new ZeppelinClient( + new ClientConfig("http://" + authority))) { + client.login("user", "password"); + + assertEquals("JSESSIONID=session-id", client.getSessionCookieHeader( + URI.create("ws://" + authority + "/gateway/a%2Fb/ws"))); + } + } + + private HttpServer startServerWithSessionCookie(String sessionId) throws IOException { + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/api/login", exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("Set-Cookie", + "JSESSIONID=" + sessionId + "; Path=/; HttpOnly"); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + }); + httpServer.start(); + return httpServer; + } +} diff --git a/zeppelin-client/src/test/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClientTest.java b/zeppelin-client/src/test/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClientTest.java index 7f6cabd1e2d..28ec9445a26 100644 --- a/zeppelin-client/src/test/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClientTest.java +++ b/zeppelin-client/src/test/java/org/apache/zeppelin/client/websocket/ZeppelinWebSocketClientTest.java @@ -17,15 +17,56 @@ package org.apache.zeppelin.client.websocket; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; import org.junit.jupiter.api.Test; import java.time.Duration; +import java.net.URI; class ZeppelinWebSocketClientTest { + @Test + void upgradeRequestIncludesRestSessionCookies() throws Exception { + ZeppelinWebSocketClient client = new ZeppelinWebSocketClient(msg -> { }); + + ClientUpgradeRequest request = client.createUpgradeRequest( + URI.create("wss://knox.example/gateway/default/zeppelin/ws"), + "JSESSIONID=session-id; hadoop-jwt=knox-token"); + + assertEquals("JSESSIONID=session-id; hadoop-jwt=knox-token", + request.getHeader("Cookie")); + assertEquals("https://knox.example", request.getHeader("Origin")); + } + + @Test + void upgradeRequestOmitsCookieHeaderForAnonymousSession() throws Exception { + ZeppelinWebSocketClient client = new ZeppelinWebSocketClient(msg -> { }); + + ClientUpgradeRequest request = client.createUpgradeRequest( + URI.create("ws://localhost:8080/ws"), " "); + + assertNull(request.getHeader("Cookie")); + assertEquals("http://localhost:8080", request.getHeader("Origin")); + } + + @Test + void upgradeRequestCanonicalizesDefaultOriginPorts() throws Exception { + ZeppelinWebSocketClient client = new ZeppelinWebSocketClient(msg -> { }); + + ClientUpgradeRequest secure = client.createUpgradeRequest( + URI.create("wss://zeppelin.example:443/ws"), null); + ClientUpgradeRequest plain = client.createUpgradeRequest( + URI.create("ws://zeppelin.example:80/ws"), null); + + assertEquals("https://zeppelin.example", secure.getHeader("Origin")); + assertEquals("http://zeppelin.example", plain.getHeader("Origin")); + } + @Test void connectFailsFastWhenPortClosed() { ZeppelinWebSocketClient client = new ZeppelinWebSocketClient(msg -> { }); diff --git a/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientIntegrationTest.java b/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientIntegrationTest.java index e1e3bbf3319..e375b426051 100644 --- a/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientIntegrationTest.java +++ b/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientIntegrationTest.java @@ -75,7 +75,13 @@ static void init() throws Exception { @AfterAll static void destroy() throws Exception { - zepServer.destroy(); + try { + if (zeppelinClient != null) { + zeppelinClient.close(); + } + } finally { + zepServer.destroy(); + } } @BeforeEach diff --git a/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientWithAuthIntegrationTest.java b/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientWithAuthIntegrationTest.java index bfe170916fe..320da8a303d 100644 --- a/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientWithAuthIntegrationTest.java +++ b/zeppelin-interpreter-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinClientWithAuthIntegrationTest.java @@ -58,7 +58,13 @@ static void init() throws Exception { @AfterAll static void destroy() throws Exception { - zepServer.destroy(); + try { + if (zeppelinClient != null) { + zeppelinClient.close(); + } + } finally { + zepServer.destroy(); + } } @BeforeEach @@ -109,4 +115,3 @@ void testLoginFailed() throws Exception { } } } - diff --git a/zeppelin-plugins/notebookrepo/github/src/main/java/org/apache/zeppelin/notebook/repo/GitHubNotebookRepo.java b/zeppelin-plugins/notebookrepo/github/src/main/java/org/apache/zeppelin/notebook/repo/GitHubNotebookRepo.java index 395c79ee775..4e9e5a639e8 100644 --- a/zeppelin-plugins/notebookrepo/github/src/main/java/org/apache/zeppelin/notebook/repo/GitHubNotebookRepo.java +++ b/zeppelin-plugins/notebookrepo/github/src/main/java/org/apache/zeppelin/notebook/repo/GitHubNotebookRepo.java @@ -66,10 +66,10 @@ public void init(ZeppelinConfiguration zConf, NoteParser noteParser) throws IOEx } @Override - public Revision checkpoint(String noteId, - String notePath, - String commitMessage, - AuthenticationInfo subject) throws IOException { + public synchronized Revision checkpoint(String noteId, + String notePath, + String commitMessage, + AuthenticationInfo subject) throws IOException { Revision revision = super.checkpoint(noteId, notePath, commitMessage, subject); updateRemoteStream(); diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java b/zeppelin-server/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java index 179dcce6e85..deb7c854612 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java @@ -67,7 +67,8 @@ public class ZeppelinConfiguration { private static final String ZEPPELIN_SITE_XML = "zeppelin-site.xml"; private static final Logger LOGGER = LoggerFactory.getLogger(ZeppelinConfiguration.class); - private Boolean anonymousAllowed; + private volatile Boolean anonymousAllowed; + private volatile String shiroPath; private static final EnvironmentConfiguration envConfig = new EnvironmentConfiguration(); private static final SystemConfiguration sysConfig = new SystemConfiguration(); @@ -618,10 +619,32 @@ public String getCredentialsPath(boolean absolute) { } public String getShiroPath() { + String initializedShiroPath = shiroPath; + if (initializedShiroPath != null) { + return initializedShiroPath; + } + return resolveShiroPath(); + } + + private String resolveShiroPath() { String shiroPath = getAbsoluteDir(String.format("%s/shiro.ini", getConfDir())); return new File(shiroPath).exists() ? shiroPath : StringUtils.EMPTY; } + /** + * Capture the authentication mode once during server construction. + * + *

Jetty's Shiro filter is also configured only at startup. Keeping this decision immutable + * prevents a later filesystem change from making notebook authorization believe the running, + * Shiro-protected server has switched to anonymous mode. + */ + public synchronized void initializeAuthenticationMode() { + if (shiroPath == null) { + shiroPath = resolveShiroPath(); + anonymousAllowed = StringUtils.isBlank(shiroPath); + } + } + public boolean isAuthenticationEnabled() { return !StringUtils.isBlank(getShiroPath()); } @@ -680,7 +703,7 @@ public boolean isPathWithScheme(String path){ public boolean isAnonymousAllowed() { if (anonymousAllowed == null) { - anonymousAllowed = this.getShiroPath().equals(StringUtils.EMPTY); + initializeAuthenticationMode(); } return anonymousAllowed; } @@ -735,6 +758,10 @@ public String getWebsocketMaxTextMessageSize() { return getString(ConfVars.ZEPPELIN_WEBSOCKET_MAX_TEXT_MESSAGE_SIZE); } + public long getWebsocketAuthorizationRolesRefreshIntervalMs() { + return getLong(ConfVars.ZEPPELIN_WEBSOCKET_AUTHORIZATION_ROLES_REFRESH_INTERVAL_MS); + } + public String getJettyName() { return getString(ConfVars.ZEPPELIN_SERVER_JETTY_NAME); } @@ -1058,6 +1085,8 @@ public enum ConfVars { ZEPPELIN_CREDENTIALS_PERSIST("zeppelin.credentials.persist", true), ZEPPELIN_CREDENTIALS_ENCRYPT_KEY("zeppelin.credentials.encryptKey", null), ZEPPELIN_WEBSOCKET_MAX_TEXT_MESSAGE_SIZE("zeppelin.websocket.max.text.message.size", "10240000"), + ZEPPELIN_WEBSOCKET_AUTHORIZATION_ROLES_REFRESH_INTERVAL_MS( + "zeppelin.websocket.authorization.roles.refresh.interval.ms", 1000L), ZEPPELIN_WEBSOCKET_PARAGRAPH_STATUS_PROGRESS("zeppelin.websocket.paragraph_status_progress.enable", true), ZEPPELIN_SERVER_DEFAULT_DIR_ALLOWED("zeppelin.server.default.dir.allowed", false), ZEPPELIN_SERVER_XFRAME_OPTIONS("zeppelin.server.xframe.options", "SAMEORIGIN"), diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/AuthorizationService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/AuthorizationService.java index 70e7aac1592..1ded5ab0b20 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/AuthorizationService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/AuthorizationService.java @@ -26,6 +26,7 @@ import jakarta.inject.Inject; import java.io.IOException; +import java.util.Collections; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -38,7 +39,7 @@ public class AuthorizationService { private static final Logger LOGGER = LoggerFactory.getLogger(AuthorizationService.class); - private static final Set EMPTY_SET = new HashSet<>(); + private static final Set EMPTY_SET = Collections.emptySet(); private final ZeppelinConfiguration zConf; private final ConfigStorage configStorage; @@ -49,6 +50,16 @@ public class AuthorizationService { // cached note permission info. (noteId --> NoteAuth) private Map notesAuth = new ConcurrentHashMap<>(); + /** + * Monotonic generation for every effective ACL or cached-role change. + * + *

Folder operations authorize a metadata snapshot outside this monitor, then reacquire the + * monitor through {@link #runWithAuthorizationVersion(long, AuthorizationOperation)} before + * mutating the repository. This prevents an ACL or role change from being interleaved between + * descendant authorization and a destructive folder mutation. + */ + private long authorizationVersion; + @Inject public AuthorizationService(NoteManager noteManager, ZeppelinConfiguration zConf, ConfigStorage storage) { @@ -84,9 +95,10 @@ public AuthorizationService(NoteManager noteManager, ZeppelinConfiguration zConf * @param subject * @throws IOException */ - public void createNoteAuth(String noteId, AuthenticationInfo subject) { + public synchronized void createNoteAuth(String noteId, AuthenticationInfo subject) { NoteAuth noteAuth = new NoteAuth(noteId, subject, zConf); this.notesAuth.put(noteId, noteAuth); + authorizationVersion++; } /** @@ -98,8 +110,40 @@ public synchronized void saveNoteAuth() throws IOException { configStorage.save(new NotebookAuthorizationInfoSaving(this.notesAuth)); } - public void removeNoteAuth(String noteId) { - this.notesAuth.remove(noteId); + public synchronized void removeNoteAuth(String noteId) { + if (this.notesAuth.remove(noteId) != null) { + authorizationVersion++; + } + } + + public synchronized long getAuthorizationVersion() { + return authorizationVersion; + } + + public synchronized boolean isAuthorizationVersionCurrent(long expectedVersion) { + return authorizationVersion == expectedVersion; + } + + /** + * Run one operation only if its authorization preflight still belongs to the current ACL + * generation. ACL and cached-role mutations use the same monitor and therefore cannot be + * interleaved with the guarded operation. + */ + public synchronized T runWithAuthorizationVersion( + long expectedVersion, AuthorizationOperation operation) throws IOException { + if (authorizationVersion != expectedVersion) { + throw new IOException("Notebook authorization changed while authorizing the operation"); + } + return operation.run(); + } + + @FunctionalInterface + public interface AuthorizationOperation { + T run() throws IOException; + } + + public boolean hasNoteAuth(String noteId) { + return this.notesAuth.containsKey(noteId); } // skip empty user and remove the white space around user name. @@ -129,6 +173,15 @@ public void setRunners(String noteId, Set entities) throws IOException { setRunners(noteId, entities, true); } + public void setPermissions( + String noteId, + Set readers, + Set runners, + Set writers, + Set owners) throws IOException { + setPermissions(noteId, readers, runners, writers, owners, true); + } + public void setRoles(String user, Set roles) { setRoles(user, roles, true); } @@ -137,61 +190,105 @@ public void clearPermission(String noteId) throws IOException { clearPermission(noteId, true); } - public void setOwners(String noteId, Set entities, boolean broadcast) throws IOException { + public synchronized void setOwners( + String noteId, Set entities, boolean broadcast) throws IOException { entities = normalizeUsers(entities); NoteAuth noteAuth = notesAuth.get(noteId); if (noteAuth == null) { throw new IOException("No noteAuth found for noteId: " + noteId); } - noteAuth.setOwners(entities); + if (!noteAuth.getOwners().equals(entities)) { + noteAuth.setOwners(entities); + authorizationVersion++; + } } - public void setReaders(String noteId, Set entities, boolean broadcast) throws IOException { + public synchronized void setReaders( + String noteId, Set entities, boolean broadcast) throws IOException { entities = normalizeUsers(entities); NoteAuth noteAuth = notesAuth.get(noteId); if (noteAuth == null) { throw new IOException("No noteAuth found for noteId: " + noteId); } - noteAuth.setReaders(entities); + if (!noteAuth.getReaders().equals(entities)) { + noteAuth.setReaders(entities); + authorizationVersion++; + } } - public void setRunners(String noteId, Set entities, boolean broadcast) throws IOException { + public synchronized void setRunners( + String noteId, Set entities, boolean broadcast) throws IOException { entities = normalizeUsers(entities); NoteAuth noteAuth = notesAuth.get(noteId); if (noteAuth == null) { throw new IOException("No noteAuth found for noteId: " + noteId); } - noteAuth.setRunners(entities); + if (!noteAuth.getRunners().equals(entities)) { + noteAuth.setRunners(entities); + authorizationVersion++; + } } - public void setWriters(String noteId, Set entities, boolean broadcast) throws IOException { + public synchronized void setWriters( + String noteId, Set entities, boolean broadcast) throws IOException { entities = normalizeUsers(entities); NoteAuth noteAuth = notesAuth.get(noteId); if (noteAuth == null) { throw new IOException("No noteAuth found for noteId: " + noteId); } - noteAuth.setWriters(entities); + if (!noteAuth.getWriters().equals(entities)) { + noteAuth.setWriters(entities); + authorizationVersion++; + } + } + + public synchronized void setPermissions( + String noteId, + Set readers, + Set runners, + Set writers, + Set owners, + boolean broadcast) throws IOException { + Set normalizedReaders = normalizeUsers(readers); + Set normalizedRunners = normalizeUsers(runners); + Set normalizedWriters = normalizeUsers(writers); + Set normalizedOwners = normalizeUsers(owners); + NoteAuth noteAuth = notesAuth.get(noteId); + if (noteAuth == null) { + throw new IOException("No noteAuth found for noteId: " + noteId); + } + NoteAuth.Permissions current = noteAuth.getPermissions(); + if (!current.getReaders().equals(normalizedReaders) + || !current.getRunners().equals(normalizedRunners) + || !current.getWriters().equals(normalizedWriters) + || !current.getOwners().equals(normalizedOwners)) { + noteAuth.setPermissions( + normalizedReaders, normalizedRunners, normalizedWriters, normalizedOwners); + authorizationVersion++; + } } - public void setRoles(String user, Set roles, boolean broadcast) { + public synchronized void setRoles(String user, Set roles, boolean broadcast) { if (StringUtils.isBlank(user)) { LOGGER.warn("Setting roles for empty user"); return; } roles = normalizeUsers(roles); - userRoles.put(user, roles); + Set immutableRoles = Collections.unmodifiableSet(new HashSet<>(roles)); + Set previousRoles = userRoles.put(user, immutableRoles); + if (!immutableRoles.equals(previousRoles)) { + authorizationVersion++; + } } public void clearPermission(String noteId, boolean broadcast) throws IOException { - NoteAuth noteAuth = notesAuth.get(noteId); - if (noteAuth == null) { - throw new IOException("No noteAuth found for noteId: " + noteId); - } - noteAuth.setReaders(new HashSet<>()); - noteAuth.setRunners(new HashSet<>()); - noteAuth.setWriters(new HashSet<>()); - noteAuth.setOwners(new HashSet<>()); - + setPermissions( + noteId, + Set.of(), + Set.of(), + Set.of(), + Set.of(), + broadcast); } public Set getOwners(String noteId) { @@ -231,32 +328,52 @@ public Set getWriters(String noteId) { } public Set getRoles(String user) { - return userRoles.getOrDefault(user, new HashSet<>()); + return new HashSet<>(userRoles.getOrDefault(user, EMPTY_SET)); } public boolean isOwner(String noteId, Set entities) { - return isMember(entities, getOwners(noteId)) || isAdmin(entities); + NoteAuth noteAuth = notesAuth.get(noteId); + if (noteAuth == null) { + return false; + } + NoteAuth.Permissions permissions = noteAuth.getPermissions(); + return isMember(entities, permissions.getOwners()) || isAdmin(entities); } public boolean isWriter(String noteId, Set entities) { - return isMember(entities, getWriters(noteId)) || - isMember(entities, getOwners(noteId)) || - isAdmin(entities); + NoteAuth noteAuth = notesAuth.get(noteId); + if (noteAuth == null) { + return false; + } + NoteAuth.Permissions permissions = noteAuth.getPermissions(); + return isMember(entities, permissions.getWriters()) + || isMember(entities, permissions.getOwners()) + || isAdmin(entities); } public boolean isReader(String noteId, Set entities) { - return isMember(entities, getReaders(noteId)) || - isMember(entities, getOwners(noteId)) || - isMember(entities, getWriters(noteId)) || - isMember(entities, getRunners(noteId)) || - isAdmin(entities); + NoteAuth noteAuth = notesAuth.get(noteId); + if (noteAuth == null) { + return false; + } + NoteAuth.Permissions permissions = noteAuth.getPermissions(); + return isMember(entities, permissions.getReaders()) + || isMember(entities, permissions.getOwners()) + || isMember(entities, permissions.getWriters()) + || isMember(entities, permissions.getRunners()) + || isAdmin(entities); } public boolean isRunner(String noteId, Set entities) { - return isMember(entities, getRunners(noteId)) || - isMember(entities, getWriters(noteId)) || - isMember(entities, getOwners(noteId)) || - isAdmin(entities); + NoteAuth noteAuth = notesAuth.get(noteId); + if (noteAuth == null) { + return false; + } + NoteAuth.Permissions permissions = noteAuth.getPermissions(); + return isMember(entities, permissions.getRunners()) + || isMember(entities, permissions.getWriters()) + || isMember(entities, permissions.getOwners()) + || isAdmin(entities); } private boolean isAdmin(Set entities) { @@ -275,6 +392,9 @@ private boolean isMember(Set a, Set b) { } public boolean isOwner(Set userAndRoles, String noteId) { + if (!hasNoteAuth(noteId)) { + return false; + } if (zConf.isAnonymousAllowed()) { LOGGER.debug("Zeppelin runs in anonymous mode, everybody is owner"); return true; @@ -287,6 +407,9 @@ public boolean isOwner(Set userAndRoles, String noteId) { //TODO(zjffdu) merge this hasWritePermission with isWriter ? public boolean hasWritePermission(Set userAndRoles, String noteId) { + if (!hasNoteAuth(noteId)) { + return false; + } if (zConf.isAnonymousAllowed()) { LOGGER.debug("Zeppelin runs in anonymous mode, everybody is writer"); return true; @@ -298,6 +421,9 @@ public boolean hasWritePermission(Set userAndRoles, String noteId) { } public boolean hasReadPermission(Set userAndRoles, String noteId) { + if (!hasNoteAuth(noteId)) { + return false; + } if (zConf.isAnonymousAllowed()) { LOGGER.debug("Zeppelin runs in anonymous mode, everybody is reader"); return true; @@ -309,6 +435,9 @@ public boolean hasReadPermission(Set userAndRoles, String noteId) { } public boolean hasRunPermission(Set userAndRoles, String noteId) { + if (!hasNoteAuth(noteId)) { + return false; + } if (zConf.isAnonymousAllowed()) { LOGGER.debug("Zeppelin runs in anonymous mode, everybody is reader"); return true; diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Note.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Note.java index 8b7622e1ef1..c2fc31f2d3d 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Note.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Note.java @@ -104,7 +104,7 @@ public class Note implements JsonSerializable { * The fair behavior can therefore create a DeadLock. */ private transient final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(false); - private transient boolean removed = false; + private transient volatile boolean removed = false; private transient InterpreterFactory interpreterFactory; private transient InterpreterSettingManager interpreterSettingManager; private transient ParagraphJobListener paragraphJobListener; diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteAuth.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteAuth.java index 7a715159e28..935fcff53d0 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteAuth.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteAuth.java @@ -20,6 +20,7 @@ import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.apache.zeppelin.user.AuthenticationInfo; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -34,10 +35,7 @@ public class NoteAuth { private final String noteId; private final ZeppelinConfiguration zConf; - private Set readers = new HashSet<>(); - private Set writers = new HashSet<>(); - private Set runners = new HashSet<>(); - private Set owners = new HashSet<>(); + private volatile Permissions permissions = Permissions.empty(); public NoteAuth(String noteId, ZeppelinConfiguration zConf) { this(noteId, AuthenticationInfo.ANONYMOUS, zConf); @@ -60,62 +58,112 @@ public NoteAuth(String noteId, AuthenticationInfo subject, ZeppelinConfiguration public NoteAuth(String noteId, Map> permissions, ZeppelinConfiguration zConf) { this.noteId = noteId; this.zConf = zConf; - this.readers = permissions.getOrDefault("readers", new HashSet<>()); - this.writers = permissions.getOrDefault("writers", new HashSet<>()); - this.runners = permissions.getOrDefault("runners", new HashSet<>()); - this.owners = permissions.getOrDefault("owners", new HashSet<>()); + this.permissions = + new Permissions( + immutableLoadedEntities( + permissions.getOrDefault("readers", Collections.emptySet())), + immutableLoadedEntities( + permissions.getOrDefault("runners", Collections.emptySet())), + immutableLoadedEntities( + permissions.getOrDefault("writers", Collections.emptySet())), + immutableLoadedEntities( + permissions.getOrDefault("owners", Collections.emptySet()))); } // used when creating new note - public void initPermissions(AuthenticationInfo subject) { + public synchronized void initPermissions(AuthenticationInfo subject) { + Set readers = Collections.emptySet(); + Set writers = Collections.emptySet(); + Set runners = Collections.emptySet(); + Set owners = Collections.emptySet(); if (!AuthenticationInfo.isAnonymous(subject)) { + Set owner = Collections.singleton(checkCaseAndConvert(subject.getUser())); if (zConf.isNotebookPublic()) { // add current user to owners - can be public - this.owners.add(checkCaseAndConvert(subject.getUser())); + owners = owner; } else { // add current user to owners, readers, runners, writers - private note - this.owners.add(checkCaseAndConvert(subject.getUser())); - this.readers.add(checkCaseAndConvert(subject.getUser())); - this.writers.add(checkCaseAndConvert(subject.getUser())); - this.runners.add(checkCaseAndConvert(subject.getUser())); + owners = owner; + readers = owner; + writers = owner; + runners = owner; } } + setPermissions(readers, runners, writers, owners); } public String getNoteId() { return noteId; } - public void setOwners(Set entities) { - this.owners = checkCaseAndConvert(entities); - } - - public void setReaders(Set entities) { - this.readers = checkCaseAndConvert(entities); - } - - public void setWriters(Set entities) { - this.writers = checkCaseAndConvert(entities); - } - - public void setRunners(Set entities) { - this.runners = checkCaseAndConvert(entities); + public synchronized void setOwners(Set entities) { + Permissions current = permissions; + permissions = + new Permissions( + current.getReaders(), + current.getRunners(), + current.getWriters(), + immutableEntities(entities)); + } + + public synchronized void setReaders(Set entities) { + Permissions current = permissions; + permissions = + new Permissions( + immutableEntities(entities), + current.getRunners(), + current.getWriters(), + current.getOwners()); + } + + public synchronized void setWriters(Set entities) { + Permissions current = permissions; + permissions = + new Permissions( + current.getReaders(), + current.getRunners(), + immutableEntities(entities), + current.getOwners()); + } + + public synchronized void setRunners(Set entities) { + Permissions current = permissions; + permissions = + new Permissions( + current.getReaders(), + immutableEntities(entities), + current.getWriters(), + current.getOwners()); + } + + public synchronized void setPermissions( + Set readers, Set runners, Set writers, Set owners) { + permissions = + new Permissions( + immutableEntities(readers), + immutableEntities(runners), + immutableEntities(writers), + immutableEntities(owners)); } public Set getOwners() { - return this.owners; + return permissions.getOwners(); } public Set getReaders() { - return this.readers; + return permissions.getReaders(); } public Set getWriters() { - return this.writers; + return permissions.getWriters(); } public Set getRunners() { - return this.runners; + return permissions.getRunners(); + } + + Permissions getPermissions() { + return permissions; } /* @@ -129,10 +177,18 @@ private Set checkCaseAndConvert(Set entities) { } return set2; } else { - return entities; + return new HashSet<>(entities); } } + private Set immutableEntities(Set entities) { + return Collections.unmodifiableSet(checkCaseAndConvert(entities)); + } + + private Set immutableLoadedEntities(Set entities) { + return Collections.unmodifiableSet(new HashSet<>(entities)); + } + private String checkCaseAndConvert(String entity) { if (zConf.isUsernameForceLowerCase()) { return entity.toLowerCase(); @@ -142,11 +198,54 @@ private String checkCaseAndConvert(String entity) { } public Map> toMap() { - Map> map = new HashMap<>(); - map.put("readers", readers); - map.put("writers", writers); - map.put("runners", runners); - map.put("owners", owners); - return map; + return permissions.toMap(); + } + + static final class Permissions { + private final Set readers; + private final Set runners; + private final Set writers; + private final Set owners; + + private Permissions( + Set readers, Set runners, Set writers, Set owners) { + this.readers = readers; + this.runners = runners; + this.writers = writers; + this.owners = owners; + } + + private static Permissions empty() { + return new Permissions( + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + } + + Set getReaders() { + return readers; + } + + Set getRunners() { + return runners; + } + + Set getWriters() { + return writers; + } + + Set getOwners() { + return owners; + } + + Map> toMap() { + Map> map = new HashMap<>(); + map.put("readers", readers); + map.put("writers", writers); + map.put("runners", runners); + map.put("owners", owners); + return map; + } } } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java index 0635fde994c..d1be895ec91 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java @@ -35,6 +35,7 @@ import org.apache.zeppelin.notebook.Notebook.NoteProcessor; import org.apache.zeppelin.notebook.exception.NotePathAlreadyExistsException; import org.apache.zeppelin.notebook.repo.NotebookRepo; +import org.apache.zeppelin.notebook.repo.NotebookRepoWithVersionControl; import org.apache.zeppelin.user.AuthenticationInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -69,6 +70,7 @@ public class NoteManager { * operations never observe a tree and a mapping that belong to different generations. */ private volatile NoteTree noteTree; + private long metadataVersion; @Inject public NoteManager(NotebookRepo notebookRepo, ZeppelinConfiguration zConf) throws IOException { @@ -105,6 +107,13 @@ public Map getNotesInfo() { return this.noteTree.notesInfo; } + /** Capture one immutable generation of the note-id/path index for authorization preflight. */ + public synchronized NoteMetadataSnapshot getNotesInfoSnapshot() { + return new NoteMetadataSnapshot( + metadataVersion, + Collections.unmodifiableMap(new LinkedHashMap<>(noteTree.notesInfo))); + } + /** * Rebuild the notebook metadata from the NotebookRepo. The new tree is built completely @@ -113,8 +122,9 @@ public Map getNotesInfo() { * * @throws IOException */ - public void reloadNotes() throws IOException { + public synchronized void reloadNotes() throws IOException { this.noteTree = buildNoteTree(); + metadataVersion++; } /** @@ -183,23 +193,26 @@ public boolean containsFolder(String folderPath) { * @param subject * @throws IOException */ - public void saveNote(Note note, AuthenticationInfo subject) throws IOException { + public synchronized void saveNote(Note note, AuthenticationInfo subject) throws IOException { if (note.isRemoved()) { LOGGER.warn("Try to save note: {} when it is removed", note.getId()); } else { - addOrUpdateNoteNode(this.noteTree, new NoteInfo(note), false); - noteCache.putNote(note); // Make sure to execute `notebookRepo.save()` successfully in concurrent context // Otherwise, the NullPointerException will be thrown when invoking notebookRepo.get() in the following operations. - synchronized (this) { - this.notebookRepo.save(note, subject); + String previousPath = noteTree.notesInfo.get(note.getId()); + addOrUpdateNoteNode(this.noteTree, new NoteInfo(note), false); + noteCache.putNote(note); + if (!StringUtils.equals(previousPath, note.getPath())) { + metadataVersion++; } + this.notebookRepo.save(note, subject); } } - public void addNote(Note note, AuthenticationInfo subject) throws IOException { + public synchronized void addNote(Note note, AuthenticationInfo subject) throws IOException { addOrUpdateNoteNode(this.noteTree, new NoteInfo(note), true); noteCache.putNote(note); + metadataVersion++; } /** @@ -212,6 +225,31 @@ public void saveNote(Note note) throws IOException { saveNote(note, AuthenticationInfo.ANONYMOUS); } + /** + * Restore a note revision and publish it to the note cache and path index atomically with + * respect to note and folder moves. + */ + public synchronized Note setNoteRevision( + String noteId, + String notePath, + String revisionId, + AuthenticationInfo subject) throws IOException { + String currentPath = noteTree.notesInfo.get(noteId); + if (currentPath == null) { + throw new IOException("No metadata found for this note: " + noteId); + } + if (!StringUtils.equals(currentPath, notePath)) { + throw new IOException("Note path changed while setting the revision"); + } + + Note note = ((NotebookRepoWithVersionControl) notebookRepo) + .setNoteRevision(noteId, notePath, revisionId, subject); + if (note != null) { + saveNote(note, subject); + } + return note; + } + /** * Remove note from NotebookRepo and NoteManager * @@ -219,12 +257,13 @@ public void saveNote(Note note) throws IOException { * @param subject * @throws IOException */ - public void removeNote(String noteId, AuthenticationInfo subject) throws IOException { + public synchronized void removeNote(String noteId, AuthenticationInfo subject) throws IOException { NoteTree tree = this.noteTree; String notePath = tree.notesInfo.remove(noteId); Folder folder = getOrCreateFolder(tree, getFolderName(notePath)); folder.removeNote(getNoteName(notePath)); noteCache.removeNote(noteId); + metadataVersion++; this.notebookRepo.remove(noteId, notePath, subject); } @@ -235,57 +274,86 @@ public void moveNote(String noteId, throw new IOException("No metadata found for this note: " + noteId); } - NoteTree tree = this.noteTree; - if (!isNotePathAvailable(tree, newNotePath)) { - throw new NotePathAlreadyExistsException("Note '" + newNotePath + "' existed"); + String notePath; + synchronized (this) { + NoteTree tree = this.noteTree; + if (!isNotePathAvailable(tree, newNotePath)) { + throw new NotePathAlreadyExistsException("Note '" + newNotePath + "' existed"); + } + + notePath = tree.notesInfo.get(noteId); + NoteNode noteNode = getNoteNode(tree, notePath); + + // Move durable state first. If the repository rejects the destination, the in-memory + // path index and cached note must remain on the source path. + this.notebookRepo.move(noteId, notePath, newNotePath, subject); + + // move the old NoteNode from notePath to newNotePath + noteNode.getParent().removeNote(getNoteName(notePath)); + noteNode.setNotePath(newNotePath); + String newParent = getFolderName(newNotePath); + Folder newFolder = getOrCreateFolder(tree, newParent); + newFolder.addNoteNode(noteNode); + + // update noteInfo mapping + tree.notesInfo.put(noteId, newNotePath); + updateCachedNotePath(noteId, newNotePath); + metadataVersion++; + + // The cache may evict the note while many notes are moved concurrently. Reload it through + // the new metadata path so the repository-backed object also receives the updated path. + if (!StringUtils.equals(notePath, newNotePath)) { + processNote(noteId, + note -> { + note.setPath(newNotePath); + return null; + }); + } + + // save note if note name is changed, because we need to update the note field in note json. + String oldNoteName = getNoteName(notePath); + String newNoteName = getNoteName(newNotePath); + if (!StringUtils.equals(oldNoteName, newNoteName)) { + processNote(noteId, + note -> { + this.notebookRepo.save(note, subject); + return null; + }); + } } + } - // move the old NoteNode from notePath to newNotePath - String notePath = tree.notesInfo.get(noteId); - NoteNode noteNode = getNoteNode(tree, notePath); - noteNode.getParent().removeNote(getNoteName(notePath)); - noteNode.setNotePath(newNotePath); - String newParent = getFolderName(newNotePath); - Folder newFolder = getOrCreateFolder(tree, newParent); - newFolder.addNoteNode(noteNode); + public synchronized void moveFolder(String folderPath, + String newFolderPath, + AuthenticationInfo subject) throws IOException { + moveFolder(folderPath, newFolderPath, subject, -1); + } - // update noteInfo mapping - tree.notesInfo.put(noteId, newNotePath); + public synchronized void moveFolder( + String folderPath, + String newFolderPath, + AuthenticationInfo subject, + long expectedMetadataVersion) throws IOException { - // update notebookrepo - this.notebookRepo.move(noteId, notePath, newNotePath, subject); + assertMetadataVersion(expectedMetadataVersion); - // Update path of the note - if (!StringUtils.equals(notePath, newNotePath)) { - processNote(noteId, - note -> { - note.setPath(newNotePath); - return null; - }); + NoteTree tree = this.noteTree; + Folder folder = getFolder(tree, folderPath); + if (StringUtils.equals(folderPath, newFolderPath)) { + return; } - - // save note if note name is changed, because we need to update the note field in note json. - String oldNoteName = getNoteName(notePath); - String newNoteName = getNoteName(newNotePath); - if (!StringUtils.equals(oldNoteName, newNoteName)) { - processNote(noteId, - note -> { - this.notebookRepo.save(note, subject); - return null; - }); + if (newFolderPath.startsWith(folderPath + "/")) { + throw new IOException( + "Can not move folder '" + folderPath + "' into its own descendant"); + } + if (containsNote(newFolderPath) || containsFolder(newFolderPath)) { + throw new NotePathAlreadyExistsException("Path '" + newFolderPath + "' existed"); } - } - - public void moveFolder(String folderPath, - String newFolderPath, - AuthenticationInfo subject) throws IOException { // update notebookrepo this.notebookRepo.move(folderPath, newFolderPath, subject); // update filesystem tree - NoteTree tree = this.noteTree; - Folder folder = getFolder(tree, folderPath); folder.getParent().removeFolder(folder.getName(), subject); Folder newFolder = getOrCreateFolder(tree, newFolderPath); newFolder.getParent().addFolder(newFolder.getName(), folder); @@ -293,7 +361,9 @@ public void moveFolder(String folderPath, // update notesInfo for (NoteInfo noteInfo : folder.getNoteInfoRecursively()) { tree.notesInfo.put(noteInfo.getId(), noteInfo.getPath()); + updateCachedNotePath(noteInfo.getId(), noteInfo.getPath()); } + metadataVersion++; } /** @@ -315,23 +385,150 @@ public List getNoteInfoRecursively(String folderPath) throws IOExcepti * @return * @throws IOException */ - public List removeFolder(String folderPath, AuthenticationInfo subject) throws IOException { + public synchronized List removeFolder( + String folderPath, AuthenticationInfo subject) throws IOException { + return removeFolder(folderPath, subject, -1); + } - // update notebookrepo - this.notebookRepo.remove(folderPath, subject); + public synchronized List removeFolder( + String folderPath, + AuthenticationInfo subject, + long expectedMetadataVersion) throws IOException { + + return removeFolder( + folderPath, subject, expectedMetadataVersion, Collections.emptyList()); + } + + synchronized List removeFolder( + String folderPath, + AuthenticationInfo subject, + long expectedMetadataVersion, + List loadedNotes) throws IOException { + + assertMetadataVersion(expectedMetadataVersion); + + List newlyRemovedNotes = new ArrayList<>(); + for (Note note : loadedNotes) { + if (!note.isRemoved()) { + note.setRemoved(true); + newlyRemovedNotes.add(note); + } + } + + try { + // update notebookrepo + this.notebookRepo.remove(folderPath, subject); + + // update filesystem tree + NoteTree tree = this.noteTree; + Folder folder = getFolder(tree, folderPath); + List noteInfos = folder.getNoteInfoRecursively(); + if (folder == tree.trash) { + folder.clear(); + } else { + folder.getParent().removeFolder(folder.getName(), subject); + } + + // update notesInfo and evict the deleted notes from the cache, mirroring removeNote + for (NoteInfo noteInfo : noteInfos) { + tree.notesInfo.remove(noteInfo.getId()); + this.noteCache.removeNote(noteInfo.getId()); + } + metadataVersion++; + + return noteInfos; + } catch (IOException | RuntimeException e) { + for (Note note : newlyRemovedNotes) { + note.setRemoved(false); + } + throw e; + } + } + + /** + * Restore every direct child of the trash against one authorized metadata generation. + * Structural changes are blocked for the full preflight and move sequence so a note cannot + * be added to the authorized folder after its ACL was checked. + * + * @return note-id to restored path for callers that need to report the restored entries + */ + public synchronized Map restoreAllFromTrash( + AuthenticationInfo subject, long expectedMetadataVersion) throws IOException { + assertMetadataVersion(expectedMetadataVersion); - // update filesystem tree NoteTree tree = this.noteTree; - Folder folder = getFolder(tree, folderPath); - List noteInfos = folder.getParent().removeFolder(folder.getName(), subject); + List notes = new ArrayList<>(tree.trash.getNotes().values()); + List folders = new ArrayList<>(tree.trash.getFolders().values()); + Map restoredPaths = new LinkedHashMap<>(); + Map destinations = new LinkedHashMap<>(); + String trashPrefix = "/" + TRASH_FOLDER; + + for (NoteNode noteNode : notes) { + String destination = noteNode.getNotePath().substring(trashPrefix.length()); + checkRestoreDestination(destination, destinations); + } + for (Folder folder : folders) { + String destination = folder.getPath().substring(trashPrefix.length()); + checkRestoreDestination(destination, destinations); + } + + boolean mutated = false; + try { + for (NoteNode noteNode : notes) { + String noteId = noteNode.getNoteId(); + String oldPath = noteNode.getNotePath(); + String newPath = oldPath.substring(trashPrefix.length()); + notebookRepo.move(noteId, oldPath, newPath, subject); + noteNode.getParent().removeNote(getNoteName(oldPath)); + noteNode.setNotePath(newPath); + getOrCreateFolder(tree, getFolderName(newPath)).addNoteNode(noteNode); + tree.notesInfo.put(noteId, newPath); + updateCachedNotePath(noteId, newPath); + restoredPaths.put(noteId, newPath); + mutated = true; + } + for (Folder folder : folders) { + String oldPath = folder.getPath(); + String newPath = oldPath.substring(trashPrefix.length()); + notebookRepo.move(oldPath, newPath, subject); + folder.getParent().removeFolder(folder.getName(), subject); + Folder destination = getOrCreateFolder(tree, newPath); + destination.getParent().addFolder(destination.getName(), folder); + for (NoteInfo noteInfo : folder.getNoteInfoRecursively()) { + tree.notesInfo.put(noteInfo.getId(), noteInfo.getPath()); + updateCachedNotePath(noteInfo.getId(), noteInfo.getPath()); + restoredPaths.put(noteInfo.getId(), noteInfo.getPath()); + } + mutated = true; + } + } finally { + if (mutated) { + metadataVersion++; + } + } + return restoredPaths; + } - // update notesInfo and evict the deleted notes from the cache, mirroring removeNote - for (NoteInfo noteInfo : noteInfos) { - tree.notesInfo.remove(noteInfo.getId()); - this.noteCache.removeNote(noteInfo.getId()); + private void checkRestoreDestination( + String destination, Map destinations) throws IOException { + if (destinations.put(destination, Boolean.TRUE) != null + || containsNote(destination) + || containsFolder(destination)) { + throw new NotePathAlreadyExistsException("Path '" + destination + "' existed"); } + } - return noteInfos; + private void assertMetadataVersion(long expectedMetadataVersion) throws IOException { + if (expectedMetadataVersion >= 0 && metadataVersion != expectedMetadataVersion) { + throw new IOException("Notebook metadata changed while authorizing the folder operation"); + } + } + + private void updateCachedNotePath(String noteId, String notePath) { + Note note = noteCache.getNote(noteId); + if (note != null) { + note.setPath(notePath); + } } /** @@ -465,6 +662,25 @@ public String getNoteIdByPath(String notePath) throws IOException { return noteNode.getNoteId(); } + /** Immutable note metadata generation used to bind authorization to a later mutation. */ + public static final class NoteMetadataSnapshot { + private final long version; + private final Map notesInfo; + + NoteMetadataSnapshot(long version, Map notesInfo) { + this.version = version; + this.notesInfo = notesInfo; + } + + public long getVersion() { + return version; + } + + public Map getNotesInfo() { + return notesInfo; + } + } + /** * The two indexes that together locate a note: the folder tree and the noteId -> notePath * mapping. A note lookup resolves the id through the mapping and then walks the tree, so @@ -592,6 +808,11 @@ public List removeFolder(String folderName, return folder.getNoteInfoRecursively(); } + private void clear() { + notes.clear(); + subFolders.clear(); + } + public List getNoteInfoRecursively() { List notesInfo = new ArrayList<>(); for (NoteNode noteNode : this.notes.values()) { diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java index 83f0032822f..d88428419cf 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java @@ -51,8 +51,6 @@ import org.apache.zeppelin.interpreter.InterpreterSetting; import org.apache.zeppelin.interpreter.InterpreterSettingManager; import org.apache.zeppelin.interpreter.ManagedInterpreterGroup; -import org.apache.zeppelin.notebook.NoteManager.Folder; -import org.apache.zeppelin.notebook.NoteManager.NoteNode; import org.apache.zeppelin.notebook.repo.NotebookRepo; import org.apache.zeppelin.notebook.repo.NotebookRepoSync; import org.apache.zeppelin.notebook.repo.NotebookRepoWithVersionControl; @@ -312,9 +310,16 @@ public String createNote(String notePath, new Note(notePath, defaultInterpreterGroup, replFactory, interpreterSettingManager, paragraphJobListener, credentials, noteEventListeners, zConf, notebookRepo.getNoteParser()); - noteManager.addNote(note, subject); - // init noteMeta + // Publish authorization before metadata. A folder operation must never observe a new note + // without its ACL and interpret the missing ACL as a public, empty permission set. authorizationService.createNoteAuth(note.getId(), subject); + try { + noteManager.addNote(note, subject); + } catch (IOException e) { + authorizationService.removeNoteAuth(note.getId()); + throw e; + } + // init noteMeta authorizationService.saveNoteAuth(); if (save) { noteManager.saveNote(note, subject); @@ -430,8 +435,8 @@ private void removeNote(Note note, AuthenticationInfo subject) throws IOExceptio // Set Remove to true to cancel saving this note note.setRemoved(true); noteManager.removeNote(note.getId(), subject); - authorizationService.removeNoteAuth(note.getId()); fireNoteRemoveEvent(note, subject); + authorizationService.removeNoteAuth(note.getId()); } public void removeCorruptedNote(String noteId, AuthenticationInfo subject) throws IOException { @@ -546,49 +551,73 @@ public void moveNote(String noteId, String newNotePath, AuthenticationInfo subje } public void moveFolder(String folderPath, String newFolderPath, AuthenticationInfo subject) throws IOException { + moveFolder(folderPath, newFolderPath, subject, -1); + } + + public void moveFolder( + String folderPath, + String newFolderPath, + AuthenticationInfo subject, + long expectedMetadataVersion) throws IOException { LOGGER.info("Move folder from {} to {}", folderPath, newFolderPath); - noteManager.moveFolder(folderPath, newFolderPath, subject); + noteManager.moveFolder(folderPath, newFolderPath, subject, expectedMetadataVersion); } public void removeFolder(String folderPath, AuthenticationInfo subject) throws IOException { + removeFolder(folderPath, subject, -1); + } + + public void removeFolder( + String folderPath, + AuthenticationInfo subject, + long expectedMetadataVersion) throws IOException { LOGGER.info("Remove folder {}", folderPath); - // Notes must be loaded and their remove listeners fired before the folder (and its - // underlying repo storage) is deleted, otherwise the note content is no longer - // available to run the same per-note cleanup as removeNote(String, AuthenticationInfo). + // Notes must be loaded before the folder (and its underlying repo storage) is deleted, + // otherwise the note content is no longer available to run the same per-note cleanup as + // removeNote(String, AuthenticationInfo). NoteManager marks these objects removed atomically + // with the deletion; listeners run only after the deletion succeeds. List noteInfos = noteManager.getNoteInfoRecursively(folderPath); + Map loadedNotes = new HashMap<>(); for (NoteInfo noteInfo : noteInfos) { processNote(noteInfo.getId(), note -> { if (note != null) { - note.setRemoved(true); - authorizationService.removeNoteAuth(note.getId()); - fireNoteRemoveEvent(note, subject); + loadedNotes.put(note.getId(), note); } return null; }); } - noteManager.removeFolder(folderPath, subject); + noteManager.removeFolder( + folderPath, + subject, + expectedMetadataVersion, + new ArrayList<>(loadedNotes.values())); + for (NoteInfo noteInfo : noteInfos) { + Note note = loadedNotes.get(noteInfo.getId()); + if (note != null) { + fireNoteRemoveEvent(note, subject); + } + authorizationService.removeNoteAuth(noteInfo.getId()); + } } public void emptyTrash(AuthenticationInfo subject) throws IOException { + emptyTrash(subject, -1); + } + + public void emptyTrash(AuthenticationInfo subject, long expectedMetadataVersion) + throws IOException { LOGGER.info("Empty Trash"); - removeFolder("/" + NoteManager.TRASH_FOLDER, subject); + removeFolder("/" + NoteManager.TRASH_FOLDER, subject, expectedMetadataVersion); } public void restoreAll(AuthenticationInfo subject) throws IOException { - NoteManager.Folder trash = noteManager.getTrashFolder(); - // restore notes under trash folder - // If the value changes in the loop, a concurrent modification exception is thrown. - // Collector implementation of collect methods to maintain immutability. - List notes = trash.getNotes().values().stream().collect(Collectors.toList()); - for (NoteManager.NoteNode noteNode : notes) { - moveNote(noteNode.getNoteId(), noteNode.getNotePath().replace("/~Trash", ""), subject); - } - // restore folders under trash folder - List folders = trash.getFolders().values().stream().collect(Collectors.toList()); - for (NoteManager.Folder folder : folders) { - moveFolder(folder.getPath(), folder.getPath().replace("/~Trash", ""), subject); - } + restoreAll(subject, -1); + } + + public void restoreAll(AuthenticationInfo subject, long expectedMetadataVersion) + throws IOException { + noteManager.restoreAllFromTrash(subject, expectedMetadataVersion); } public Revision checkpointNote(String noteId, String notePath, String checkpointMessage, @@ -615,10 +644,7 @@ public List listRevisionHistory(String noteId, public Note setNoteRevision(String noteId, String notePath, String revisionId, AuthenticationInfo subject) throws IOException { if (((NotebookRepoSync) notebookRepo).isRevisionSupportedInDefaultRepo()) { - Note note = ((NotebookRepoWithVersionControl) notebookRepo) - .setNoteRevision(noteId, notePath, revisionId, subject); - noteManager.saveNote(note); - return note; + return noteManager.setNoteRevision(noteId, notePath, revisionId, subject); } else { return null; } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/GitNotebookRepo.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/GitNotebookRepo.java index 80d336934b9..8f87d8aa778 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/GitNotebookRepo.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/GitNotebookRepo.java @@ -22,6 +22,7 @@ import org.apache.zeppelin.notebook.NoteParser; import org.apache.zeppelin.user.AuthenticationInfo; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.NoHeadException; import org.eclipse.jgit.diff.DiffEntry; @@ -31,6 +32,8 @@ import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.treewalk.filter.PathFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,38 +78,127 @@ public void init(ZeppelinConfiguration zConf, NoteParser noteParser) throws IOEx } @Override - public void move(String noteId, - String notePath, - String newNotePath, - AuthenticationInfo subject) throws IOException { + public synchronized void move(String noteId, + String notePath, + String newNotePath, + AuthenticationInfo subject) throws IOException { + ObjectId headBeforeMove = git.getRepository().resolve(Constants.HEAD); super.move(noteId, notePath, newNotePath, subject); String noteFileName = buildNoteFileName(noteId, notePath); String newNoteFileName = buildNoteFileName(noteId, newNotePath); try { - git.rm().addFilepattern(noteFileName).call(); + git.rm().setCached(true).addFilepattern(noteFileName).call(); git.add().addFilepattern(newNoteFileName).call(); git.commit().setMessage("Move note " + noteId + " from " + noteFileName + " to " + newNoteFileName).call(); - } catch (GitAPIException e) { - throw new IOException(e); + } catch (GitAPIException | RuntimeException e) { + if (headContainsMove(headBeforeMove, noteFileName, newNoteFileName, e)) { + LOGGER.warn( + "Git committed note move from {} to {} before reporting a hook failure; " + + "keeping the committed move", + notePath, + newNotePath, + e); + return; + } + throw rollbackFailedMove( + "Failed to commit note move from " + notePath + " to " + newNotePath, + e, + () -> super.move(noteId, newNotePath, notePath, subject), + noteFileName, + newNoteFileName); } } @Override - public void move(String folderPath, String newFolderPath, - AuthenticationInfo subject) throws IOException { + public synchronized void move(String folderPath, String newFolderPath, + AuthenticationInfo subject) throws IOException { + ObjectId headBeforeMove = git.getRepository().resolve(Constants.HEAD); super.move(folderPath, newFolderPath, subject); + String folderName = folderPath.substring(1); + String newFolderName = newFolderPath.substring(1); try { - git.rm().addFilepattern(folderPath.substring(1)).call(); - git.add().addFilepattern(newFolderPath.substring(1)).call(); + git.rm().setCached(true).addFilepattern(folderName).call(); + git.add().addFilepattern(newFolderName).call(); git.commit().setMessage("Move folder " + folderPath + " to " + newFolderPath).call(); - } catch (GitAPIException e) { - throw new IOException(e); + } catch (GitAPIException | RuntimeException e) { + if (headContainsMove(headBeforeMove, folderName, newFolderName, e)) { + LOGGER.warn( + "Git committed folder move from {} to {} before reporting a hook failure; " + + "keeping the committed move", + folderPath, + newFolderPath, + e); + return; + } + throw rollbackFailedMove( + "Failed to commit folder move from " + folderPath + " to " + newFolderPath, + e, + () -> super.move(newFolderPath, folderPath, subject), + folderName, + newFolderName); + } + } + + private IOException rollbackFailedMove( + String message, + Throwable cause, + IoAction rollback, + String... affectedPaths) { + IOException failure = new IOException(message, cause); + try { + rollback.run(); + } catch (IOException rollbackFailure) { + failure.addSuppressed(rollbackFailure); } + + try { + ResetCommand reset = git.reset(); + for (String affectedPath : affectedPaths) { + reset.addPath(affectedPath); + } + reset.call(); + } catch (GitAPIException | RuntimeException resetFailure) { + failure.addSuppressed(resetFailure); + } + return failure; + } + + private boolean headContainsMove( + ObjectId previousHead, + String sourcePath, + String destinationPath, + Throwable failure) { + try { + ObjectId currentHead = git.getRepository().resolve(Constants.HEAD); + boolean headChanged = previousHead == null + ? currentHead != null + : !previousHead.equals(currentHead); + if (!headChanged || currentHead == null) { + return false; + } + try (RevWalk revWalk = new RevWalk(git.getRepository())) { + RevCommit currentCommit = revWalk.parseCommit(currentHead); + try (TreeWalk source = TreeWalk.forPath( + git.getRepository(), sourcePath, currentCommit.getTree()); + TreeWalk destination = TreeWalk.forPath( + git.getRepository(), destinationPath, currentCommit.getTree())) { + return source == null && destination != null; + } + } + } catch (IOException | RuntimeException headInspectionFailure) { + failure.addSuppressed(headInspectionFailure); + return false; + } + } + + @FunctionalInterface + private interface IoAction { + void run() throws IOException; } @Override - public void remove(String noteId, String notePath, AuthenticationInfo subject) + public synchronized void remove(String noteId, String notePath, AuthenticationInfo subject) throws IOException { super.remove(noteId, notePath, subject); String noteFileName = buildNoteFileName(noteId, notePath); @@ -119,7 +211,7 @@ public void remove(String noteId, String notePath, AuthenticationInfo subject) } @Override - public void remove(String folderPath, AuthenticationInfo subject) throws IOException { + public synchronized void remove(String folderPath, AuthenticationInfo subject) throws IOException { super.remove(folderPath, subject); try { git.rm().addFilepattern(folderPath.substring(1)).call(); @@ -137,10 +229,10 @@ public void remove(String folderPath, AuthenticationInfo subject) throws IOExcep * @see org.apache.zeppelin.notebook.repo.VFSNotebookRepo#checkpoint(String, String) */ @Override - public Revision checkpoint(String noteId, - String notePath, - String commitMessage, - AuthenticationInfo subject) throws IOException { + public synchronized Revision checkpoint(String noteId, + String notePath, + String commitMessage, + AuthenticationInfo subject) throws IOException { String noteFileName = buildNoteFileName(noteId, notePath); Revision revision = Revision.EMPTY; try { @@ -229,8 +321,8 @@ public List revisionHistory(String noteId, } @Override - public Note setNoteRevision(String noteId, String notePath, String revId, - AuthenticationInfo subject) + public synchronized Note setNoteRevision(String noteId, String notePath, String revId, + AuthenticationInfo subject) throws IOException { Note revisionNote = get(noteId, notePath, revId, subject); if (revisionNote != null) { diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepo.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepo.java index 32e433fff8f..db37c381104 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepo.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepo.java @@ -24,6 +24,7 @@ import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -189,11 +190,21 @@ public void move(String noteId, buildNoteFileName(noteId, notePath), NameScope.DESCENDENT); FileObject destFileObject = rootNotebookFileObject.resolveFile( buildNoteFileName(noteId, newNotePath), NameScope.DESCENDENT); + if (destFileObject.exists() && !isSameLocalFile(fileObject, destFileObject)) { + throw new IOException("Destination note already exists: " + newNotePath); + } // create parent folder first, otherwise move operation will fail destFileObject.getParent().createFolder(); fileObject.moveTo(destFileObject); } + private static boolean isSameLocalFile(FileObject source, FileObject destination) + throws IOException { + return "file".equalsIgnoreCase(source.getName().getScheme()) + && "file".equalsIgnoreCase(destination.getName().getScheme()) + && Files.isSameFile(source.getPath(), destination.getPath()); + } + @Override public void move(String folderPath, String newFolderPath, AuthenticationInfo subject) throws IOException{ @@ -202,6 +213,9 @@ public void move(String folderPath, String newFolderPath, folderPath.substring(1), NameScope.DESCENDENT); FileObject destFileObject = rootNotebookFileObject.resolveFile( newFolderPath.substring(1), NameScope.DESCENDENT); + if (destFileObject.exists() && !isSameLocalFile(fileObject, destFileObject)) { + throw new IOException("Destination folder already exists: " + newFolderPath); + } // create parent folder first, otherwise move operation will fail destFileObject.getParent().createFolder(); fileObject.moveTo(destFileObject); @@ -266,4 +280,3 @@ public void updateSettings(Map settings, AuthenticationInfo subj } } } - diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java index 296b687797f..a4ebbbce854 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java @@ -27,7 +27,7 @@ import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.ldap.AbstractLdapRealm; -import org.apache.shiro.realm.ldap.DefaultLdapContextFactory; +import org.apache.shiro.realm.ldap.JndiLdapContextFactory; import org.apache.shiro.realm.ldap.LdapContextFactory; import org.apache.shiro.realm.ldap.LdapUtils; import org.apache.shiro.subject.PrincipalCollection; @@ -105,9 +105,7 @@ protected void onInit() { public LdapContextFactory getLdapContextFactory() { if (this.ldapContextFactory == null) { LOGGER.debug("No LdapContextFactory specified - creating a default instance."); - DefaultLdapContextFactory defaultFactory = new DefaultLdapContextFactory(); - defaultFactory.setPrincipalSuffix(this.principalSuffix); - defaultFactory.setSearchBase(this.searchBase); + JndiLdapContextFactory defaultFactory = new JndiLdapContextFactory(); defaultFactory.setUrl(this.url); defaultFactory.setSystemUsername(this.systemUsername); defaultFactory.setSystemPassword(getSystemPassword()); @@ -292,6 +290,18 @@ public Map getListRoles() { return roles; } + /** + * Resolve every role for one principal with a single Active Directory query. + * + *

This is used when Zeppelin captures an authenticated identity for REST or WebSocket. + * Calling {@code Subject.hasRole} once per configured role can otherwise repeat the same LDAP + * lookup when Shiro authorization caching is disabled. + */ + public AuthorizationInfo queryForAuthorizationInfo(PrincipalCollection principals) + throws NamingException { + return queryForAuthorizationInfo(principals, getLdapContextFactory()); + } + private Set getRoleNamesForUser(String username, LdapContext ldapContext) throws NamingException { Set roleNames = new LinkedHashSet<>(); diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java index be8a0f0c68d..9a50f40e40d 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java @@ -48,7 +48,7 @@ import org.apache.hadoop.security.alias.CredentialProvider; import org.apache.hadoop.security.alias.CredentialProviderFactory; import org.apache.shiro.SecurityUtils; -import org.apache.shiro.ShiroException; +import org.apache.shiro.lang.ShiroException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.SimpleAuthenticationInfo; @@ -59,6 +59,7 @@ import org.apache.shiro.crypto.hash.Hash; import org.apache.shiro.crypto.hash.HashRequest; import org.apache.shiro.crypto.hash.HashService; +import org.apache.shiro.crypto.hash.SimpleHashProvider; import org.apache.shiro.realm.ldap.DefaultLdapRealm; import org.apache.shiro.realm.ldap.JndiLdapContextFactory; import org.apache.shiro.realm.ldap.LdapContextFactory; @@ -66,7 +67,7 @@ import org.apache.shiro.session.Session; import org.apache.shiro.subject.MutablePrincipalCollection; import org.apache.shiro.subject.PrincipalCollection; -import org.apache.shiro.util.StringUtils; +import org.apache.shiro.lang.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -143,7 +144,9 @@ public class LdapRealm extends DefaultLdapRealm { private static final String DEFAULT_PRINCIPAL_REGEX = "(.*)"; private static final String MEMBER_SUBSTITUTION_TOKEN = "{0}"; - private static final String HASHING_ALGORITHM = "SHA-1"; + private static final String HASHING_ALGORITHM = "SHA-256"; + private static final int HASHING_ITERATIONS = + SimpleHashProvider.Parameters.DEFAULT_ITERATIONS; private static final Logger LOGGER = LoggerFactory.getLogger(LdapRealm.class); static { @@ -200,6 +203,7 @@ public void setHadoopSecurityCredentialPath(String hadoopSecurityCredentialPath) public LdapRealm() { HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher(HASHING_ALGORITHM); + credentialsMatcher.setHashIterations(HASHING_ITERATIONS); setCredentialsMatcher(credentialsMatcher); } @@ -1029,7 +1033,10 @@ protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token, HashRequest.Builder builder = new HashRequest.Builder(); Hash credentialsHash = hashService .computeHash(builder.setSource(token.getCredentials()) - .setAlgorithmName(HASHING_ALGORITHM).build()); + .setAlgorithmName(HASHING_ALGORITHM) + .addParameter( + SimpleHashProvider.Parameters.PARAMETER_ITERATIONS, HASHING_ITERATIONS) + .build()); return new SimpleAuthenticationInfo(token.getPrincipal(), credentialsHash.toHex(), credentialsHash.getSalt(), getName()); diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/kerberos/KerberosRealm.java b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/kerberos/KerberosRealm.java index 0a09853aa7f..beeee990aae 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/kerberos/KerberosRealm.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/kerberos/KerberosRealm.java @@ -509,7 +509,7 @@ && getTokenMaxInactiveInterval() > 0) { isCookiePersistent(), isHttps); } KerberosToken kerberosToken = new KerberosToken(token.getUserName(), token.toString()); - SecurityUtils.getSubject().login(kerberosToken); + loginIfNecessary(SecurityUtils.getSubject(), kerberosToken); doFilter(filterChain, httpRequest, httpResponse); } } else { @@ -548,6 +548,20 @@ && getTokenMaxInactiveInterval() > 0) { } } + /** + * Preserve the Shiro session when Hadoop's signed Kerberos token identifies the subject that + * is already authenticated. Shiro 2 rotates an existing session on every successful login; + * re-login on every REST request would therefore invalidate a WebSocket using that session. + */ + void loginIfNecessary( + org.apache.shiro.subject.Subject shiroSubject, KerberosToken kerberosToken) { + if (shiroSubject.isAuthenticated() + && Objects.equals(shiroSubject.getPrincipal(), kerberosToken.getPrincipal())) { + return; + } + shiroSubject.login(kerberosToken); + } + /** * It enforces the the Kerberos SPNEGO authentication sequence returning an * {@link AuthenticationToken} only after the Kerberos SPNEGO sequence has diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/AbstractRestApi.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/AbstractRestApi.java index 67b2ab2adcf..54ba594078d 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/AbstractRestApi.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/AbstractRestApi.java @@ -18,14 +18,12 @@ package org.apache.zeppelin.rest; import java.io.IOException; -import java.util.HashSet; -import java.util.Set; import jakarta.ws.rs.WebApplicationException; import org.apache.zeppelin.service.AuthenticationService; import org.apache.zeppelin.service.ServiceContext; +import org.apache.zeppelin.service.ServiceContextFactory; import org.apache.zeppelin.service.SimpleServiceCallback; -import org.apache.zeppelin.user.AuthenticationInfo; import com.google.gson.Gson; @@ -40,12 +38,7 @@ protected AbstractRestApi(AuthenticationService authenticationService) { } protected ServiceContext getServiceContext() { - AuthenticationInfo authInfo = new AuthenticationInfo(authenticationService.getPrincipal()); - authInfo.setRoles(authenticationService.getAssociatedRoles()); - Set userAndRoles = new HashSet<>(); - userAndRoles.add(authenticationService.getPrincipal()); - userAndRoles.addAll(authenticationService.getAssociatedRoles()); - return new ServiceContext(authInfo, userAndRoles); + return ServiceContextFactory.create(authenticationService.getAuthenticatedIdentity()); } public static class RestServiceCallback extends SimpleServiceCallback { diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/LoginRestApi.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/LoginRestApi.java index d8b8c93b93e..48c72f620e4 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/LoginRestApi.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/LoginRestApi.java @@ -16,6 +16,7 @@ */ package org.apache.zeppelin.rest; +import java.io.Serializable; import java.text.ParseException; import java.util.Collection; import java.util.HashMap; @@ -39,7 +40,9 @@ import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.realm.Realm; +import org.apache.shiro.session.Session; import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.apache.zeppelin.annotation.ZeppelinApi; import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.apache.zeppelin.notebook.AuthorizationService; @@ -49,6 +52,7 @@ import org.apache.zeppelin.realm.kerberos.KerberosToken; import org.apache.zeppelin.server.JsonResponse; import org.apache.zeppelin.service.AuthenticationService; +import org.apache.zeppelin.socket.ConnectionManager; import org.apache.zeppelin.ticket.TicketContainer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,14 +68,17 @@ public class LoginRestApi extends AbstractRestApi { private final ZeppelinConfiguration zConf; private final AuthorizationService authorizationService; + private final ConnectionManager connectionManager; @Inject public LoginRestApi(ZeppelinConfiguration zConf, AuthenticationService authenticationService, - AuthorizationService authorizationService) { + AuthorizationService authorizationService, + ConnectionManager connectionManager) { super(authenticationService); this.zConf = zConf; this.authorizationService = authorizationService; + this.connectionManager = connectionManager; } @GET @@ -183,8 +190,11 @@ private JsonResponse> proceedToLogin(Subject currentUser, Au JsonResponse> response = null; try { logoutCurrentUser(); - currentUser.getSession(true); currentUser.login(token); + // Shiro rotates any pre-authentication session ID on successful login. Only ensure the + // session exists after login so the REST response and subsequent WebSocket upgrade use + // the final authenticated session rather than a fixation-prone pre-login session. + currentUser.getSession(true); Set roles = authenticationService.getAssociatedRoles(); String principal = authenticationService.getPrincipal(); @@ -212,10 +222,9 @@ private JsonResponse> proceedToLogin(Subject currentUser, Au } /** - * Post Login - * Returns userName & password - * for anonymous access, username is always anonymous. - * After getting this ticket, access through websockets become safe + * Authenticate the Shiro session and return legacy UI identity metadata. + * For anonymous access, username is always anonymous. The response ticket is not a REST or + * WebSocket authentication credential; the resulting Shiro session cookie authenticates both. * * @return 200 response */ @@ -226,17 +235,9 @@ public Response postLogin(@FormParam("userName") String userName, LOGGER.debug("userName: {}", userName); // ticket set to anonymous for anonymous user. Simplify testing. Subject currentUser = SecurityUtils.getSubject(); - if (currentUser.isAuthenticated()) { - currentUser.logout(); - } LOGGER.debug("currentUser: {}", currentUser); - JsonResponse> response = null; - if (!currentUser.isAuthenticated()) { - - UsernamePasswordToken token = new UsernamePasswordToken(userName, password); - - response = proceedToLogin(currentUser, token); - } + UsernamePasswordToken token = new UsernamePasswordToken(userName, password); + JsonResponse> response = proceedToLogin(currentUser, token); if (response == null) { response = new JsonResponse<>(Response.Status.FORBIDDEN, "", null); @@ -291,7 +292,13 @@ private String constructUrl(String providerURL, String redirectParam, private void logoutCurrentUser() { Subject currentUser = SecurityUtils.getSubject(); TicketContainer.instance.removeTicket(authenticationService.getPrincipal()); - currentUser.getSession().stop(); - currentUser.logout(); + Session session = currentUser.getSession(false); + Serializable sessionId = session == null ? null : session.getId(); + org.apache.shiro.mgt.SecurityManager securityManager = ThreadContext.getSecurityManager(); + try { + currentUser.logout(); + } finally { + connectionManager.closeConnectionsForSession(securityManager, sessionId); + } } } 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 192cd5056c5..109348623ec 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 @@ -301,10 +301,7 @@ public Response putNotePermissions(@PathParam("noteId") String noteId, String re } } - authorizationService.setReaders(noteId, readers); - authorizationService.setRunners(noteId, runners); - authorizationService.setWriters(noteId, writers); - authorizationService.setOwners(noteId, owners); + authorizationService.setPermissions(noteId, readers, runners, writers, owners); LOGGER.debug("After set permissions {} {} {} {}", authorizationService.getOwners(noteId), authorizationService.getReaders(noteId), authorizationService.getRunners(noteId), authorizationService.getWriters(noteId)); diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java index 89bc317734d..29b048702b4 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java @@ -52,10 +52,9 @@ public SecurityRestApi(AuthenticationService authenticationService) { } /** - * Get ticket - * Returns username & ticket - * for anonymous access, username is always anonymous. - * After getting this ticket, access through websockets become safe + * Return legacy UI identity metadata. + * For anonymous access, username is always anonymous. The returned ticket is retained for + * response compatibility and is not a REST or WebSocket authentication credential. * * @return 200 response */ diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstaller.java b/zeppelin-server/src/main/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstaller.java new file mode 100644 index 00000000000..168b49df491 --- /dev/null +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstaller.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.zeppelin.server; + +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter; +import java.util.EnumSet; +import jakarta.servlet.DispatcherType; + +/** Installs Jetty's default WebSocket upgrade filter after Zeppelin's authentication filter. */ +final class JettyWebSocketUpgradeFilterInstaller { + + private JettyWebSocketUpgradeFilterInstaller() { + } + + static FilterHolder installAfterAuthenticationFilter( + WebAppContext webApp, FilterHolder authenticationFilter) { + webApp.addFilter( + authenticationFilter, "/ws", EnumSet.allOf(DispatcherType.class)); + + FilterHolder filterHolder = new FilterHolder(WebSocketUpgradeFilter.class); + filterHolder.setName(WebSocketUpgradeFilter.class.getName()); + filterHolder.setAsyncSupported(true); + webApp.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST)); + return filterHolder; + } +} diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java b/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java index b3f78816aec..725b98b0b99 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java @@ -92,6 +92,7 @@ import org.apache.zeppelin.search.NoSearchService; import org.apache.zeppelin.search.SearchService; import org.apache.zeppelin.service.*; +import org.apache.zeppelin.service.AuthenticatedSessionService; import org.apache.zeppelin.service.AuthenticationService; import org.apache.zeppelin.service.auth.AuthenticationServiceFactory; import org.apache.zeppelin.socket.ConnectionManager; @@ -147,6 +148,7 @@ public ZeppelinServer(ZeppelinConfiguration zConf) throws IOException { public ZeppelinServer(ZeppelinConfiguration zConf, String serviceLocatorName) throws IOException { LOGGER.info("Instantiated ZeppelinServer"); this.zConf = zConf; + this.zConf.initializeAuthenticationMode(); if (zConf.isPrometheusMetricEnabled()) { promMetricRegistry = Optional.of(new PrometheusMeterRegistry(PrometheusConfig.DEFAULT)); } else { @@ -193,6 +195,7 @@ protected void configure() { bind(AuthenticationServiceFactory.getAuthServiceClass(zConf)) .to(AuthenticationService.class) .in(Singleton.class); + bindAsContract(AuthenticatedSessionService.class).in(Singleton.class); bindAsContract(HeliumBundleFactory.class).in(Singleton.class); bindAsContract(HeliumApplicationFactory.class).in(Singleton.class); bindAsContract(ConfigurationService.class).in(Singleton.class); @@ -563,9 +566,16 @@ private void setupRestApiContextHandler(WebAppContext webapp) { String shiroIniPath = zConf.getShiroPath(); if (!StringUtils.isBlank(shiroIniPath)) { webapp.setInitParameter("shiroConfigLocations", new File(shiroIniPath).toURI().toString()); - webapp - .addFilter(ShiroFilter.class, "/api/*", EnumSet.allOf(DispatcherType.class)) - .setInitParameter("staticSecurityManagerEnabled", "true"); + FilterHolder shiroFilter = + webapp.addFilter( + ShiroFilter.class, "/api/*", EnumSet.allOf(DispatcherType.class)); + shiroFilter.setInitParameter("staticSecurityManagerEnabled", "true"); + + // Jetty's WebSocket initializer otherwise prepends its upgrade filter and bypasses Shiro + // after a successful upgrade. Pre-register the default-named filter after Shiro so the + // initializer reuses it in this order. + JettyWebSocketUpgradeFilterInstaller.installAfterAuthenticationFilter( + webapp, shiroFilter); webapp.addEventListener(new EnvironmentLoaderListener()); } } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedIdentity.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedIdentity.java new file mode 100644 index 00000000000..3fafa3141cf --- /dev/null +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedIdentity.java @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Immutable authentication identity captured at a transport boundary. + * + *

The session identifier is an opaque handle. It must not be exposed to clients or used as an + * authentication credential by itself. + */ +public final class AuthenticatedIdentity { + + public static final String ANONYMOUS_PRINCIPAL = "anonymous"; + + private static final AuthenticatedIdentity ANONYMOUS = + new AuthenticatedIdentity(ANONYMOUS_PRINCIPAL, Collections.emptySet(), false, null); + + private final String principal; + private final Set roles; + private final boolean authenticated; + private final Serializable sessionId; + + public AuthenticatedIdentity( + String principal, Set roles, boolean authenticated, Serializable sessionId) { + this.principal = Objects.requireNonNull(principal, "principal"); + this.roles = Collections.unmodifiableSet(new HashSet<>(Objects.requireNonNull(roles, "roles"))); + this.authenticated = authenticated; + this.sessionId = sessionId; + } + + public static AuthenticatedIdentity anonymous() { + return ANONYMOUS; + } + + public String getPrincipal() { + return principal; + } + + public Set getRoles() { + return roles; + } + + public boolean isAuthenticated() { + return authenticated; + } + + public Optional getSessionId() { + return Optional.ofNullable(sessionId); + } +} diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java new file mode 100644 index 00000000000..01d341811c2 --- /dev/null +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import java.io.Serializable; +import java.time.Clock; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import jakarta.inject.Inject; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.Subject; + +/** + * Revalidates the Shiro session captured when a long-lived transport was established. + * + *

The transport retains only an opaque session handle, never a thread-bound {@link Subject}. + * This lets REST and WebSocket requests use the same Shiro session lifecycle while ensuring that + * logout and expiry are observed before a WebSocket operation is dispatched. + */ +public class AuthenticatedSessionService { + + private static final String ROLE_SNAPSHOT_SESSION_ATTRIBUTE = + AuthenticatedSessionService.class.getName() + ".roleSnapshot"; + + private final AuthenticationService authenticationService; + private final Clock clock; + + @Inject + public AuthenticatedSessionService(AuthenticationService authenticationService) { + this(authenticationService, Clock.systemUTC()); + } + + AuthenticatedSessionService(AuthenticationService authenticationService, Clock clock) { + this.authenticationService = authenticationService; + this.clock = clock; + } + + /** Validate that a transport's captured session is still authenticated without touching it. */ + public void validate( + AuthenticatedIdentity connectionIdentity, SecurityManager securityManager) { + try { + restoreValidatedSubject(connectionIdentity, securityManager); + } catch (SessionAuthenticationException e) { + throw e; + } catch (RuntimeException e) { + throw new SessionAuthenticationException("Authenticated session is no longer valid", e); + } + } + + /** + * Revalidate a transport identity and return a fresh principal/role snapshot. + * + * @param connectionIdentity identity captured at the transport handshake + * @param securityManager exact Shiro security manager that authenticated the handshake + * @param touchSession whether this operation should extend the Shiro session idle timeout + * @return a fresh server-authenticated identity + * @throws SessionAuthenticationException when the session is missing, expired or changed + */ + public AuthenticatedIdentity refresh( + AuthenticatedIdentity connectionIdentity, + SecurityManager securityManager, + boolean touchSession) { + return refresh(connectionIdentity, securityManager, touchSession, 0); + } + + /** + * Revalidate a transport identity while allowing a recent role snapshot to be reused. + * + *

The Shiro session and principal are validated on every call. Only the role set may be + * reused, which bounds role-revocation latency without delaying logout or session-expiry + * detection. A non-positive {@code maxRoleAgeMillis} disables reuse and has the same strict + * semantics as the three-argument overload. + * + * @param connectionIdentity identity captured at the transport handshake + * @param securityManager exact Shiro security manager that authenticated the handshake + * @param touchSession whether this operation should extend the Shiro session idle timeout + * @param maxRoleAgeMillis maximum age of a reusable role snapshot, in milliseconds + * @return a server-authenticated identity with current or recently refreshed roles + * @throws SessionAuthenticationException when the session is missing, expired or changed + */ + public AuthenticatedIdentity refresh( + AuthenticatedIdentity connectionIdentity, + SecurityManager securityManager, + boolean touchSession, + long maxRoleAgeMillis) { + Objects.requireNonNull(connectionIdentity, "connectionIdentity"); + + try { + Subject subject = restoreValidatedSubject(connectionIdentity, securityManager); + if (subject == null) { + return AuthenticatedIdentity.anonymous(); + } + Session session = subject.getSession(false); + if (touchSession) { + session.touch(); + } + + long roleLookupStartedAtMillis = clock.millis(); + if (maxRoleAgeMillis > 0) { + Object cached = session.getAttribute(ROLE_SNAPSHOT_SESSION_ATTRIBUTE); + if (cached instanceof RoleSnapshot) { + RoleSnapshot roleSnapshot = (RoleSnapshot) cached; + if (roleSnapshot.isReusableFor( + connectionIdentity, roleLookupStartedAtMillis, maxRoleAgeMillis)) { + return roleSnapshot.toIdentity(); + } + } + } + + AuthenticatedIdentity refreshed = + subject.execute(authenticationService::getAuthenticatedIdentity); + Serializable sessionId = connectionIdentity.getSessionId().orElseThrow( + () -> new SessionAuthenticationException("Authenticated session is unavailable")); + if (!refreshed.isAuthenticated() + || !connectionIdentity.getPrincipal().equals(refreshed.getPrincipal()) + || !refreshed.getSessionId().filter(sessionId::equals).isPresent()) { + throw new SessionAuthenticationException("Authenticated session identity changed"); + } + session.setAttribute( + ROLE_SNAPSHOT_SESSION_ATTRIBUTE, + new RoleSnapshot(refreshed, roleLookupStartedAtMillis)); + return refreshed; + } catch (SessionAuthenticationException e) { + throw e; + } catch (RuntimeException e) { + throw new SessionAuthenticationException("Authenticated session is no longer valid", e); + } + } + + private Subject restoreValidatedSubject( + AuthenticatedIdentity connectionIdentity, SecurityManager securityManager) { + Objects.requireNonNull(connectionIdentity, "connectionIdentity"); + if (!connectionIdentity.isAuthenticated() + && AuthenticatedIdentity.ANONYMOUS_PRINCIPAL.equals( + connectionIdentity.getPrincipal()) + && connectionIdentity.getSessionId().isEmpty()) { + // Reaching the endpoint means Shiro's configured `/ws` chain admitted this anonymous + // handshake, or authentication is disabled entirely. + return null; + } + + Serializable sessionId = connectionIdentity.getSessionId().orElseThrow( + () -> new SessionAuthenticationException("Authenticated session is unavailable")); + if (!connectionIdentity.isAuthenticated()) { + throw new SessionAuthenticationException("Authenticated session is unavailable"); + } + if (securityManager == null) { + throw new SessionAuthenticationException("Authentication manager is unavailable"); + } + + Subject subject = restoreSubject(sessionId, securityManager); + Session session = subject.getSession(false); + if (session == null || !subject.isAuthenticated() || !sessionId.equals(session.getId())) { + throw new SessionAuthenticationException("Authenticated session is no longer valid"); + } + String principal = subject.execute(authenticationService::getPrincipal); + if (!connectionIdentity.getPrincipal().equals(principal)) { + throw new SessionAuthenticationException("Authenticated session identity changed"); + } + return subject; + } + + Subject restoreSubject(Serializable sessionId, SecurityManager securityManager) { + return new Subject.Builder(securityManager) + .sessionId(sessionId) + .sessionCreationEnabled(false) + .buildSubject(); + } + + private static final class RoleSnapshot implements Serializable { + private static final long serialVersionUID = 1L; + + private final String principal; + private final Set roles; + private final Serializable sessionId; + private final long capturedAtMillis; + + private RoleSnapshot(AuthenticatedIdentity identity, long capturedAtMillis) { + this.principal = identity.getPrincipal(); + this.roles = Collections.unmodifiableSet(new HashSet<>(identity.getRoles())); + this.sessionId = identity.getSessionId().orElse(null); + this.capturedAtMillis = capturedAtMillis; + } + + private boolean isReusableFor( + AuthenticatedIdentity connectionIdentity, long nowMillis, long maxRoleAgeMillis) { + long ageMillis = nowMillis - capturedAtMillis; + return ageMillis >= 0 + && ageMillis < maxRoleAgeMillis + && principal.equals(connectionIdentity.getPrincipal()) + && connectionIdentity.getSessionId().filter(sessionId::equals).isPresent(); + } + + private AuthenticatedIdentity toIdentity() { + return new AuthenticatedIdentity(principal, roles, true, sessionId); + } + } +} diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticationService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticationService.java index eaab4d4e425..1822ed6f77d 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticationService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticationService.java @@ -29,6 +29,13 @@ */ public interface AuthenticationService { + /** + * Capture the current principal, roles and session handle as one identity snapshot. + * + * @return the identity bound to the current request + */ + AuthenticatedIdentity getAuthenticatedIdentity(); + /** * Get current principal/username. * @return diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ConfigurationService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ConfigurationService.java index a3b226770d6..3938e23c3c3 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ConfigurationService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ConfigurationService.java @@ -47,6 +47,13 @@ public int getWsMaxMessageSize() { return Integer.parseInt(zConf.getWebsocketMaxTextMessageSize()); } + /** Properties safe to expose to every authenticated notebook client. */ + public Map getClientProperties() { + return Map.of( + ZeppelinConfiguration.ConfVars.ZEPPELIN_WEBSOCKET_MAX_TEXT_MESSAGE_SIZE.getVarName(), + zConf.getWebsocketMaxTextMessageSize()); + } + public Map getPropertiesWithPrefix(String prefix, ServiceContext context, ServiceCallback> callback) diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/NoAuthenticationService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/NoAuthenticationService.java index d41273b3aa3..174b469fe3f 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/NoAuthenticationService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/NoAuthenticationService.java @@ -38,6 +38,11 @@ public NoAuthenticationService() { LOGGER.info("NoAuthenticationService is initialized"); } + @Override + public AuthenticatedIdentity getAuthenticatedIdentity() { + return AuthenticatedIdentity.anonymous(); + } + @Override public String getPrincipal() { return ANONYMOUS; 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 554b85f4de2..d3d6a3ca3ce 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 @@ -82,8 +82,9 @@ public class NotebookService { private static final Logger LOGGER = LoggerFactory.getLogger(NotebookService.class); + private static final String TRASH_PATH = "/" + NoteManager.TRASH_FOLDER; private static final DateTimeFormatter TRASH_CONFLICT_TIMESTAMP_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + DateTimeFormatter.ofPattern("yyyy-MM-dd HH-mm-ss").withZone(ZoneId.systemDefault()); private final ZeppelinConfiguration zConf; private final Notebook notebook; @@ -108,6 +109,10 @@ public String getHomeNote(ServiceContext context, if (StringUtils.isBlank(noteId)) { callback.onSuccess(null, context); } else { + if (!checkPermission( + noteId, Permission.READER, Message.OP.GET_HOME_NOTE, context, callback)) { + return noteId; + } notebook.processNote(noteId, note -> { if (note != null && !checkPermission(noteId, Permission.READER, Message.OP.GET_HOME_NOTE, context, @@ -133,6 +138,11 @@ public T getNote(String noteId, ServiceContext context, ServiceCallback callback, NoteProcessor noteProcessor) throws IOException { + // Authorize before processNote: a reload reads from the repository and replaces the + // shared cache entry, so it must never be triggered by a user who cannot read the note. + if (!checkPermission(noteId, Permission.READER, Message.OP.GET_NOTE, context, callback)) { + return null; + } return notebook.processNote(noteId, reload, note -> { if (note == null) { @@ -239,11 +249,17 @@ String normalizeNotePath(String notePath) throws IOException { notePath = notePath.replace("\r", " ").replace("\n", " "); - notePath = NotebookPathValidator.decodeRepeatedly(notePath); + notePath = NotebookPathValidator.decodeRepeatedly(notePath) + .replace("\r", " ").replace("\n", " "); if (notePath.endsWith("/")) { throw new IOException("Note name shouldn't end with '/'"); } + NotebookPathValidator.rejectTraversalSegments(notePath); + if (notePath.contains("//")) { + throw new IOException("Empty path segments are not allowed"); + } + int pos = notePath.lastIndexOf("/"); if ((notePath.length() - pos) > 255) { throw new IOException("Note name must be less than 255"); @@ -687,13 +703,13 @@ public void restoreNote(String noteId, return null; } - if (!note.getPath().startsWith("/" + NoteManager.TRASH_FOLDER)) { + if (!isTrashDescendant(note.getPath())) { callback.onFailure(new IOException("Can not restore this note " + note.getPath() + " as it is not in trash folder"), context); return null; } try { - String destNotePath = note.getPath().replace("/" + NoteManager.TRASH_FOLDER, ""); + String destNotePath = restoreDestination(note.getPath()); notebook.moveNote(noteId, destNotePath, context.getAutheInfo()); callback.onSuccess(note, context); } catch (IOException e) { @@ -709,17 +725,33 @@ public void restoreFolder(String folderPath, ServiceContext context, ServiceCallback callback) throws IOException { - if (!folderPath.startsWith("/" + NoteManager.TRASH_FOLDER)) { - callback.onFailure(new IOException("Can not restore this folder: " + folderPath + + String normalizedFolderPath = normalizeNotePath(folderPath); + if (!isTrashDescendant(normalizedFolderPath)) { + callback.onFailure(new IOException("Can not restore this folder: " + normalizedFolderPath + " as it is not in trash folder"), context); return; } + FolderPermissionSnapshot authorization = checkFolderPermission( + normalizedFolderPath, Permission.WRITER, Message.OP.RESTORE_FOLDER, context, callback); + if (authorization == null) { + return; + } try { - String destFolderPath = folderPath.replace("/" + NoteManager.TRASH_FOLDER, ""); - notebook.moveFolder(folderPath, destFolderPath, context.getAutheInfo()); + String destFolderPath = restoreDestination(normalizedFolderPath); + authorizationService.runWithAuthorizationVersion( + authorization.getAuthorizationVersion(), + () -> { + notebook.moveFolder( + normalizedFolderPath, + destFolderPath, + context.getAutheInfo(), + authorization.getMetadata().getVersion()); + return null; + }); callback.onSuccess(null, context); } catch (IOException e) { - callback.onFailure(new IOException("Fail to restore folder: " + folderPath, e), context); + callback.onFailure( + new IOException("Fail to restore folder: " + normalizedFolderPath, e), context); } } @@ -728,8 +760,23 @@ public void restoreFolder(String folderPath, public void restoreAll(ServiceContext context, ServiceCallback callback) throws IOException { + FolderPermissionSnapshot authorization = checkFolderPermission( + "/" + NoteManager.TRASH_FOLDER, + Permission.WRITER, + Message.OP.RESTORE_ALL, + context, + callback); + if (authorization == null) { + return; + } try { - notebook.restoreAll(context.getAutheInfo()); + authorizationService.runWithAuthorizationVersion( + authorization.getAuthorizationVersion(), + () -> { + notebook.restoreAll( + context.getAutheInfo(), authorization.getMetadata().getVersion()); + return null; + }); callback.onSuccess(null, context); } catch (IOException e) { callback.onFailure(new IOException("Fail to restore all", e), context); @@ -998,6 +1045,10 @@ public void removeNoteForms(String noteId, String formName, ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.WRITER, Message.OP.REMOVE_NOTE_FORMS, context, + callback)) { + return; + } notebook.processNote(noteId, note -> { if (note == null) { @@ -1024,13 +1075,18 @@ public NotebookRepoWithVersionControl.Revision checkpointNote( ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.WRITER, Message.OP.CHECKPOINT_NOTE, context, + callback)) { + return null; + } + NotebookRepoWithVersionControl.Revision revision = notebook.processNote(noteId, note -> { if (note == null) { callback.onFailure(new NoteNotFoundException(noteId), context); return null; } - if (!checkPermission(noteId, Permission.WRITER, Message.OP.REMOVE_NOTE_FORMS, context, + if (!checkPermission(noteId, Permission.WRITER, Message.OP.CHECKPOINT_NOTE, context, callback)) { return null; } @@ -1046,6 +1102,10 @@ public List listRevisionHistory( ServiceContext context, ServiceCallback> callback) throws IOException { + if (!checkPermission( + noteId, Permission.READER, Message.OP.LIST_REVISION_HISTORY, context, callback)) { + return null; + } List revisions = notebook.processNote(noteId, note -> { @@ -1053,16 +1113,12 @@ public List listRevisionHistory( callback.onFailure(new NoteNotFoundException(noteId), context); return null; } - // TODO(zjffdu) Disable checking permission for now, otherwise zeppelin will send 2 AUTH_INFO - // message to frontend when frontend try to get note without proper privilege. - // if (!checkPermission(noteId, Permission.READER, Message.OP.LIST_REVISION_HISTORY, context, - // callback)) { - // return null; - // } return notebook.listRevisionHistory(noteId, note.getPath(), context.getAutheInfo()); }); - callback.onSuccess(revisions, context); + if (revisions != null) { + callback.onSuccess(revisions, context); + } return revisions; } @@ -1072,6 +1128,11 @@ public void setNoteRevision(String noteId, ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.WRITER, Message.OP.SET_NOTE_REVISION, context, + callback)) { + return; + } + notebook.processNote(noteId, note -> { if (note == null) { @@ -1103,6 +1164,11 @@ public Note getNotebyRevision(String noteId, ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.READER, Message.OP.NOTE_REVISION, context, + callback)) { + return null; + } + return notebook.processNote(noteId, note -> { if (note == null) { @@ -1126,6 +1192,11 @@ public void getNoteByRevisionForCompare(String noteId, ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.READER, Message.OP.NOTE_REVISION_FOR_COMPARE, context, + callback)) { + return; + } + notebook.processNote(noteId , note -> { if (note == null) { @@ -1157,6 +1228,10 @@ public List completion( ServiceContext context, ServiceCallback> callback) throws IOException { + if (!checkPermission(noteId, Permission.WRITER, Message.OP.COMPLETION, context, callback)) { + return null; + } + return notebook.processNote(noteId, note -> { if (note == null) { @@ -1186,10 +1261,18 @@ public void getEditorSetting(String noteId, String paragraphText, ServiceContext context, ServiceCallback> callback) throws IOException { + if (!checkPermission(noteId, Permission.READER, Message.OP.EDITOR_SETTING, context, callback)) { + return; + } notebook.processNote(noteId, note -> { if (note == null) { callback.onFailure(new NoteNotFoundException(noteId), context); + return null; + } + if (!checkPermission( + noteId, Permission.READER, Message.OP.EDITOR_SETTING, context, callback)) { + return null; } try { Map settings = notebook.getInterpreterSettingManager(). @@ -1207,6 +1290,11 @@ public void updatePersonalizedMode(String noteId, ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.WRITER, Message.OP.UPDATE_PERSONALIZED_MODE, context, + callback)) { + return; + } + notebook.processNote(noteId, note -> { if (note == null) { @@ -1233,10 +1321,8 @@ public void moveNoteToTrash(String noteId, return; } - String destNotePath = "/" + NoteManager.TRASH_FOLDER + notebook.getNoteManager().getNotesInfo().get(noteId); - if (notebook.containsNote(destNotePath)) { - destNotePath = destNotePath + " " + TRASH_CONFLICT_TIMESTAMP_FORMATTER.format(Instant.now()); - } + String destNotePath = findAvailableTrashPath( + TRASH_PATH + notebook.getNoteManager().getNotesInfo().get(noteId)); final String finalDestNotePath = destNotePath; @@ -1261,25 +1347,58 @@ public void moveFolderToTrash(String folderPath, ServiceContext context, ServiceCallback callback) throws IOException { - //TODO(zjffdu) folder permission check - //TODO(zjffdu) folderPath is relative path, need to fix it in frontend - LOGGER.info("Move folder {} to trash", folderPath); - - String destFolderPath = "/" + NoteManager.TRASH_FOLDER + "/" + folderPath; - if (notebook.containsNote(destFolderPath)) { - destFolderPath = destFolderPath + " " + - TRASH_CONFLICT_TIMESTAMP_FORMATTER.format(Instant.now()); + String sourceFolderPath = normalizeNotePath(folderPath); + if (isTrashPath(sourceFolderPath)) { + callback.onFailure( + new IOException("Folder is already in the trash: " + sourceFolderPath), context); + return; } - - notebook.moveFolder("/" + folderPath, destFolderPath, context.getAutheInfo()); + FolderPermissionSnapshot authorization = checkFolderPermission( + sourceFolderPath, + Permission.OWNER, + Message.OP.MOVE_FOLDER_TO_TRASH, + context, + callback); + if (authorization == null) { + return; + } + LOGGER.info("Move folder {} to trash", sourceFolderPath); + + String destFolderPath = findAvailableTrashPath(TRASH_PATH + sourceFolderPath); + + authorizationService.runWithAuthorizationVersion( + authorization.getAuthorizationVersion(), + () -> { + notebook.moveFolder( + sourceFolderPath, + destFolderPath, + context.getAutheInfo(), + authorization.getMetadata().getVersion()); + return null; + }); callback.onSuccess(null, context); } public void emptyTrash(ServiceContext context, ServiceCallback callback) throws IOException { + FolderPermissionSnapshot authorization = checkFolderPermission( + "/" + NoteManager.TRASH_FOLDER, + Permission.OWNER, + Message.OP.EMPTY_TRASH, + context, + callback); + if (authorization == null) { + return; + } try { - notebook.emptyTrash(context.getAutheInfo()); + authorizationService.runWithAuthorizationVersion( + authorization.getAuthorizationVersion(), + () -> { + notebook.emptyTrash( + context.getAutheInfo(), authorization.getMetadata().getVersion()); + return null; + }); callback.onSuccess(null, context); } catch (IOException e) { callback.onFailure(e, context); @@ -1290,8 +1409,27 @@ public void emptyTrash(ServiceContext context, public List removeFolder(String folderPath, ServiceContext context, ServiceCallback> callback) throws IOException { + String normalizedFolderPath = normalizeNotePath(folderPath); + if (TRASH_PATH.equals(normalizedFolderPath)) { + callback.onFailure( + new IOException("Use emptyTrash to remove the trash root"), context); + return null; + } + FolderPermissionSnapshot authorization = checkFolderPermission( + normalizedFolderPath, Permission.OWNER, Message.OP.REMOVE_FOLDER, context, callback); + if (authorization == null) { + return null; + } try { - notebook.removeFolder(folderPath, context.getAutheInfo()); + authorizationService.runWithAuthorizationVersion( + authorization.getAuthorizationVersion(), + () -> { + notebook.removeFolder( + normalizedFolderPath, + context.getAutheInfo(), + authorization.getMetadata().getVersion()); + return null; + }); List notesInfo = notebook.getNotesInfo( noteId -> authorizationService.isReader(noteId, context.getUserAndRoles())); callback.onSuccess(notesInfo, context); @@ -1306,11 +1444,31 @@ public List renameFolder(String folderPath, String newFolderPath, ServiceContext context, ServiceCallback> callback) throws IOException { - //TODO(zjffdu) folder permission check + String normalizedFolderPath = normalizeNotePath(folderPath); + String normalizedNewFolderPath = normalizeNotePath(newFolderPath); + if (isTrashPath(normalizedFolderPath) || isTrashPath(normalizedNewFolderPath)) { + callback.onFailure( + new IOException("Use the dedicated trash operations for paths under " + TRASH_PATH), + context); + return null; + } + FolderPermissionSnapshot authorization = checkFolderPermission( + normalizedFolderPath, Permission.OWNER, Message.OP.FOLDER_RENAME, context, callback); + if (authorization == null) { + return null; + } try { - notebook.moveFolder(normalizeNotePath(folderPath), - normalizeNotePath(newFolderPath), context.getAutheInfo()); + authorizationService.runWithAuthorizationVersion( + authorization.getAuthorizationVersion(), + () -> { + notebook.moveFolder( + normalizedFolderPath, + normalizedNewFolderPath, + context.getAutheInfo(), + authorization.getMetadata().getVersion()); + return null; + }); List notesInfo = notebook.getNotesInfo( noteId -> authorizationService.isReader(noteId, context.getUserAndRoles())); callback.onSuccess(notesInfo, context); @@ -1344,8 +1502,8 @@ public void spell(String noteId, Map config = (Map) message.get("config"); notebook.processNote(noteId, note -> { - Paragraph p = setParagraphUsingMessage(note, message, paragraphId, - text, title, params, config); + Paragraph p = setParagraphUsingMessage(note, paragraphId, + text, title, params, config, context); p.setResult((InterpreterResult) message.get("results")); p.setErrorMessage((String) message.get("errorMessage")); p.setStatusWithoutNotification(status); @@ -1389,14 +1547,14 @@ private void addNewParagraphIfLastParagraphIsExecuted(Note note, Paragraph p) { } - private Paragraph setParagraphUsingMessage(Note note, Message fromMessage, String paragraphId, + private Paragraph setParagraphUsingMessage(Note note, String paragraphId, String text, String title, Map params, - Map config) { + Map config, + ServiceContext context) { Paragraph p = note.getParagraph(paragraphId); p.setText(text); p.setTitle(title); - AuthenticationInfo subject = - new AuthenticationInfo(fromMessage.principal, fromMessage.roles, fromMessage.ticket); + AuthenticationInfo subject = context.getAutheInfo(); p.setAuthenticationInfo(subject); p.settings.setParams(params); p.setConfig(config); @@ -1418,6 +1576,11 @@ public void updateAngularObject(String noteId, String paragraphId, String interp ServiceContext context, ServiceCallback callback) throws IOException { + if (!checkPermission(noteId, Permission.RUNNER, Message.OP.ANGULAR_OBJECT_UPDATED, context, + callback)) { + return; + } + String user = context.getAutheInfo().getUser(); AngularObject ao = null; boolean global = false; @@ -1430,6 +1593,10 @@ public void updateAngularObject(String noteId, String paragraphId, String interp return note.getBindedInterpreterSettings(new ArrayList<>(context.getUserAndRoles())); } }); + if (!checkPermission(noteId, Permission.RUNNER, Message.OP.ANGULAR_OBJECT_UPDATED, context, + callback)) { + return; + } for (InterpreterSetting setting : settings) { if (setting.getInterpreterGroup(user, noteId) == null) { continue; @@ -1536,6 +1703,11 @@ private boolean checkPermission(String noteId, Message.OP op, ServiceContext context, ServiceCallback callback) throws IOException { + // Preserve the API's not-found contract while still failing closed when a live note has + // missing authorization metadata. Callers will resolve the absent note and return 404. + if (!notebook.containsNoteById(noteId)) { + return true; + } boolean isAllowed = false; Set allowed = null; switch (permission) { @@ -1567,4 +1739,84 @@ private boolean checkPermission(String noteId, } } + private FolderPermissionSnapshot checkFolderPermission( + String folderPath, + Permission permission, + Message.OP op, + ServiceContext context, + ServiceCallback callback) throws IOException { + String normalizedFolderPath = normalizeNotePath(folderPath); + String descendantPrefix = normalizedFolderPath + "/"; + long authorizationVersion = authorizationService.getAuthorizationVersion(); + NoteManager.NoteMetadataSnapshot metadata = + notebook.getNoteManager().getNotesInfoSnapshot(); + for (Map.Entry note : metadata.getNotesInfo().entrySet()) { + String notePath = note.getValue(); + if ((normalizedFolderPath.equals(notePath) || notePath.startsWith(descendantPrefix)) + && !checkPermission(note.getKey(), permission, op, context, callback)) { + return null; + } + } + if (!authorizationService.isAuthorizationVersionCurrent(authorizationVersion)) { + callback.onFailure( + new IOException("Notebook authorization changed during folder authorization"), context); + return null; + } + return new FolderPermissionSnapshot(metadata, authorizationVersion); + } + + private static final class FolderPermissionSnapshot { + private final NoteManager.NoteMetadataSnapshot metadata; + private final long authorizationVersion; + + private FolderPermissionSnapshot( + NoteManager.NoteMetadataSnapshot metadata, long authorizationVersion) { + this.metadata = metadata; + this.authorizationVersion = authorizationVersion; + } + + private NoteManager.NoteMetadataSnapshot getMetadata() { + return metadata; + } + + private long getAuthorizationVersion() { + return authorizationVersion; + } + } + + private String findAvailableTrashPath(String desiredPath) { + if (!pathExists(desiredPath)) { + return desiredPath; + } + + String timestampedPath = desiredPath + " " + + TRASH_CONFLICT_TIMESTAMP_FORMATTER.format(Instant.now()); + String candidate = timestampedPath; + int suffix = 2; + while (pathExists(candidate)) { + candidate = timestampedPath + "-" + suffix++; + } + return candidate; + } + + private boolean pathExists(String path) { + return notebook.containsNote(path) || notebook.containsFolder(path); + } + + private static boolean isTrashPath(String path) { + return TRASH_PATH.equalsIgnoreCase(path) + || path.regionMatches(true, 0, TRASH_PATH + "/", 0, TRASH_PATH.length() + 1); + } + + private static boolean isTrashDescendant(String path) { + return path.regionMatches(true, 0, TRASH_PATH + "/", 0, TRASH_PATH.length() + 1); + } + + private static String restoreDestination(String trashPath) throws IOException { + if (!isTrashDescendant(trashPath)) { + throw new IOException("Path is not in the trash: " + trashPath); + } + return trashPath.substring(TRASH_PATH.length()); + } + } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ServiceContextFactory.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ServiceContextFactory.java new file mode 100644 index 00000000000..5a601087f9f --- /dev/null +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ServiceContextFactory.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import org.apache.zeppelin.user.AuthenticationInfo; + +/** Creates transport-neutral service contexts from server-authenticated identities. */ +public final class ServiceContextFactory { + + private ServiceContextFactory() { + } + + public static ServiceContext create(AuthenticatedIdentity identity) { + Objects.requireNonNull(identity, "identity"); + + Set roles = new HashSet<>(identity.getRoles()); + AuthenticationInfo authenticationInfo = + new AuthenticationInfo(identity.getPrincipal(), roles, null); + + Set userAndRoles = new HashSet<>(roles); + userAndRoles.add(identity.getPrincipal()); + return new ServiceContext(authenticationInfo, userAndRoles); + } +} diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/SessionAuthenticationException.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/SessionAuthenticationException.java new file mode 100644 index 00000000000..62b14f4415f --- /dev/null +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/SessionAuthenticationException.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +/** Indicates that a transport's server-authenticated session is no longer valid. */ +public class SessionAuthenticationException extends RuntimeException { + + public SessionAuthenticationException(String message) { + super(message); + } + + public SessionAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java index 21219c6e2e5..9b0a69a38b1 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java @@ -16,6 +16,7 @@ */ package org.apache.zeppelin.service; +import java.io.Serializable; import java.security.Principal; import java.sql.Connection; import java.sql.PreparedStatement; @@ -47,6 +48,7 @@ import org.apache.shiro.realm.ldap.DefaultLdapRealm; import org.apache.shiro.realm.ldap.JndiLdapContextFactory; import org.apache.shiro.realm.text.IniRealm; +import org.apache.shiro.session.Session; import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.JdbcUtils; @@ -107,6 +109,25 @@ public ShiroAuthenticationService(ZeppelinConfiguration zConf) throws Exception } } + /** + * Capture the current Shiro subject as one immutable identity snapshot. + * + * @return authenticated identity, or the anonymous identity when the subject is not authenticated + */ + @Override + public AuthenticatedIdentity getAuthenticatedIdentity() { + Subject subject = org.apache.shiro.SecurityUtils.getSubject(); + if (!subject.isAuthenticated()) { + return AuthenticatedIdentity.anonymous(); + } + + String principal = getAuthenticatedPrincipal(subject); + Set roles = getAssociatedRoles(subject, principal); + Session session = subject.getSession(false); + Serializable sessionId = session == null ? null : session.getId(); + return new AuthenticatedIdentity(principal, roles, true, sessionId); + } + /** * Return the authenticated user if any otherwise returns "anonymous". * @@ -114,20 +135,27 @@ public ShiroAuthenticationService(ZeppelinConfiguration zConf) throws Exception */ @Override public String getPrincipal() { - Subject subject = org.apache.shiro.SecurityUtils.getSubject(); + return getPrincipal(org.apache.shiro.SecurityUtils.getSubject()); + } - String principal; - if (subject.isAuthenticated()) { - principal = extractPrincipal(subject); - if (zConf.isUsernameForceLowerCase()) { - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Converting principal name {} to lower case: {}", principal, principal.toLowerCase()); - } - principal = principal.toLowerCase(); - } - } else { + private String getPrincipal(Subject subject) { + if (!subject.isAuthenticated()) { // TODO(jl): Could be better to occur error? - principal = "anonymous"; + return AuthenticatedIdentity.ANONYMOUS_PRINCIPAL; + } + return getAuthenticatedPrincipal(subject); + } + + private String getAuthenticatedPrincipal(Subject subject) { + String principal = extractPrincipal(subject); + if (zConf.isUsernameForceLowerCase()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Converting principal name {} to lower case: {}", + principal, + principal.toLowerCase()); + } + principal = principal.toLowerCase(); } return principal; } @@ -227,43 +255,58 @@ public List getMatchedRoles() { @Override public Set getAssociatedRoles() { Subject subject = org.apache.shiro.SecurityUtils.getSubject(); + if (!subject.isAuthenticated()) { + return new HashSet<>(); + } + return getAssociatedRoles(subject, getAuthenticatedPrincipal(subject)); + } + + private Set getAssociatedRoles(Subject subject, String principal) { Set roles = new HashSet<>(); Map allRoles = null; - if (subject.isAuthenticated()) { - Collection realmsList = getRealmsList(); - for (Realm realm : realmsList) { - String name = realm.getClass().getName(); - if (INI_REALM.equals(name)) { - allRoles = ((IniRealm) realm).getIni().get("roles"); - break; - } else if (LDAP_REALM.equals(name)) { - try { - AuthorizationInfo auth = - ((LdapRealm) realm) - .queryForAuthorizationInfo( - new SimplePrincipalCollection(subject.getPrincipal(), realm.getName()), - ((LdapRealm) realm).getContextFactory()); - if (auth != null) { - roles = new HashSet<>(auth.getRoles()); - } - } catch (NamingException e) { - LOGGER.error("Can't fetch roles", e); + Collection realmsList = getRealmsList(); + for (Realm realm : realmsList) { + String name = realm.getClass().getName(); + if (INI_REALM.equals(name)) { + allRoles = ((IniRealm) realm).getIni().get("roles"); + break; + } else if (LDAP_REALM.equals(name)) { + try { + AuthorizationInfo auth = + ((LdapRealm) realm) + .queryForAuthorizationInfo( + new SimplePrincipalCollection(subject.getPrincipal(), realm.getName()), + ((LdapRealm) realm).getContextFactory()); + if (auth != null) { + roles = new HashSet<>(auth.getRoles()); } - break; - } else if (ACTIVE_DIRECTORY_GROUP_REALM.equals(name)) { - allRoles = ((ActiveDirectoryGroupRealm) realm).getListRoles(); - break; - } else if (realm instanceof KnoxJwtRealm) { - roles = ((KnoxJwtRealm) realm).mapGroupPrincipals(getPrincipal()); - break; + } catch (NamingException e) { + LOGGER.error("Can't fetch roles", e); } - } - if (allRoles != null) { - for (Map.Entry pair : allRoles.entrySet()) { - if (subject.hasRole(pair.getKey())) { - roles.add(pair.getKey()); + break; + } else if (realm instanceof ActiveDirectoryGroupRealm) { + try { + AuthorizationInfo auth = + ((ActiveDirectoryGroupRealm) realm) + .queryForAuthorizationInfo( + new SimplePrincipalCollection(subject.getPrincipal(), realm.getName())); + if (auth != null) { + roles = new HashSet<>(auth.getRoles()); } + } catch (NamingException e) { + LOGGER.error("Can't fetch Active Directory roles", e); + } + break; + } else if (realm instanceof KnoxJwtRealm) { + roles = ((KnoxJwtRealm) realm).mapGroupPrincipals(principal); + break; + } + } + if (allRoles != null) { + for (Map.Entry pair : allRoles.entrySet()) { + if (subject.hasRole(pair.getKey())) { + roles.add(pair.getKey()); } } } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java index 6b13613ccec..310f179d1a5 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java @@ -25,6 +25,7 @@ import io.micrometer.core.instrument.Tags; import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.mgt.SecurityManager; import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.apache.zeppelin.display.GUI; import org.apache.zeppelin.display.Input; @@ -42,6 +43,7 @@ import jakarta.inject.Inject; import java.io.IOException; +import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.Date; @@ -55,6 +57,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; +import jakarta.websocket.CloseReason; /** * Manager class for managing websocket connections @@ -86,6 +89,7 @@ public class ConnectionManager { private final AuthorizationService authorizationService; private final ZeppelinConfiguration zConf; + private volatile NoteBroadcastHandler noteBroadcastHandler; @Inject public ConnectionManager(AuthorizationService authorizationService, ZeppelinConfiguration zConf) { @@ -97,10 +101,46 @@ public void addConnection(NotebookSocket conn) { connectedSockets.add(conn); } + /** Return a stable snapshot of every ordinary WebSocket connection. */ + public List getConnections() { + return new ArrayList<>(connectedSockets); + } + + public void setNoteBroadcastHandler(NoteBroadcastHandler noteBroadcastHandler) { + this.noteBroadcastHandler = noteBroadcastHandler; + } + public void removeConnection(NotebookSocket conn) { connectedSockets.remove(conn); } + /** Close every WebSocket associated with one exact Shiro session. */ + public int closeConnectionsForSession( + SecurityManager securityManager, Serializable sessionId) { + if (securityManager == null || sessionId == null) { + return 0; + } + + Set sessionSockets = new HashSet<>(connectedSockets); + sessionSockets.addAll(watcherSockets); + int closed = 0; + CloseReason closeReason = new CloseReason( + CloseReason.CloseCodes.VIOLATED_POLICY, "Authenticated session ended"); + for (NotebookSocket socket : sessionSockets) { + if (socket.getAuthenticationSecurityManager() == securityManager + && socket.getAuthenticatedIdentity() != null + && socket.getAuthenticatedIdentity().getSessionId().filter(sessionId::equals).isPresent()) { + try { + socket.close(closeReason); + closed++; + } catch (IOException e) { + LOGGER.debug("Failed to close WebSocket for an ended authenticated session", e); + } + } + } + return closed; + } + public void addNoteConnection(String noteId, NotebookSocket socket) { LOGGER.debug("Add connection {} to note: {}", socket, noteId); synchronized (noteSocketMap) { @@ -130,10 +170,25 @@ public void removeNoteConnection(String noteId, NotebookSocket socket) { } } + /** Return a stable snapshot of the sockets subscribed to a logical note/channel. */ + public List getNoteConnections(String noteId) { + synchronized (noteSocketMap) { + Set sockets = noteSocketMap.get(noteId); + return sockets == null ? Collections.emptyList() : new ArrayList<>(sockets); + } + } + + /** Return a stable snapshot of the sockets associated with a user. */ + public List getUserConnections(String user) { + Queue sockets = userSocketMap.get(user); + return sockets == null ? Collections.emptyList() : new ArrayList<>(sockets); + } + private void removeNoteConnection(String noteId, Set sockets, - NotebookSocket socket) { - sockets.remove(socket); - checkCollaborativeStatus(noteId, sockets); + NotebookSocket socket) { + if (sockets.remove(socket)) { + checkCollaborativeStatus(noteId, sockets); + } } public void removeConnectionFromAllNote(NotebookSocket socket) { @@ -220,7 +275,15 @@ private void checkCollaborativeStatus(String noteId, Set socketL } message.put("users", userList); } - broadcast(noteId, message); + NoteBroadcastHandler handler = noteBroadcastHandler; + if (handler != null) { + handler.broadcast(noteId, message); + } + } + + @FunctionalInterface + public interface NoteBroadcastHandler { + void broadcast(String noteId, Message message); } @@ -260,7 +323,7 @@ public void broadcast(String noteId, Message m) { } } - private void broadcastToWatchers(String noteId, String subject, Message message) { + void broadcastToWatchers(String noteId, String subject, Message message) { synchronized (watcherSockets) { for (NotebookSocket watcher : watcherSockets) { try { 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 090272ce5d9..4375a673d15 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 @@ -54,6 +54,7 @@ import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.thrift.TException; +import org.apache.shiro.mgt.SecurityManager; import org.apache.zeppelin.common.Message; import org.apache.zeppelin.common.Message.OP; import org.apache.zeppelin.conf.ZeppelinConfiguration; @@ -86,13 +87,16 @@ import org.apache.zeppelin.rest.exception.ForbiddenException; import org.apache.zeppelin.scheduler.Job; import org.apache.zeppelin.scheduler.Job.Status; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticatedSessionService; import org.apache.zeppelin.service.ConfigurationService; import org.apache.zeppelin.service.JobManagerService; import org.apache.zeppelin.service.NotebookService; import org.apache.zeppelin.service.ServiceContext; +import org.apache.zeppelin.service.ServiceContextFactory; +import org.apache.zeppelin.service.SessionAuthenticationException; import org.apache.zeppelin.service.SimpleServiceCallback; import org.apache.zeppelin.service.exception.JobManagerForbiddenException; -import org.apache.zeppelin.ticket.TicketContainer; import org.apache.zeppelin.types.InterpreterSettingsList; import org.apache.zeppelin.user.AuthenticationInfo; import org.apache.zeppelin.util.IdHashes; @@ -106,7 +110,6 @@ import org.slf4j.LoggerFactory; import static org.apache.zeppelin.common.Message.MSG_ID_NOT_DEFINED; -import static org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars.ZEPPELIN_ALLOWED_ORIGINS; /** * Zeppelin websocket service. This class used setter injection because all servlet should have @@ -156,6 +159,7 @@ String getKey() { private AuthorizationService authorizationService; private Provider configurationServiceProvider; private Provider jobManagerServiceProvider; + private AuthenticatedSessionService authenticatedSessionService; public NotebookServer() { NotebookServer.self.set(this); @@ -201,6 +205,13 @@ public void setAuthorizationService(AuthorizationService authorizationService) { @Inject public void setConnectionManager(ConnectionManager connectionManager) { this.connectionManager = connectionManager; + this.connectionManager.setNoteBroadcastHandler(this::broadcastToAuthorizedNoteSubscribers); + } + + @Inject + public void setAuthenticatedSessionService( + AuthenticatedSessionService authenticatedSessionService) { + this.authenticatedSessionService = authenticatedSessionService; } public ConnectionManager getConnectionManager() { @@ -251,66 +262,86 @@ public void onOpen(Session session, EndpointConfig endpointConfig) throws IOExce LOGGER.info("Open connection to {} with Session: {}, config: {}", ServerUtils.getRemoteAddress(session), session, endpointConfig.getUserProperties().keySet()); Map headers = endpointConfig.getUserProperties(); - String origin = String.valueOf(headers.get(CorsUtils.HEADER_ORIGIN)); - if (checkOrigin(origin)) { + Object capturedIdentity = headers.get(SessionConfigurator.AUTHENTICATED_IDENTITY); + Object capturedSecurityManager = + headers.get(SessionConfigurator.AUTHENTICATION_SECURITY_MANAGER); + if (!(capturedIdentity instanceof AuthenticatedIdentity)) { + session.close(authenticationFailureCloseReason()); + return; + } + + try { + AuthenticatedIdentity identity = authenticatedSessionService.refresh( + (AuthenticatedIdentity) capturedIdentity, + capturedSecurityManager instanceof SecurityManager + ? (SecurityManager) capturedSecurityManager : null, + false); NotebookSocket notebookSocket = sessionIdNotebookSocketMap - .computeIfAbsent(session.getId(), unused -> new NotebookSocket(session, headers)); + .computeIfAbsent( + session.getId(), unused -> new NotebookSocket( + session, + headers, + identity, + capturedSecurityManager instanceof SecurityManager + ? (SecurityManager) capturedSecurityManager : null, + authenticatedSessionService)); onOpen(notebookSocket); - } else { - LOGGER.error("Websocket request is not allowed by {} settings. Origin: {}", ZEPPELIN_ALLOWED_ORIGINS, - origin); - session.close(); + // Register first, then validate once more. A concurrent logout either sees the registered + // socket or invalidates the session before this second check, closing the race in between. + authenticatedSessionService.validate( + identity, notebookSocket.getAuthenticationSecurityManager()); + } catch (SessionAuthenticationException e) { + LOGGER.info("Rejecting WebSocket because its authenticated session is invalid"); + session.close(authenticationFailureCloseReason()); } } public void onOpen(NotebookSocket conn) { connectionManager.addConnection(conn); + AuthenticatedIdentity identity = conn.getAuthenticatedIdentity(); + if (identity != null && StringUtils.isEmpty(conn.getUser())) { + connectionManager.addUserConnection(identity.getPrincipal(), conn); + } } @OnMessage public void onMessage(Session session, String msg) { NotebookSocket conn = sessionIdNotebookSocketMap.get(session.getId()); - onMessage(conn, msg); + if (conn != null) { + onMessage(conn, msg); + } } public void onMessage(NotebookSocket conn, String msg) { try { Message receivedMessage = deserializeMessage(msg); if (receivedMessage.op != OP.PING) { - LOGGER.debug("RECEIVE: " + receivedMessage.op + - ", RECEIVE PRINCIPAL: " + receivedMessage.principal + - ", RECEIVE ROLES: " + receivedMessage.roles + - ", RECEIVE DATA: " + receivedMessage.data); + LOGGER.debug("RECEIVE: " + receivedMessage.op + ", RECEIVE DATA: " + + receivedMessage.data); } if (LOGGER.isTraceEnabled()) { LOGGER.trace("RECEIVE MSG = " + receivedMessage); } - TicketContainer.Entry ticketEntry = TicketContainer.instance.getTicketEntry(receivedMessage.principal); - if (ticketEntry == null || StringUtils.isEmpty(ticketEntry.getTicket())) { - LOGGER.debug("{} message: no ticket on file for principal {}", - receivedMessage.op, receivedMessage.principal); - return; - } else if (!ticketEntry.getTicket().equals(receivedMessage.ticket)) { - /* not to pollute logs, log instead of exception */ - LOGGER.debug("{} message: ticket mismatch for principal {}", - receivedMessage.op, receivedMessage.principal); - if (!receivedMessage.op.equals(OP.PING)) { - conn.send(serializeMessage(new Message(OP.SESSION_LOGOUT).put("info", - "Your ticket is invalid possibly due to server restart. Please login again."))); - } - - return; - } - - boolean allowAnonymous = zConf.isAnonymousAllowed(); - if (!allowAnonymous && receivedMessage.principal.equals("anonymous")) { - LOGGER.warn("Anonymous access not allowed."); - return; + AuthenticatedIdentity identity; + if (receivedMessage.op == OP.PING) { + // PING must detect logout/expiry, but it must neither extend idle timeout nor repeat + // potentially remote realm/LDAP role lookups every ten seconds. + authenticatedSessionService.validate( + conn.getAuthenticatedIdentity(), conn.getAuthenticationSecurityManager()); + identity = conn.getAuthenticatedIdentity(); + } else { + identity = authenticatedSessionService.refresh( + conn.getAuthenticatedIdentity(), conn.getAuthenticationSecurityManager(), true); } + ServiceContext context = ServiceContextFactory.create(identity); if (Message.isDisabledForRunningNotes(receivedMessage.op)) { - boolean noteRunning = getNotebook().processNote((String) receivedMessage.get("noteId"), + String noteId = (String) receivedMessage.get("noteId"); + if (!authorizationService.isReader(noteId, context.getUserAndRoles())) { + throw new ForbiddenException("Insufficient privileges to read note"); + } + boolean noteRunning = getNotebook().processNote(noteId, note -> note != null && note.isRunning()); if (noteRunning) { throw new Exception("Note is now running sequentially. Can not be performed: " + receivedMessage.op); @@ -318,9 +349,8 @@ public void onMessage(NotebookSocket conn, String msg) { } if (StringUtils.isEmpty(conn.getUser())) { - connectionManager.addUserConnection(receivedMessage.principal, conn); + connectionManager.addUserConnection(identity.getPrincipal(), conn); } - ServiceContext context = getServiceContext(ticketEntry); // Lets be elegant here switch (receivedMessage.op) { case LIST_NOTES: @@ -372,7 +402,7 @@ public void onMessage(NotebookSocket conn, String msg) { importNote(conn, context, receivedMessage); break; case CONVERT_NOTE_NBFORMAT: - convertNote(conn, receivedMessage); + convertNote(conn, context, receivedMessage); break; case COMMIT_PARAGRAPH: updateParagraph(conn, context, receivedMessage); @@ -428,13 +458,13 @@ public void onMessage(NotebookSocket conn, String msg) { angularObjectUpdated(conn, context, receivedMessage); break; case ANGULAR_OBJECT_CLIENT_BIND: - angularObjectClientBind(conn, receivedMessage); + angularObjectClientBind(conn, context, receivedMessage); break; case ANGULAR_OBJECT_CLIENT_UNBIND: - angularObjectClientUnbind(conn, receivedMessage); + angularObjectClientUnbind(conn, context, receivedMessage); break; case LIST_CONFIGURATIONS: - sendAllConfigurations(conn, context, receivedMessage); + sendClientConfigurations(conn); break; case CHECKPOINT_NOTE: checkpointNote(conn, context, receivedMessage); @@ -484,6 +514,13 @@ public void onMessage(NotebookSocket conn, String msg) { default: break; } + } catch (SessionAuthenticationException e) { + LOGGER.info("Closing WebSocket because its authenticated session is invalid"); + try { + conn.close(authenticationFailureCloseReason()); + } catch (IOException iox) { + LOGGER.debug("Failed to close WebSocket with an invalid authenticated session", iox); + } } catch (Exception e) { LOGGER.error("Can't handle message: {}", msg, e); try { @@ -494,6 +531,11 @@ public void onMessage(NotebookSocket conn, String msg) { } } + private static CloseReason authenticationFailureCloseReason() { + return new CloseReason( + CloseReason.CloseCodes.VIOLATED_POLICY, "Authenticated session is no longer valid"); + } + @OnClose public void onClose(Session session, CloseReason closeReason) { NotebookSocket notebookSocket = sessionIdNotebookSocketMap.remove(session.getId()); @@ -575,30 +617,116 @@ public void onFailure(Exception ex, ServiceContext context) throws IOException { }); } - public void broadcastUpdateNoteJobInfo(Note note, long lastUpdateUnixTime) throws IOException { - ServiceContext context = new ServiceContext(new AuthenticationInfo(), authorizationService.getOwners(note.getId())); - getJobManagerService().getNoteJobInfoByUnixTime(lastUpdateUnixTime, context, - new WebSocketServiceCallback>(null) { - @Override - public void onSuccess(List notesJobInfo, - ServiceContext context) throws IOException { - super.onSuccess(notesJobInfo, context); - Map response = new HashMap<>(); - response.put("lastResponseUnixTime", System.currentTimeMillis()); - response.put("jobs", notesJobInfo); - connectionManager.broadcast(JobManagerServiceType.JOB_MANAGER_PAGE.getKey(), - new Message(OP.LIST_UPDATE_NOTE_JOBS).put("noteRunningJobs", response)); - } + public void broadcastUpdateNoteJobInfo(Note note) throws IOException { + ServiceContext context = new ServiceContext( + new AuthenticationInfo(), authorizationService.getOwners(note.getId())); + getJobManagerService().getNoteJobInfo( + note.getId(), context, new JobManagerServiceCallback(note)); + } - @Override - public void onFailure(Exception ex, ServiceContext context) throws IOException { - if (ex instanceof JobManagerForbiddenException) { - LOGGER.debug(ex.getMessage()); - } else { - LOGGER.warn(ex.getMessage()); - } - } - }); + void broadcastToAuthorizedNoteSubscribers(String noteId, Message message) { + connectionManager.broadcastToWatchers(noteId, StringUtils.EMPTY, message); + for (NotebookSocket connection : connectionManager.getNoteConnections(noteId)) { + if (isAuthorizedNoteSubscriber(noteId, connection)) { + try { + connection.send(serializeMessage(message)); + } catch (IOException | RuntimeException e) { + LOGGER.error("Cannot send note update to authorized subscriber", e); + } + } + } + } + + private void broadcastToAuthorizedNoteSubscribersExcept( + String noteId, Message message, NotebookSocket excluded) { + connectionManager.broadcastToWatchers(noteId, StringUtils.EMPTY, message); + for (NotebookSocket connection : connectionManager.getNoteConnections(noteId)) { + if (!connection.equals(excluded) && isAuthorizedNoteSubscriber(noteId, connection)) { + try { + connection.send(serializeMessage(message)); + } catch (IOException | RuntimeException e) { + LOGGER.error("Cannot send note update to authorized subscriber", e); + } + } + } + } + + private void multicastToAuthorizedUser( + String noteId, String user, Message message) { + for (NotebookSocket connection : connectionManager.getUserConnections(user)) { + if (isAuthorizedNoteSubscriber(noteId, connection)) { + try { + connection.send(serializeMessage(message)); + } catch (IOException | RuntimeException e) { + LOGGER.error("Cannot send personalized note update to authorized subscriber", e); + } + } + } + } + + private boolean isAuthorizedNoteSubscriber(String noteId, NotebookSocket connection) { + AuthenticatedIdentity identity = connection.getAuthenticatedIdentity(); + if (identity == null) { + connectionManager.removeNoteConnection(noteId, connection); + return false; + } + try { + AuthenticatedIdentity refreshedIdentity = authenticatedSessionService.refresh( + identity, + connection.getAuthenticationSecurityManager(), + false, + zConf.getWebsocketAuthorizationRolesRefreshIntervalMs()); + Set userAndRoles = new HashSet<>(refreshedIdentity.getRoles()); + userAndRoles.add(refreshedIdentity.getPrincipal()); + if (authorizationService.isReader(noteId, userAndRoles)) { + return true; + } + connectionManager.removeNoteConnection(noteId, connection); + return false; + } catch (SessionAuthenticationException e) { + connectionManager.removeNoteConnection(noteId, connection); + try { + connection.close(authenticationFailureCloseReason()); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + LOGGER.info("Closed invalid note subscriber", e); + return false; + } + } + + void broadcastJobUpdateToAuthorizedSubscribers(Note note, Message message) { + for (NotebookSocket connection : connectionManager.getNoteConnections( + JobManagerServiceType.JOB_MANAGER_PAGE.getKey())) { + AuthenticatedIdentity identity = connection.getAuthenticatedIdentity(); + if (identity == null) { + continue; + } + try { + AuthenticatedIdentity refreshedIdentity = authenticatedSessionService.refresh( + identity, + connection.getAuthenticationSecurityManager(), + false, + zConf.getWebsocketAuthorizationRolesRefreshIntervalMs()); + Set userAndRoles = new HashSet<>(refreshedIdentity.getRoles()); + userAndRoles.add(refreshedIdentity.getPrincipal()); + if (!authorizationService.isOwner(userAndRoles, note.getId())) { + continue; + } + connection.send(serializeMessage(message)); + } catch (SessionAuthenticationException e) { + connectionManager.removeNoteConnection( + JobManagerServiceType.JOB_MANAGER_PAGE.getKey(), connection); + try { + connection.close(authenticationFailureCloseReason()); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + LOGGER.info("Closed invalid Job Manager subscriber", e); + } catch (IOException | RuntimeException e) { + LOGGER.error("Cannot send job update to authorized subscriber", e); + } + } } public void unsubscribeNoteJobInfo(NotebookSocket conn) { @@ -610,6 +738,7 @@ public void getInterpreterBindings(NotebookSocket conn, Message fromMessage) throws IOException { List settingList = new ArrayList<>(); String noteId = (String) fromMessage.data.get("noteId"); + requireReader(noteId, context); getNotebook().processNote(noteId, note -> { @@ -630,6 +759,7 @@ public void saveInterpreterBindings(NotebookSocket conn, ServiceContext context, throws IOException { List settingList = new ArrayList<>(); String noteId = (String) fromMessage.data.get("noteId"); + requireWriter(noteId, context); // use write lock, because defaultInterpreterGroup is overwritten getNotebook().processNote(noteId, note -> { @@ -661,7 +791,7 @@ public void broadcastNote(Note note) { private void inlineBroadcastNote(Note note) { Message message = new Message(OP.NOTE).put("note", note); - connectionManager.broadcast(note.getId(), message); + broadcastToAuthorizedNoteSubscribers(note.getId(), message); } private void inlineBroadcastParagraph(Note note, Paragraph p, String msgId) { @@ -671,7 +801,7 @@ private void inlineBroadcastParagraph(Note note, Paragraph p, String msgId) { broadcastParagraphs(p.getUserParagraphMap(), p, msgId); } else { Message message = new Message(OP.PARAGRAPH).withMsgId(msgId).put("paragraph", p); - connectionManager.broadcast(note.getId(), message); + broadcastToAuthorizedNoteSubscribers(note.getId(), message); } } @@ -679,17 +809,18 @@ public void broadcastParagraph(Note note, Paragraph p, String msgId) { inlineBroadcastParagraph(note, p, msgId); } - private void inlineBroadcastParagraphs(Map userParagraphMap, String msgId) { + private void inlineBroadcastParagraphs( + String noteId, Map userParagraphMap, String msgId) { if (null != userParagraphMap) { for (String user : userParagraphMap.keySet()) { Message message = new Message(OP.PARAGRAPH).withMsgId(msgId).put("paragraph", userParagraphMap.get(user)); - connectionManager.multicastToUser(user, message); + multicastToAuthorizedUser(noteId, user, message); } } } private void broadcastParagraphs(Map userParagraphMap, Paragraph defaultParagraph, String msgId) { - inlineBroadcastParagraphs(userParagraphMap, msgId); + inlineBroadcastParagraphs(defaultParagraph.getNote().getId(), userParagraphMap, msgId); } private void inlineBroadcastNewParagraph(Note note, Paragraph para, String msgId) { @@ -698,7 +829,7 @@ private void inlineBroadcastNewParagraph(Note note, Paragraph para, String msgId Message message = new Message(OP.PARAGRAPH_ADDED).withMsgId(msgId).put("paragraph", para).put("index", paraIndex); - connectionManager.broadcast(note.getId(), message); + broadcastToAuthorizedNoteSubscribers(note.getId(), message); } private void broadcastNewParagraph(Note note, Paragraph para, String msgId) { @@ -710,13 +841,32 @@ private void inlineBroadcastNoteList() { } public void broadcastNoteListUpdate() { - connectionManager.forAllUsers((user, userAndRoles) -> { - List notesInfo = getNotebook().getNotesInfo( - noteId -> authorizationService.isReader(noteId, userAndRoles)); - - connectionManager.multicastToUser(user, - new Message(OP.NOTES_INFO).put("notes", notesInfo)); - }); + for (NotebookSocket connection : connectionManager.getConnections()) { + AuthenticatedIdentity identity = connection.getAuthenticatedIdentity(); + if (identity == null) { + continue; + } + try { + AuthenticatedIdentity refreshedIdentity = authenticatedSessionService.refresh( + identity, + connection.getAuthenticationSecurityManager(), + false, + zConf.getWebsocketAuthorizationRolesRefreshIntervalMs()); + Set userAndRoles = new HashSet<>(refreshedIdentity.getRoles()); + userAndRoles.add(refreshedIdentity.getPrincipal()); + List notesInfo = getNotebook().getNotesInfo( + noteId -> authorizationService.isReader(noteId, userAndRoles)); + connectionManager.unicast( + new Message(OP.NOTES_INFO).put("notes", notesInfo), connection); + } catch (SessionAuthenticationException e) { + try { + connection.close(authenticationFailureCloseReason()); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + LOGGER.info("Closed invalid note-list subscriber", e); + } + } } public void broadcastNoteList(AuthenticationInfo subject, Set userAndRoles) { @@ -736,10 +886,24 @@ public void onSuccess(List notesInfo, ServiceContext context) throws I public void broadcastReloadedNoteList(ServiceContext context) throws IOException { + requireGlobalNotebookAdministration(context, OP.RELOAD_NOTES_FROM_REPO); getNotebook().reloadAllNotes(context.getAutheInfo()); broadcastNoteListUpdate(); } + private void requireGlobalNotebookAdministration(ServiceContext context, OP operation) { + if (zConf.isAnonymousAllowed()) { + return; + } + String administratorRole = zConf.getString( + ZeppelinConfiguration.ConfVars.ZEPPELIN_OWNER_ROLE); + if (StringUtils.isBlank(administratorRole) + || !context.getUserAndRoles().contains(administratorRole)) { + throw new ForbiddenException( + "Administrator role is required for " + operation); + } + } + void permissionError(NotebookSocket conn, String op, String userName, Set userAndRoles, Set allowed) throws IOException { LOGGER.info("Cannot {}. Connection readers {}. Allowed readers {}", op, userAndRoles, allowed); @@ -868,7 +1032,7 @@ private void updateNote(NotebookSocket conn, ServiceContext context, Message fro new WebSocketServiceCallback(conn) { @Override public void onSuccess(Note note, ServiceContext context) throws IOException { - connectionManager.broadcast(note.getId(), new Message(OP.NOTE_UPDATED).put("name", name) + broadcastToAuthorizedNoteSubscribers(note.getId(), new Message(OP.NOTE_UPDATED).put("name", name) .put("config", config) .put("info", note.getInfo())); broadcastNoteList(context.getAutheInfo(), context.getUserAndRoles()); @@ -888,7 +1052,7 @@ private void updatePersonalizedMode(NotebookSocket conn, ServiceContext context, public void onSuccess(Note note, ServiceContext context) throws IOException { super.onSuccess(note, context); - connectionManager.broadcastNote(note); + broadcastNote(note); } }); } @@ -1131,7 +1295,7 @@ public void onSuccess(String result, ServiceContext context) throws IOException Message message = new Message(OP.PATCH_PARAGRAPH) .put("patch", result) .put("paragraphId", paragraphId); - connectionManager.broadcastExcept(noteId2, message, conn); + broadcastToAuthorizedNoteSubscribersExcept(noteId2, message, conn); } }); } @@ -1168,8 +1332,10 @@ public void onSuccess(Note note, ServiceContext context) throws IOException { }); } - protected void convertNote(NotebookSocket conn, Message fromMessage) throws IOException { + protected void convertNote( + NotebookSocket conn, ServiceContext context, Message fromMessage) throws IOException { String noteId = fromMessage.get("noteId").toString(); + requireReader(noteId, context); getNotebook().processNote(noteId, note -> { if (note == null) { @@ -1221,7 +1387,8 @@ private void removeParagraph(NotebookSocket conn, @Override public void onSuccess(Paragraph p, ServiceContext context) throws IOException { super.onSuccess(p, context); - connectionManager.broadcast(p.getNote().getId(), new Message(OP.PARAGRAPH_REMOVED).put("id", p.getId())); + broadcastToAuthorizedNoteSubscribers( + p.getNote().getId(), new Message(OP.PARAGRAPH_REMOVED).put("id", p.getId())); } }); } @@ -1237,7 +1404,10 @@ private void clearParagraphOutput(NotebookSocket conn, public void onSuccess(Paragraph p, ServiceContext context) throws IOException { super.onSuccess(p, context); if (p.getNote().isPersonalizedMode()) { - connectionManager.unicastParagraph(p.getNote(), p, context.getAutheInfo().getUser(), fromMessage.msgId); + multicastToAuthorizedUser( + p.getNote().getId(), + context.getAutheInfo().getUser(), + new Message(OP.PARAGRAPH).withMsgId(fromMessage.msgId).put("paragraph", p)); } else { broadcastParagraph(p.getNote(), p, fromMessage.msgId); } @@ -1287,7 +1457,7 @@ private void angularObjectUpdated(NotebookSocket conn, String interpreterGroupId = (String) fromMessage.get("interpreterGroupId"); String varName = (String) fromMessage.get("name"); Object varValue = fromMessage.get("value"); - String user = fromMessage.principal; + requireRunner(noteId, context); getNotebookService().updateAngularObject(noteId, paragraphId, interpreterGroupId, varName, varValue, context, @@ -1295,7 +1465,7 @@ private void angularObjectUpdated(NotebookSocket conn, @Override public void onSuccess(AngularObject ao, ServiceContext context) throws IOException { super.onSuccess(ao, context); - connectionManager.broadcastExcept(noteId, + broadcastToAuthorizedNoteSubscribersExcept(noteId, new Message( OP.ANGULAR_OBJECT_UPDATE).put("angularObject", ao) .put("interpreterGroupId", interpreterGroupId) @@ -1316,11 +1486,13 @@ public void onSuccess(AngularObject ao, ServiceContext context) throws IOExcepti * registry given a noteId and a paragraph id. * 2. Save AngularObject to note. */ - protected void angularObjectClientBind(NotebookSocket conn, Message fromMessage) throws Exception { + protected void angularObjectClientBind( + NotebookSocket conn, ServiceContext context, Message fromMessage) throws Exception { String noteId = fromMessage.getType("noteId"); String varName = fromMessage.getType("name"); Object varValue = fromMessage.get("value"); String paragraphId = fromMessage.getType("paragraphId"); + requireRunner(noteId, context); if (paragraphId == null) { throw new IllegalArgumentException( "target paragraph not specified for " + "angular value bind"); @@ -1351,10 +1523,12 @@ protected void angularObjectClientBind(NotebookSocket conn, Message fromMessage) * registry given a noteId and an optional list of paragraph id(s). * 2. Delete AngularObject from note. */ - protected void angularObjectClientUnbind(NotebookSocket conn, Message fromMessage) throws Exception { + protected void angularObjectClientUnbind( + NotebookSocket conn, ServiceContext context, Message fromMessage) throws Exception { String noteId = fromMessage.getType("noteId"); String varName = fromMessage.getType("name"); String paragraphId = fromMessage.getType("paragraphId"); + requireRunner(noteId, context); if (paragraphId == null) { throw new IllegalArgumentException( "target paragraph not specified for " + "angular value unBind"); @@ -1388,6 +1562,24 @@ private InterpreterGroup findInterpreterGroupForParagraph(Note note, String para return paragraph.getBindedInterpreter().getInterpreterGroup(); } + private void requireReader(String noteId, ServiceContext context) { + if (!authorizationService.isReader(noteId, context.getUserAndRoles())) { + throw new ForbiddenException("Insufficient privileges to read note " + noteId); + } + } + + private void requireWriter(String noteId, ServiceContext context) { + if (!authorizationService.isWriter(noteId, context.getUserAndRoles())) { + throw new ForbiddenException("Insufficient privileges to write note " + noteId); + } + } + + private void requireRunner(String noteId, ServiceContext context) { + if (!authorizationService.isRunner(noteId, context.getUserAndRoles())) { + throw new ForbiddenException("Insufficient privileges to run note " + noteId); + } + } + private AngularObject pushAngularObjectToRemoteRegistry(String noteId, String paragraphId, String varName, Object varValue, RemoteAngularObjectRegistry remoteRegistry, @@ -1395,7 +1587,7 @@ private AngularObject pushAngularObjectToRemoteRegistry(String noteId, String pa NotebookSocket conn) { final AngularObject ao = remoteRegistry.addAndNotifyRemoteProcess(varName, varValue, noteId, paragraphId); - connectionManager.broadcastExcept(noteId, new Message(OP.ANGULAR_OBJECT_UPDATE) + broadcastToAuthorizedNoteSubscribersExcept(noteId, new Message(OP.ANGULAR_OBJECT_UPDATE) .put("angularObject", ao) .put("interpreterGroupId", interpreterGroupId).put("noteId", noteId) .put("paragraphId", paragraphId), conn); @@ -1408,7 +1600,7 @@ private AngularObject removeAngularFromRemoteRegistry(String noteId, String para String interpreterGroupId, NotebookSocket conn) { final AngularObject ao = remoteRegistry.removeAndNotifyRemoteProcess(varName, noteId, paragraphId); - connectionManager.broadcastExcept(noteId, new Message(OP.ANGULAR_OBJECT_REMOVE) + broadcastToAuthorizedNoteSubscribersExcept(noteId, new Message(OP.ANGULAR_OBJECT_REMOVE) .put("angularObject", ao) .put("interpreterGroupId", interpreterGroupId).put("noteId", noteId) .put("paragraphId", paragraphId), conn); @@ -1427,7 +1619,7 @@ private void moveParagraph(NotebookSocket conn, @Override public void onSuccess(Paragraph result, ServiceContext context) throws IOException { super.onSuccess(result, context); - connectionManager.broadcast(result.getNote().getId(), + broadcastToAuthorizedNoteSubscribers(result.getNote().getId(), new Message(OP.PARAGRAPH_MOVED) .put("id", paragraphId) .put("index", newIndex)); @@ -1520,7 +1712,7 @@ private void broadcastSpellExecution(NotebookSocket conn, public void onSuccess(Paragraph p, ServiceContext context) throws IOException { super.onSuccess(p, context); // broadcast to other clients only - connectionManager.broadcastExcept(p.getNote().getId(), + broadcastToAuthorizedNoteSubscribersExcept(p.getNote().getId(), new Message(OP.RUN_PARAGRAPH_USING_SPELL).put("paragraph", p), conn); } }); @@ -1547,7 +1739,11 @@ public void onSuccess(Paragraph p, ServiceContext context) if (p.getNote().isPersonalizedMode()) { Paragraph p2 = p.getNote().clearPersonalizedParagraphOutput(paragraphId, context.getAutheInfo().getUser()); - connectionManager.unicastParagraph(p.getNote(), p2, context.getAutheInfo().getUser(), fromMessage.msgId); + multicastToAuthorizedUser( + p.getNote().getId(), + context.getAutheInfo().getUser(), + new Message(OP.PARAGRAPH).withMsgId(fromMessage.msgId) + .put("paragraph", p2)); } // if it's the last paragraph and not empty, let's add a new one @@ -1565,18 +1761,9 @@ public void onSuccess(Paragraph p, ServiceContext context) } - private void sendAllConfigurations(NotebookSocket conn, - ServiceContext context, - Message message) throws IOException { - - getConfigurationService().getAllProperties(context, - new WebSocketServiceCallback>(conn) { - @Override - public void onSuccess(Map properties, ServiceContext context) throws IOException { - super.onSuccess(properties, context); - conn.send(serializeMessage(new Message(OP.CONFIGURATIONS_INFO).put("configurations", properties))); - } - }); + private void sendClientConfigurations(NotebookSocket conn) throws IOException { + conn.send(serializeMessage(new Message(OP.CONFIGURATIONS_INFO) + .put("configurations", getConfigurationService().getClientProperties()))); } private void checkpointNote(NotebookSocket conn, @@ -1692,7 +1879,7 @@ public void onOutputAppend(String noteId, String paragraphId, int index, String .put("paragraphId", paragraphId) .put("index", index) .put("data", output); - connectionManager.broadcast(noteId, msg); + broadcastToAuthorizedNoteSubscribers(noteId, msg); } /** @@ -1724,10 +1911,10 @@ public void onOutputUpdated(String noteId, String paragraphId, int index, if (note.isPersonalizedMode()) { String user = note.getParagraph(paragraphId).getUser(); if (null != user) { - connectionManager.multicastToUser(user, msg); + multicastToAuthorizedUser(noteId, user, msg); } } else { - connectionManager.broadcast(noteId, msg); + broadcastToAuthorizedNoteSubscribers(noteId, msg); } return null; }); @@ -1773,7 +1960,7 @@ public void onOutputAppend(String noteId, String paragraphId, int index, String Message msg = new Message(OP.APP_APPEND_OUTPUT).put("noteId", noteId).put("paragraphId", paragraphId) .put("index", index).put("appId", appId).put("data", output); - connectionManager.broadcast(noteId, msg); + broadcastToAuthorizedNoteSubscribers(noteId, msg); } /** @@ -1790,14 +1977,14 @@ public void onOutputUpdated(String noteId, String paragraphId, int index, String .put("type", type) .put("appId", appId) .put("data", output); - connectionManager.broadcast(noteId, msg); + broadcastToAuthorizedNoteSubscribers(noteId, msg); } @Override public void onLoad(String noteId, String paragraphId, String appId, HeliumPackage pkg) { Message msg = new Message(OP.APP_LOAD).put("noteId", noteId).put("paragraphId", paragraphId) .put("appId", appId).put("pkg", pkg); - connectionManager.broadcast(noteId, msg); + broadcastToAuthorizedNoteSubscribers(noteId, msg); } @Override @@ -1805,7 +1992,7 @@ public void onStatusChange(String noteId, String paragraphId, String appId, Stri Message msg = new Message(OP.APP_STATUS_CHANGE).put("noteId", noteId).put("paragraphId", paragraphId) .put("appId", appId).put("status", status); - connectionManager.broadcast(noteId, msg); + broadcastToAuthorizedNoteSubscribers(noteId, msg); } @Override @@ -1866,10 +2053,7 @@ public void run() { @Override public void onParagraphRemove(Paragraph p) { try { - ServiceContext context = - new ServiceContext(new AuthenticationInfo(), authorizationService.getOwners(p.getNote().getId())); - getJobManagerService().getNoteJobInfoByUnixTime(System.currentTimeMillis() - 5000, context, - new JobManagerServiceCallback()); + broadcastUpdateNoteJobInfo(p.getNote()); } catch (IOException e) { LOGGER.warn("can not broadcast for job manager: {}", e.getMessage(), e); } @@ -1877,15 +2061,9 @@ public void onParagraphRemove(Paragraph p) { @Override public void onNoteRemove(Note note, AuthenticationInfo subject) { - try { - broadcastUpdateNoteJobInfo(note, System.currentTimeMillis() - 5000); - } catch (IOException e) { - LOGGER.warn("can not broadcast for job manager: {}", e.getMessage(), e); - } - try { getJobManagerService().removeNoteJobInfo(note.getId(), null, - new JobManagerServiceCallback()); + new JobManagerServiceCallback(note)); } catch (IOException e) { LOGGER.warn("can not broadcast for job manager: {}", e.getMessage(), e); } @@ -1895,8 +2073,7 @@ public void onNoteRemove(Note note, AuthenticationInfo subject) { @Override public void onParagraphCreate(Paragraph p) { try { - getJobManagerService().getNoteJobInfo(p.getNote().getId(), null, - new JobManagerServiceCallback()); + broadcastUpdateNoteJobInfo(p.getNote()); } catch (IOException e) { LOGGER.warn("can not broadcast for job manager: {}", e.getMessage(), e); } @@ -1910,8 +2087,7 @@ public void onParagraphUpdate(Paragraph p) { @Override public void onNoteCreate(Note note, AuthenticationInfo subject) { try { - getJobManagerService().getNoteJobInfo(note.getId(), null, - new JobManagerServiceCallback()); + broadcastUpdateNoteJobInfo(note); } catch (IOException e) { LOGGER.warn("can not broadcast for job manager: {}", e.getMessage(), e); } @@ -1925,8 +2101,7 @@ public void onNoteUpdate(Note note, AuthenticationInfo subject) { @Override public void onParagraphStatusChange(Paragraph p, Status status) { try { - getJobManagerService().getNoteJobInfo(p.getNote().getId(), null, - new JobManagerServiceCallback()); + broadcastUpdateNoteJobInfo(p.getNote()); } catch (IOException e) { LOGGER.warn("can not broadcast for job manager: {}", e.getMessage(), e); } @@ -1934,6 +2109,12 @@ public void onParagraphStatusChange(Paragraph p, Status status) { private class JobManagerServiceCallback extends SimpleServiceCallback> { + private final Note note; + + JobManagerServiceCallback(Note note) { + this.note = note; + } + @Override public void onSuccess(List notesJobInfo, ServiceContext context) throws IOException { @@ -1941,8 +2122,8 @@ public void onSuccess(List notesJobInfo, Map response = new HashMap<>(); response.put("lastResponseUnixTime", System.currentTimeMillis()); response.put("jobs", notesJobInfo); - connectionManager.broadcast(JobManagerServiceType.JOB_MANAGER_PAGE.getKey(), - new Message(OP.LIST_UPDATE_NOTE_JOBS).put("noteRunningJobs", response)); + broadcastJobUpdateToAuthorizedSubscribers( + note, new Message(OP.LIST_UPDATE_NOTE_JOBS).put("noteRunningJobs", response)); } @Override @@ -1962,7 +2143,7 @@ public void onProgressUpdate(Job job, int progress) { if (!sendParagraphStatusToFrontend()) { return; } - connectionManager.broadcast(p.getNote().getId(), + broadcastToAuthorizedNoteSubscribers(p.getNote().getId(), new Message(OP.PROGRESS).put("id", p.getId()).put("progress", progress)); } } @@ -2008,7 +2189,7 @@ public void onStatusChange(Job job, Status before, Status after) { p.setStatusToUserParagraph(p.getStatus()); broadcastParagraph(p.getNote(), p, MSG_ID_NOT_DEFINED); try { - broadcastUpdateNoteJobInfo(p.getNote(), System.currentTimeMillis() - 5000); + broadcastUpdateNoteJobInfo(p.getNote()); } catch (IOException e) { LOGGER.error("can not broadcast for job manager", e); } @@ -2032,7 +2213,8 @@ public void checkpointOutput(String noteId, String paragraphId) { @Override public void noteRunningStatusChange(String noteId, boolean newStatus) { - connectionManager.broadcast(noteId, new Message(OP.NOTE_RUNNING_STATUS).put("status", newStatus)); + broadcastToAuthorizedNoteSubscribers( + noteId, new Message(OP.NOTE_RUNNING_STATUS).put("status", newStatus)); } private void sendAllAngularObjects(Note note, String user, NotebookSocket conn) @@ -2102,7 +2284,7 @@ private void updateNoteAngularObject(String noteId, AngularObject angularObject, if (intpSettings.isEmpty()) { return; } - connectionManager.broadcast(noteId, new Message(OP.ANGULAR_OBJECT_UPDATE) + broadcastToAuthorizedNoteSubscribers(noteId, new Message(OP.ANGULAR_OBJECT_UPDATE) .put("angularObject", angularObject) .put("interpreterGroupId", interpreterGroupId).put("noteId", noteId) .put("paragraphId", angularObject.getParagraphId())); @@ -2130,7 +2312,7 @@ private void removeNoteAngularObject(String noteId, AngularObject angularObject, getNotebook().getInterpreterSettingManager().getSettingIds(); for (String id : settingIds) { if (interpreterGroupId.contains(id)) { - connectionManager.broadcast(noteId, + broadcastToAuthorizedNoteSubscribers(noteId, new Message(OP.ANGULAR_OBJECT_REMOVE) .put("name", angularObject.getName()) .put("noteId", angularObject.getNoteId()) @@ -2169,10 +2351,11 @@ private void getInterpreterSettings(NotebookSocket conn, ServiceContext context, Message message) throws IOException { List allSettings = getNotebook().getInterpreterSettingManager().get(); - List result = new ArrayList<>(); + List result = new ArrayList<>(); for (InterpreterSetting setting : allSettings) { if (setting.isUserAuthorized(new ArrayList<>(context.getUserAndRoles()))) { - result.add(setting); + result.add(new InterpreterSettingsList( + setting.getId(), setting.getName(), setting.getInterpreterInfos(), false)); } } conn.send(serializeMessage( @@ -2199,7 +2382,7 @@ public void onParaInfosReceived(String noteId, String paragraphId, paragraph .updateRuntimeInfos(label, tooltip, metaInfos, setting.getGroup(), setting.getId()); getNotebook().saveNote(note, AuthenticationInfo.ANONYMOUS); - connectionManager.broadcast( + broadcastToAuthorizedNoteSubscribers( note.getId(), new Message(OP.PARAS_INFO).put("id", paragraphId).put("infos", paragraph.getRuntimeInfos())); @@ -2249,7 +2432,7 @@ private void broadcastNoteForms(Note note) { GUI formsSettings = new GUI(); formsSettings.setForms(note.getNoteForms()); formsSettings.setParams(note.getNoteParams()); - connectionManager.broadcast(note.getId(), + broadcastToAuthorizedNoteSubscribers(note.getId(), new Message(OP.SAVE_NOTE_FORMS).put("formsData", formsSettings)); } @@ -2295,15 +2478,6 @@ public void sendMessage(String message) { connectionManager.broadcast(m); } - private ServiceContext getServiceContext(TicketContainer.Entry ticketEntry) { - AuthenticationInfo authInfo = - new AuthenticationInfo(ticketEntry.getPrincipal(), ticketEntry.getRoles(), ticketEntry.getTicket()); - Set userAndRoles = new HashSet<>(); - userAndRoles.add(authInfo.getUser()); - userAndRoles.addAll(authInfo.getRoles()); - return new ServiceContext(authInfo, userAndRoles); - } - public class WebSocketServiceCallback extends SimpleServiceCallback { private final NotebookSocket conn; diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookSocket.java b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookSocket.java index 1805ce456f1..e7c27d970df 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookSocket.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookSocket.java @@ -17,13 +17,20 @@ package org.apache.zeppelin.socket; import org.apache.commons.lang3.StringUtils; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticatedSessionService; +import org.apache.zeppelin.service.SessionAuthenticationException; +import org.apache.shiro.mgt.SecurityManager; import org.apache.zeppelin.utils.ServerUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; +import jakarta.websocket.CloseReason; import jakarta.websocket.Session; /** @@ -32,13 +39,24 @@ public class NotebookSocket { private static final Logger LOGGER = LoggerFactory.getLogger(NotebookSocket.class); - private Session session; - private Map headers; + private final Session session; + private final Map headers; + private final AuthenticatedIdentity authenticatedIdentity; + private final SecurityManager authenticationSecurityManager; + private final AuthenticatedSessionService authenticatedSessionService; private String user; - public NotebookSocket(Session session, Map headers) { + public NotebookSocket( + Session session, + Map headers, + AuthenticatedIdentity authenticatedIdentity, + SecurityManager authenticationSecurityManager, + AuthenticatedSessionService authenticatedSessionService) { this.session = session; - this.headers = headers; + this.headers = Collections.unmodifiableMap(new HashMap<>(headers)); + this.authenticatedIdentity = authenticatedIdentity; + this.authenticationSecurityManager = authenticationSecurityManager; + this.authenticatedSessionService = authenticatedSessionService; this.user = StringUtils.EMPTY; LOGGER.debug("NotebookSocket created for session: {}", session.getId()); } @@ -48,6 +66,19 @@ public String getHeader(String key) { } public void send(String serializeMessage) throws IOException { + try { + authenticatedSessionService.validate( + authenticatedIdentity, authenticationSecurityManager); + } catch (SessionAuthenticationException e) { + try { + close(new CloseReason( + CloseReason.CloseCodes.VIOLATED_POLICY, + "Authenticated session is no longer valid")); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + throw new IOException("Authenticated session is no longer valid", e); + } session.getAsyncRemote().sendText(serializeMessage, result -> { if (result.getException() != null) { LOGGER.error("Failed to send async message for User {} in Session {}: {}", this.user, this.session.getId(), result.getException()); @@ -55,6 +86,18 @@ public void send(String serializeMessage) throws IOException { }); } + public void close(CloseReason closeReason) throws IOException { + session.close(closeReason); + } + + public AuthenticatedIdentity getAuthenticatedIdentity() { + return authenticatedIdentity; + } + + public SecurityManager getAuthenticationSecurityManager() { + return authenticationSecurityManager; + } + public String getUser() { return user; } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java index 03961782ca1..ca0650c7d7c 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java @@ -16,6 +16,8 @@ */ package org.apache.zeppelin.socket; +import java.net.URISyntaxException; +import java.net.UnknownHostException; import java.util.List; import jakarta.websocket.HandshakeResponse; @@ -23,6 +25,10 @@ import jakarta.websocket.server.ServerEndpointConfig; import jakarta.websocket.server.ServerEndpointConfig.Configurator; +import org.apache.zeppelin.conf.ZeppelinConfiguration; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticationService; +import org.apache.shiro.util.ThreadContext; import org.apache.zeppelin.util.WatcherSecurityKey; import org.apache.zeppelin.utils.CorsUtils; import org.glassfish.hk2.api.ServiceLocator; @@ -32,10 +38,28 @@ */ public class SessionConfigurator extends Configurator { + public static final String AUTHENTICATED_IDENTITY = + SessionConfigurator.class.getName() + ".authenticatedIdentity"; + public static final String AUTHENTICATION_SECURITY_MANAGER = + SessionConfigurator.class.getName() + ".authenticationSecurityManager"; + private final ServiceLocator serviceLocator; + private final ZeppelinConfiguration zConf; + private final AuthenticationService authenticationService; public SessionConfigurator(ServiceLocator serviceLocator) { this.serviceLocator = serviceLocator; + this.zConf = serviceLocator.getService(ZeppelinConfiguration.class); + this.authenticationService = serviceLocator.getService(AuthenticationService.class); + } + + @Override + public boolean checkOrigin(String originHeaderValue) { + try { + return CorsUtils.isValidOrigin(originHeaderValue, zConf); + } catch (UnknownHostException | URISyntaxException e) { + return false; + } } @Override @@ -48,6 +72,10 @@ public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, holder = request.getHeaders().get(CorsUtils.HEADER_ORIGIN); sec.getUserProperties().put(CorsUtils.HEADER_ORIGIN, null != holder && !holder.isEmpty() ? holder.get(0) : null); + AuthenticatedIdentity identity = authenticationService.getAuthenticatedIdentity(); + sec.getUserProperties().put(AUTHENTICATED_IDENTITY, identity); + sec.getUserProperties().put( + AUTHENTICATION_SECURITY_MANAGER, ThreadContext.getSecurityManager()); } @Override diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/ticket/TicketContainer.java b/zeppelin-server/src/main/java/org/apache/zeppelin/ticket/TicketContainer.java index 8d42e652ebc..8df4c23a713 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/ticket/TicketContainer.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/ticket/TicketContainer.java @@ -28,9 +28,11 @@ import org.slf4j.LoggerFactory; /** - * Very simple ticket container - * No cleanup is done, since the same user accross different devices share the same ticket - * The Map size is at most the number of different user names having access to a Zeppelin instance + * Legacy UI identity metadata retained for response compatibility. + * + *

Tickets from this container are not authentication credentials for REST or WebSocket. + * Both transports are authenticated by the Shiro session. No cleanup is done because the same + * user across different devices shares one legacy entry, so the map is bounded by user names. */ @@ -86,8 +88,8 @@ public boolean isValid(String principal, String ticket) { } /** - * get or create ticket for Websocket authentication assigned to authenticated shiro user - * For unathenticated user (anonymous), always return ticket value "anonymous" + * Get or create legacy response metadata for an authenticated Shiro user. + * For an unauthenticated user (anonymous), always return ticket value "anonymous". * @param principal * @return */ diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/utils/CorsUtils.java b/zeppelin-server/src/main/java/org/apache/zeppelin/utils/CorsUtils.java index 1d20783c4b8..9b7a0c57fdd 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/utils/CorsUtils.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/utils/CorsUtils.java @@ -32,24 +32,46 @@ private CorsUtils() { public static final String HEADER_ORIGIN = "Origin"; public static boolean isValidOrigin(String sourceHost, ZeppelinConfiguration zConf) throws UnknownHostException, URISyntaxException { + if (sourceHost == null || sourceHost.isEmpty()) { + return false; + } + + URI origin = new URI(sourceHost); + String originHost = origin.getHost(); + if (originHost == null + || origin.getScheme() == null + || origin.getUserInfo() != null + || origin.getQuery() != null + || origin.getFragment() != null + || (origin.getPath() != null && !origin.getPath().isEmpty())) { + return false; + } - String sourceUriHost = ""; + String normalizedOrigin = sourceHost.toLowerCase(Locale.ROOT); + if (zConf.getAllowedOrigins().contains("*") + || zConf.getAllowedOrigins().contains(normalizedOrigin)) { + return true; + } + if (!zConf.getAllowedOrigins().isEmpty()) { + return false; + } - if (sourceHost != null && !sourceHost.isEmpty()) { - sourceUriHost = new URI(sourceHost).getHost(); - sourceUriHost = (sourceUriHost == null) ? "" : sourceUriHost.toLowerCase(Locale.ROOT); + String expectedScheme = zConf.useSsl() ? "https" : "http"; + int expectedPort = zConf.useSsl() ? zConf.getServerSslPort() : zConf.getServerPort(); + int originPort = origin.getPort(); + if (originPort < 0) { + originPort = "https".equalsIgnoreCase(origin.getScheme()) ? 443 : 80; } + String normalizedHost = originHost.toLowerCase(Locale.ROOT); String currentHost = InetAddress.getLocalHost().getHostName().toLowerCase(Locale.ROOT); - // getAllowedOrigins() returns lowercased entries; normalize sourceHost the same way - // before the membership check so case differences in the Origin header do not produce - // false rejections of explicitly configured origins. - String normalizedOrigin = - sourceHost == null ? "" : sourceHost.toLowerCase(Locale.ROOT); - - return zConf.getAllowedOrigins().contains("*") - || currentHost.equals(sourceUriHost) - || "localhost".equals(sourceUriHost) - || zConf.getAllowedOrigins().contains(normalizedOrigin); + boolean localOrigin = currentHost.equals(normalizedHost) + || "localhost".equals(normalizedHost) + || "127.0.0.1".equals(normalizedHost) + || "::1".equals(normalizedHost) + || "[::1]".equals(normalizedHost); + return localOrigin + && expectedScheme.equalsIgnoreCase(origin.getScheme()) + && expectedPort == originPort; } } diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/conf/ZeppelinConfigurationTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/conf/ZeppelinConfigurationTest.java index a5cb0037fd0..3f074a5f725 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/conf/ZeppelinConfigurationTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/conf/ZeppelinConfigurationTest.java @@ -19,8 +19,11 @@ import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -29,6 +32,20 @@ class ZeppelinConfigurationTest { + @Test + void authenticationModeDoesNotChangeAfterStartup(@TempDir Path confDir) throws Exception { + Path shiroIni = Files.createFile(confDir.resolve("shiro.ini")); + ZeppelinConfiguration zConf = ZeppelinConfiguration.load("zeppelin-test-site.xml"); + zConf.setProperty(ConfVars.ZEPPELIN_CONF_DIR.getVarName(), confDir.toString()); + + zConf.initializeAuthenticationMode(); + Files.delete(shiroIni); + + assertTrue(zConf.isAuthenticationEnabled()); + assertFalse(zConf.isAnonymousAllowed()); + assertEquals(shiroIni.toString(), zConf.getShiroPath()); + } + @Test void getAllowedOrigins2Test() throws MalformedURLException { @@ -56,6 +73,18 @@ void getAllowedOriginsNoneTest() throws MalformedURLException { assertTrue(origins.isEmpty()); } + @Test + void websocketAuthorizationRoleRefreshIntervalDefaultsAndCanBeDisabled() { + ZeppelinConfiguration zConf = ZeppelinConfiguration.load("zeppelin-test-site.xml"); + + assertEquals(1000L, zConf.getWebsocketAuthorizationRolesRefreshIntervalMs()); + + zConf.setProperty( + ConfVars.ZEPPELIN_WEBSOCKET_AUTHORIZATION_ROLES_REFRESH_INTERVAL_MS.getVarName(), + "0"); + assertEquals(0L, zConf.getWebsocketAuthorizationRolesRefreshIntervalMs()); + } + @Test void isWindowsPathTestTrue() { diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/AuthorizationServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/AuthorizationServiceTest.java new file mode 100644 index 00000000000..c617ea9cda9 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/AuthorizationServiceTest.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.zeppelin.notebook; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.zeppelin.conf.ZeppelinConfiguration; +import org.apache.zeppelin.storage.ConfigStorage; +import org.apache.zeppelin.user.AuthenticationInfo; +import org.junit.jupiter.api.Test; + +class AuthorizationServiceTest { + + @Test + void missingAuthorizationIsDifferentFromAnExistingPublicAcl() throws Exception { + NoteManager noteManager = mock(NoteManager.class); + when(noteManager.getNotesInfo()).thenReturn(Collections.emptyMap()); + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.isAnonymousAllowed()).thenReturn(true); + ConfigStorage storage = mock(ConfigStorage.class); + AuthorizationService service = new AuthorizationService(noteManager, zConf, storage); + service.createNoteAuth("note-id", AuthenticationInfo.ANONYMOUS); + + assertTrue(service.isReader("note-id", Set.of("user"))); + assertTrue(service.isOwner("note-id", Set.of("user"))); + assertTrue(service.hasReadPermission(Set.of("user"), "note-id")); + + service.removeNoteAuth("note-id"); + + assertFalse(service.isReader("note-id", Set.of("user"))); + assertFalse(service.isOwner("note-id", Set.of("user"))); + assertFalse(service.hasReadPermission(Set.of("user"), "note-id")); + assertFalse(service.isOwner(Set.of("user"), "note-id")); + } + + @Test + void replaceAndClearPermissionsPublishCompleteSnapshots() throws Exception { + NoteManager noteManager = mock(NoteManager.class); + when(noteManager.getNotesInfo()).thenReturn(Collections.emptyMap()); + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + ConfigStorage storage = mock(ConfigStorage.class); + AuthorizationService service = new AuthorizationService(noteManager, zConf, storage); + service.createNoteAuth("note-id", new AuthenticationInfo("initial-owner")); + + Set readers = new HashSet<>(Set.of(" reader ", "")); + Set runners = new HashSet<>(Set.of(" runner ")); + Set writers = new HashSet<>(Set.of(" writer ")); + Set owners = new HashSet<>(Set.of(" owner ")); + service.setPermissions("note-id", readers, runners, writers, owners); + + readers.add("late-reader"); + runners.add("late-runner"); + writers.add("late-writer"); + owners.add("late-owner"); + assertEquals(Set.of("reader"), service.getReaders("note-id")); + assertEquals(Set.of("runner"), service.getRunners("note-id")); + assertEquals(Set.of("writer"), service.getWriters("note-id")); + assertEquals(Set.of("owner"), service.getOwners("note-id")); + + assertThrows( + NullPointerException.class, + () -> + service.setPermissions( + "note-id", + Set.of("partial-reader"), + Set.of("partial-runner"), + Set.of("partial-writer"), + null)); + assertEquals(Set.of("reader"), service.getReaders("note-id")); + assertEquals(Set.of("runner"), service.getRunners("note-id")); + assertEquals(Set.of("writer"), service.getWriters("note-id")); + assertEquals(Set.of("owner"), service.getOwners("note-id")); + + service.clearPermission("note-id"); + + assertTrue(service.getReaders("note-id").isEmpty()); + assertTrue(service.getRunners("note-id").isEmpty()); + assertTrue(service.getWriters("note-id").isEmpty()); + assertTrue(service.getOwners("note-id").isEmpty()); + } + + @Test + void effectiveAclAndRoleChangesAdvanceAuthorizationVersion() throws Exception { + AuthorizationService service = newAuthorizationService(); + service.createNoteAuth("note-id", new AuthenticationInfo("owner")); + long initialVersion = service.getAuthorizationVersion(); + + service.setPermissions( + "note-id", Set.of("reader"), Set.of("runner"), Set.of("writer"), Set.of("owner")); + long aclVersion = service.getAuthorizationVersion(); + assertEquals(initialVersion + 1, aclVersion); + + service.setPermissions( + "note-id", Set.of("reader"), Set.of("runner"), Set.of("writer"), Set.of("owner")); + assertEquals(aclVersion, service.getAuthorizationVersion()); + + service.setRoles("owner", Set.of("group")); + long roleVersion = service.getAuthorizationVersion(); + assertEquals(aclVersion + 1, roleVersion); + service.setRoles("owner", Set.of("group")); + assertEquals(roleVersion, service.getAuthorizationVersion()); + } + + @Test + void staleAuthorizationVersionRejectsGuardedFolderMutation() throws Exception { + AuthorizationService service = newAuthorizationService(); + service.createNoteAuth("note-id", new AuthenticationInfo("owner")); + long beforeRoleChange = service.getAuthorizationVersion(); + service.setRoles("owner", Set.of("new-role")); + AtomicBoolean executed = new AtomicBoolean(); + + assertThrows( + IOException.class, + () -> + service.runWithAuthorizationVersion( + beforeRoleChange, + () -> { + executed.set(true); + return null; + })); + + assertFalse(executed.get()); + } + + @Test + void guardedFolderMutationCannotInterleaveWithAclChange() throws Exception { + AuthorizationService service = newAuthorizationService(); + service.createNoteAuth("note-id", new AuthenticationInfo("owner")); + long authorizedVersion = service.getAuthorizationVersion(); + CountDownLatch operationStarted = new CountDownLatch(1); + CountDownLatch releaseOperation = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + + try { + Future guardedOperation = executor.submit( + () -> service.runWithAuthorizationVersion( + authorizedVersion, + () -> { + operationStarted.countDown(); + try { + releaseOperation.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while testing authorization lock", e); + } + return null; + })); + assertTrue(operationStarted.await(5, TimeUnit.SECONDS)); + + Future aclChange = executor.submit( + () -> { + service.setOwners("note-id", Set.of("new-owner")); + return null; + }); + assertThrows(TimeoutException.class, () -> aclChange.get(200, TimeUnit.MILLISECONDS)); + + releaseOperation.countDown(); + guardedOperation.get(5, TimeUnit.SECONDS); + aclChange.get(5, TimeUnit.SECONDS); + assertEquals(Set.of("new-owner"), service.getOwners("note-id")); + } finally { + releaseOperation.countDown(); + executor.shutdownNow(); + } + } + + private static AuthorizationService newAuthorizationService() { + NoteManager noteManager = mock(NoteManager.class); + when(noteManager.getNotesInfo()).thenReturn(Collections.emptyMap()); + return new AuthorizationService( + noteManager, mock(ZeppelinConfiguration.class), mock(ConfigStorage.class)); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteAuthTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteAuthTest.java index bb29b1a6ece..4a59dec0379 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteAuthTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteAuthTest.java @@ -22,6 +22,8 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -137,6 +139,43 @@ void testMapConstructor() { assertTrue(auth.getWriters().contains("TestGroup")); } + @Test + void permissionUpdatesPublishOneImmutableDefensiveSnapshot() { + NoteAuth auth = new NoteAuth("note1", zConf); + Set readers = new HashSet<>(Set.of("reader")); + Set runners = new HashSet<>(Set.of("runner")); + Set writers = new HashSet<>(Set.of("writer")); + Set owners = new HashSet<>(Set.of("owner")); + + auth.setPermissions(readers, runners, writers, owners); + NoteAuth.Permissions firstSnapshot = auth.getPermissions(); + Map> firstMap = auth.toMap(); + readers.add("late-reader"); + runners.add("late-runner"); + writers.add("late-writer"); + owners.add("late-owner"); + + assertEquals(Set.of("reader"), firstSnapshot.getReaders()); + assertEquals(Set.of("runner"), firstSnapshot.getRunners()); + assertEquals(Set.of("writer"), firstSnapshot.getWriters()); + assertEquals(Set.of("owner"), firstSnapshot.getOwners()); + assertEquals(Set.of("reader"), firstMap.get("readers")); + assertThrows(UnsupportedOperationException.class, + () -> auth.getReaders().add("mutated-reader")); + + auth.setPermissions( + Set.of("next-reader"), + Set.of("next-runner"), + Set.of("next-writer"), + Set.of("next-owner")); + + assertFalse(firstSnapshot.getReaders().contains("next-reader")); + assertEquals(Set.of("next-reader"), auth.getReaders()); + assertEquals(Set.of("next-runner"), auth.getRunners()); + assertEquals(Set.of("next-writer"), auth.getWriters()); + assertEquals(Set.of("next-owner"), auth.getOwners()); + } + private static Map> getTestMap(String user, String group) { Map> map = new HashMap<>(); Set readers = new HashSet(); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java index eaed222f9e3..0f8ca28cea6 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java @@ -20,6 +20,7 @@ import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.apache.zeppelin.notebook.exception.NotePathAlreadyExistsException; import org.apache.zeppelin.notebook.repo.InMemoryNotebookRepo; +import org.apache.zeppelin.notebook.repo.NotebookRepoWithVersionControl; import org.apache.zeppelin.user.AuthenticationInfo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,10 +30,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -127,6 +130,340 @@ void testMoveNoteRejectsDuplicatePath() throws IOException { "Note '/prod/note-1' existed"); } + @Test + void failedNoteMoveKeepsSourceMetadataAndCachedPath() throws IOException { + NoteManager manager = new NoteManager(new FailingNoteMoveRepo(), zConf); + Note note = createNote("/source/note"); + manager.saveNote(note); + + assertThrows( + IllegalStateException.class, + () -> manager.moveNote( + note.getId(), "/destination/note", AuthenticationInfo.ANONYMOUS)); + + assertEquals("/source/note", manager.getNotesInfo().get(note.getId())); + assertEquals("/source/note", note.getPath()); + assertTrue(manager.containsNote("/source/note")); + assertFalse(manager.containsNote("/destination/note")); + } + + @Test + void testMoveFolderRejectsExistingDestination() throws IOException { + Note source = createNote("/source/note"); + Note destination = createNote("/destination/note"); + noteManager.saveNote(source); + noteManager.saveNote(destination); + + assertThrows( + NotePathAlreadyExistsException.class, + () -> noteManager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals("/source/note", noteManager.getNotesInfo().get(source.getId())); + assertEquals("/destination/note", noteManager.getNotesInfo().get(destination.getId())); + } + + @Test + void testMoveFolderRejectsNoteAtDestination() throws IOException { + Note source = createNote("/source/note"); + Note destination = createNote("/destination"); + noteManager.saveNote(source); + noteManager.saveNote(destination); + + assertThrows( + NotePathAlreadyExistsException.class, + () -> noteManager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals("/source/note", noteManager.getNotesInfo().get(source.getId())); + assertEquals("/destination", noteManager.getNotesInfo().get(destination.getId())); + } + + @Test + void testMoveFolderRejectsOwnDescendant() throws IOException { + Note source = createNote("/source/note"); + noteManager.saveNote(source); + + assertThrows( + IOException.class, + () -> noteManager.moveFolder( + "/source", "/source/child", AuthenticationInfo.ANONYMOUS)); + + assertEquals("/source/note", noteManager.getNotesInfo().get(source.getId())); + } + + @Test + void folderMutationRejectsAnAuthorizationSnapshotAfterMembershipChanges() throws IOException { + Note original = createNote("/source/original"); + noteManager.saveNote(original); + NoteManager.NoteMetadataSnapshot authorized = noteManager.getNotesInfoSnapshot(); + + Note addedAfterAuthorization = createNote("/source/added-later"); + noteManager.saveNote(addedAfterAuthorization); + + IOException failure = assertThrows( + IOException.class, + () -> noteManager.moveFolder( + "/source", + "/destination", + AuthenticationInfo.ANONYMOUS, + authorized.getVersion())); + assertEquals( + "Notebook metadata changed while authorizing the folder operation", + failure.getMessage()); + assertEquals("/source/original", noteManager.getNotesInfo().get(original.getId())); + assertEquals( + "/source/added-later", noteManager.getNotesInfo().get(addedAfterAuthorization.getId())); + } + + @Test + void folderMoveUpdatesCachedNotePathBeforeASubsequentSave() throws IOException { + Note note = createNote("/source/note"); + noteManager.saveNote(note); + + noteManager.moveFolder("/source", "/destination", AuthenticationInfo.ANONYMOUS); + + assertEquals("/destination/note", note.getPath()); + noteManager.saveNote(note); + assertEquals("/destination/note", noteManager.getNotesInfo().get(note.getId())); + assertFalse(noteManager.containsNote("/source/note")); + } + + @Test + void restoreAllUpdatesDirectAndNestedCachedNotePathsBeforeReturning() throws IOException { + Note directNote = createNote("/~Trash/direct-note"); + Note nestedNote = createNote("/~Trash/folder/nested-note"); + noteManager.saveNote(directNote); + noteManager.saveNote(nestedNote); + NoteManager.NoteMetadataSnapshot authorized = noteManager.getNotesInfoSnapshot(); + + noteManager.restoreAllFromTrash(AuthenticationInfo.ANONYMOUS, authorized.getVersion()); + + assertEquals("/direct-note", directNote.getPath()); + assertEquals("/folder/nested-note", nestedNote.getPath()); + noteManager.saveNote(directNote); + noteManager.saveNote(nestedNote); + assertEquals("/direct-note", noteManager.getNotesInfo().get(directNote.getId())); + assertEquals("/folder/nested-note", noteManager.getNotesInfo().get(nestedNote.getId())); + } + + @Test + void emptyTrashKeepsTheLiveTrashNodeForLaterRestoreAll() throws IOException { + Note discardedNote = createNote("/~Trash/discarded-note"); + noteManager.saveNote(discardedNote); + noteManager.removeFolder("/~Trash", AuthenticationInfo.ANONYMOUS); + + Note laterNote = createNote("/~Trash/later-note"); + noteManager.saveNote(laterNote); + NoteManager.NoteMetadataSnapshot authorized = noteManager.getNotesInfoSnapshot(); + noteManager.restoreAllFromTrash(AuthenticationInfo.ANONYMOUS, authorized.getVersion()); + + assertEquals("/later-note", laterNote.getPath()); + assertEquals("/later-note", noteManager.getNotesInfo().get(laterNote.getId())); + assertTrue(noteManager.containsFolder("/~Trash")); + } + + @Test + void failedFolderRemovalRollsBackRemovedStateBeforeWaitingSaveContinues() throws Exception { + BlockingFailingFolderRemoveRepo repo = new BlockingFailingFolderRemoveRepo(); + NoteManager manager = new NoteManager(repo, zConf); + Note note = createNote("/folder/note"); + manager.saveNote(note); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch removalFinished = new CountDownLatch(1); + CountDownLatch saveStarted = new CountDownLatch(1); + CountDownLatch saveFinished = new CountDownLatch(1); + List removalFailures = Collections.synchronizedList(new ArrayList<>()); + + try { + executor.execute(() -> { + try { + manager.removeFolder( + "/folder", AuthenticationInfo.ANONYMOUS, -1, List.of(note)); + } catch (Throwable t) { + removalFailures.add(t); + } finally { + removalFinished.countDown(); + } + }); + assertTrue(repo.removeStarted.await(5, TimeUnit.SECONDS)); + + executor.execute(() -> { + saveStarted.countDown(); + try { + manager.saveNote(note); + } catch (Throwable t) { + removalFailures.add(t); + } finally { + saveFinished.countDown(); + } + }); + assertTrue(saveStarted.await(5, TimeUnit.SECONDS)); + assertFalse(saveFinished.await(200, TimeUnit.MILLISECONDS)); + + repo.allowRemoveToFail.countDown(); + assertTrue(removalFinished.await(5, TimeUnit.SECONDS)); + assertTrue(saveFinished.await(5, TimeUnit.SECONDS)); + assertEquals(1, removalFailures.size()); + assertTrue(removalFailures.get(0) instanceof IllegalStateException); + assertFalse(note.isRemoved()); + assertEquals("/folder/note", manager.getNotesInfo().get(note.getId())); + } finally { + repo.allowRemoveToFail.countDown(); + executor.shutdownNow(); + } + } + + @Test + void setRevisionAndMovePublishOnePathGeneration() throws Exception { + BlockingVersionedRepo repo = new BlockingVersionedRepo(); + NoteManager manager = new NoteManager(repo, zConf); + Note note = createNote("/source/note"); + manager.saveNote(note); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch moveStarted = new CountDownLatch(1); + CountDownLatch moveFinished = new CountDownLatch(1); + + try { + Future revision = executor.submit(() -> manager.setNoteRevision( + note.getId(), "/source/note", "revision", AuthenticationInfo.ANONYMOUS)); + assertTrue(repo.revisionStarted.await(5, TimeUnit.SECONDS)); + + Future move = executor.submit(() -> { + moveStarted.countDown(); + try { + manager.moveNote( + note.getId(), "/destination/note", AuthenticationInfo.ANONYMOUS); + } finally { + moveFinished.countDown(); + } + return null; + }); + assertTrue(moveStarted.await(5, TimeUnit.SECONDS)); + assertFalse(moveFinished.await(200, TimeUnit.MILLISECONDS)); + + repo.allowRevisionToReturn.countDown(); + assertNotNull(revision.get(5, TimeUnit.SECONDS)); + move.get(5, TimeUnit.SECONDS); + + assertEquals("/destination/note", manager.getNotesInfo().get(note.getId())); + assertEquals("/destination/note", note.getPath()); + assertEquals(Set.of("/destination/note"), repo.persistedPaths); + assertFalse(manager.containsNote("/source/note")); + assertTrue(manager.containsNote("/destination/note")); + + IOException stalePath = assertThrows( + IOException.class, + () -> manager.setNoteRevision( + note.getId(), "/source/note", "revision", AuthenticationInfo.ANONYMOUS)); + assertEquals("Note path changed while setting the revision", stalePath.getMessage()); + assertEquals(Set.of("/destination/note"), repo.persistedPaths); + } finally { + repo.allowRevisionToReturn.countDown(); + executor.shutdownNow(); + } + } + + private static final class BlockingFailingFolderRemoveRepo extends InMemoryNotebookRepo { + private final CountDownLatch removeStarted = new CountDownLatch(1); + private final CountDownLatch allowRemoveToFail = new CountDownLatch(1); + + @Override + public void remove(String folderPath, AuthenticationInfo subject) { + removeStarted.countDown(); + try { + if (!allowRemoveToFail.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to fail folder removal"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while failing folder removal", e); + } + throw new IllegalStateException("Failed to remove folder"); + } + } + + private static final class FailingNoteMoveRepo extends InMemoryNotebookRepo { + @Override + public void move( + String noteId, + String notePath, + String newNotePath, + AuthenticationInfo subject) { + throw new IllegalStateException("Failed to move note"); + } + } + + private static final class BlockingVersionedRepo extends InMemoryNotebookRepo + implements NotebookRepoWithVersionControl { + private final CountDownLatch revisionStarted = new CountDownLatch(1); + private final CountDownLatch allowRevisionToReturn = new CountDownLatch(1); + private final Set persistedPaths = ConcurrentHashMap.newKeySet(); + + @Override + public void save(Note note, AuthenticationInfo subject) throws IOException { + super.save(note, subject); + persistedPaths.add(note.getPath()); + } + + @Override + public void move( + String noteId, + String notePath, + String newNotePath, + AuthenticationInfo subject) { + super.move(noteId, notePath, newNotePath, subject); + persistedPaths.remove(notePath); + persistedPaths.add(newNotePath); + } + + @Override + public Revision checkpoint( + String noteId, + String notePath, + String checkpointMsg, + AuthenticationInfo subject) { + return Revision.EMPTY; + } + + @Override + public Note get( + String noteId, + String notePath, + String revId, + AuthenticationInfo subject) throws IOException { + return get(noteId, notePath, subject); + } + + @Override + public List revisionHistory( + String noteId, + String notePath, + AuthenticationInfo subject) { + return Collections.emptyList(); + } + + @Override + public Note setNoteRevision( + String noteId, + String notePath, + String revId, + AuthenticationInfo subject) throws IOException { + revisionStarted.countDown(); + try { + if (!allowRevisionToReturn.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to return a note revision"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while returning a note revision", e); + } + Note note = get(noteId, notePath, subject); + save(note, subject); + return note; + } + } + private Note createNote(String notePath) { return new Note(notePath, "test", null, null, null, null, null, zConf, noteParser); } diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java index 39e6ec70e38..c3c8ba5783c 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java @@ -1704,10 +1704,14 @@ public void onParagraphStatusChange(Paragraph p, Status status) { @Test void testRemoveFolderFiresNoteRemoveEventForEachNote() throws IOException { final AtomicInteger onNoteRemove = new AtomicInteger(0); + final AtomicInteger removedNotesSeenByListener = new AtomicInteger(0); notebook.addNotebookEventListener(new NoteEventListener() { @Override public void onNoteRemove(Note note, AuthenticationInfo subject) { onNoteRemove.incrementAndGet(); + if (note.isRemoved()) { + removedNotesSeenByListener.incrementAndGet(); + } } @Override @@ -1741,6 +1745,7 @@ public void onParagraphStatusChange(Paragraph p, Status status) { notebook.removeFolder("/folder1", anonymous); assertEquals(2, onNoteRemove.get()); + assertEquals(2, removedNotesSeenByListener.get()); } @Test diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/GitNotebookRepoTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/GitNotebookRepoTest.java index 3996ed2b03e..7c97f73b38f 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/GitNotebookRepoTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/GitNotebookRepoTest.java @@ -22,8 +22,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; import java.io.File; import java.io.IOException; @@ -44,9 +50,17 @@ import org.apache.zeppelin.notebook.repo.NotebookRepoWithVersionControl.Revision; import org.apache.zeppelin.user.AuthenticationInfo; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.CommitCommand; +import org.eclipse.jgit.api.ResetCommand; +import org.eclipse.jgit.api.errors.AbortedByHookException; +import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.api.errors.NoHeadException; import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.treewalk.TreeWalk; @@ -441,13 +455,220 @@ void moveFolderTest() throws IOException, GitAPIException { //when final String NOTE_DIR = TEST_NOTE_PATH.substring(0, TEST_NOTE_PATH.lastIndexOf("/")); final String MOVE_DIR = "/move"; - new File(notebooksDir + MOVE_DIR).mkdirs(); notebookRepo.move(NOTE_DIR, MOVE_DIR, null); //then assertFileIsMoved(); } + @Test + void caseOnlyNoteMovePreservesFileAndCommitsNewCasing() + throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + + String newPath = "/my_project/My_note1"; + notebookRepo.move(TEST_NOTE_ID, TEST_NOTE_PATH, newPath, null); + + String newFileName = notebookRepo.buildNoteFileName(TEST_NOTE_ID, newPath); + assertTrue(new File(notebooksDir, newFileName).isFile()); + assertEquals(newPath, notebookRepo.list(null).get(TEST_NOTE_ID).getPath()); + assertHeadContains(newFileName); + } + + @Test + void caseOnlyFolderMovePreservesFilesAndCommitsNewCasing() + throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + notebookRepo.checkpoint(TEST_NOTE_ID2, TEST_NOTE_PATH2, "second commit, note2", null); + + String newFolder = "/My_project"; + notebookRepo.move("/my_project", newFolder, null); + + String newPath = newFolder + "/my_note1"; + String newFileName = notebookRepo.buildNoteFileName(TEST_NOTE_ID, newPath); + assertTrue(new File(notebooksDir, newFileName).isFile()); + assertEquals(newPath, notebookRepo.list(null).get(TEST_NOTE_ID).getPath()); + assertHeadContains(newFileName); + } + + @Test + void failedNoteMoveCommitRollsBackFileAndIndex() throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + failNextCommit(new NoHeadException("forced commit failure")); + + String movePath = "/move/my_note1"; + assertThrows( + IOException.class, + () -> notebookRepo.move(TEST_NOTE_ID, TEST_NOTE_PATH, movePath, null)); + + String sourceFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, TEST_NOTE_PATH); + String destinationFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, movePath); + assertTrue(new File(notebooksDir, sourceFile).isFile()); + assertFalse(new File(notebooksDir, destinationFile).exists()); + assertTrue( + notebookRepo.getGit().status() + .addPath(sourceFile) + .addPath(destinationFile) + .call() + .isClean()); + } + + @Test + void failedFolderMoveCommitRollsBackFilesAndIndex() throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + notebookRepo.checkpoint(TEST_NOTE_ID2, TEST_NOTE_PATH2, "second commit, note2", null); + failNextCommit(new JGitInternalException("forced internal commit failure")); + + String sourceFolder = "/my_project"; + String destinationFolder = "/move"; + assertThrows( + IOException.class, + () -> notebookRepo.move(sourceFolder, destinationFolder, null)); + + assertTrue(new File(notebooksDir, sourceFolder.substring(1)).isDirectory()); + assertFalse(new File(notebooksDir, destinationFolder.substring(1)).exists()); + assertTrue( + notebookRepo.getGit().status() + .addPath(sourceFolder.substring(1)) + .addPath(destinationFolder.substring(1)) + .call() + .isClean()); + } + + @Test + void failureReportedAfterCommitKeepsCommittedMove() throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + ObjectId headBeforeMove = notebookRepo.getGit().getRepository().resolve(Constants.HEAD); + failAfterCommitUpdatesHead(); + + String movePath = "/move/my_note1"; + notebookRepo.move(TEST_NOTE_ID, TEST_NOTE_PATH, movePath, null); + + String destinationFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, movePath); + ObjectId headAfterMove = notebookRepo.getGit().getRepository().resolve(Constants.HEAD); + assertNotEquals(headBeforeMove, headAfterMove); + assertTrue(new File(notebooksDir, destinationFile).isFile()); + assertEquals(movePath, notebookRepo.list(null).get(TEST_NOTE_ID).getPath()); + assertHeadContains(destinationFile); + } + + @Test + void unrelatedHeadAdvanceDoesNotMasqueradeAsCommittedMove() + throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + Git realGit = notebookRepo.getGit(); + ObjectId originalHead = realGit.getRepository().resolve(Constants.HEAD); + RevCommit unrelatedCommit = realGit.commit() + .setAllowEmpty(true) + .setMessage("unrelated external commit") + .call(); + realGit.reset() + .setMode(ResetCommand.ResetType.HARD) + .setRef(originalHead.getName()) + .call(); + failNextCommitAfterHeadAdvance(unrelatedCommit.getId()); + + String movePath = "/move/my_note1"; + assertThrows( + IOException.class, + () -> notebookRepo.move(TEST_NOTE_ID, TEST_NOTE_PATH, movePath, null)); + + String sourceFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, TEST_NOTE_PATH); + String destinationFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, movePath); + assertEquals( + unrelatedCommit.getId(), + notebookRepo.getGit().getRepository().resolve(Constants.HEAD)); + assertTrue(new File(notebooksDir, sourceFile).isFile()); + assertFalse(new File(notebooksDir, destinationFile).exists()); + assertTrue( + notebookRepo.getGit().status() + .addPath(sourceFile) + .addPath(destinationFile) + .call() + .isClean()); + } + + @Test + void resetRuntimeFailureIsPreservedOnMoveFailure() throws IOException, GitAPIException { + notebookRepo = new GitNotebookRepo(); + notebookRepo.init(zConf, noteParser); + notebookRepo.checkpoint(TEST_NOTE_ID, TEST_NOTE_PATH, "first commit, note1", null); + Git git = failNextCommit(new NoHeadException("forced commit failure")); + doThrow(new RuntimeException("forced reset failure")).when(git).reset(); + + String movePath = "/move/my_note1"; + IOException failure = assertThrows( + IOException.class, + () -> notebookRepo.move(TEST_NOTE_ID, TEST_NOTE_PATH, movePath, null)); + + String sourceFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, TEST_NOTE_PATH); + String destinationFile = notebookRepo.buildNoteFileName(TEST_NOTE_ID, movePath); + assertTrue(new File(notebooksDir, sourceFile).isFile()); + assertFalse(new File(notebooksDir, destinationFile).exists()); + assertEquals(1, failure.getSuppressed().length); + assertEquals("forced reset failure", failure.getSuppressed()[0].getMessage()); + } + + private Git failNextCommit(Throwable failure) throws GitAPIException { + Git git = spy(notebookRepo.getGit()); + CommitCommand commit = mock(CommitCommand.class); + when(commit.setMessage(anyString())).thenReturn(commit); + when(commit.call()).thenAnswer(invocation -> { + throw failure; + }); + doReturn(commit).when(git).commit(); + notebookRepo.setGit(git); + return git; + } + + private void failAfterCommitUpdatesHead() throws GitAPIException { + Git realGit = notebookRepo.getGit(); + Git git = spy(realGit); + CommitCommand realCommit = realGit.commit(); + CommitCommand reportedFailure = mock(CommitCommand.class); + when(reportedFailure.setMessage(anyString())).thenAnswer(invocation -> { + realCommit.setMessage(invocation.getArgument(0)); + return reportedFailure; + }); + when(reportedFailure.call()).thenAnswer(invocation -> { + realCommit.call(); + throw new AbortedByHookException("simulated failure", "post-commit", 1); + }); + doReturn(reportedFailure).when(git).commit(); + notebookRepo.setGit(git); + } + + private void failNextCommitAfterHeadAdvance(ObjectId newHead) throws GitAPIException { + Git realGit = notebookRepo.getGit(); + Git git = spy(realGit); + CommitCommand commit = mock(CommitCommand.class); + when(commit.setMessage(anyString())).thenReturn(commit); + when(commit.call()).thenAnswer(invocation -> { + realGit.reset() + .setMode(ResetCommand.ResetType.MIXED) + .setRef(newHead.getName()) + .call(); + throw new ConcurrentRefUpdateException( + "simulated concurrent ref update", + realGit.getRepository().exactRef(Constants.HEAD), + RefUpdate.Result.LOCK_FAILURE); + }); + doReturn(commit).when(git).commit(); + notebookRepo.setGit(git); + } + @Test void removeNoteTest() throws IOException, GitAPIException { //given @@ -496,6 +717,15 @@ private void assertFileIsMoved() throws IOException, GitAPIException { } } + private void assertHeadContains(String path) throws IOException, GitAPIException { + Git git = notebookRepo.getGit(); + RevCommit latestCommit = git.log().call().iterator().next(); + try (TreeWalk treeWalk = TreeWalk.forPath( + git.getRepository(), path, latestCommit.getTree())) { + assertNotNull(treeWalk); + } + } + private void assertFileIsDeleted() throws IOException, GitAPIException { Git git = notebookRepo.getGit(); RevCommit latestCommit = git.log().call().iterator().next(); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java index 9bf02c0b3da..730dc1e4dab 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java @@ -130,6 +130,36 @@ void testNoteNameWithColon() throws IOException { assertEquals(1, noteInfos.size()); } + @Test + void testCaseOnlyNoteRename() throws IOException { + Note note = new Note(); + note.setPath("/case-note"); + note.setNoteParser(noteParser); + notebookRepo.save(note, AuthenticationInfo.ANONYMOUS); + + notebookRepo.move( + note.getId(), note.getPath(), "/Case-note", AuthenticationInfo.ANONYMOUS); + + assertEquals( + "/Case-note", + notebookRepo.list(AuthenticationInfo.ANONYMOUS).get(note.getId()).getPath()); + } + + @Test + void testCaseOnlyFolderRename() throws IOException { + Note note = new Note(); + note.setPath("/case-folder/note"); + note.setNoteParser(noteParser); + notebookRepo.save(note, AuthenticationInfo.ANONYMOUS); + + notebookRepo.move( + "/case-folder", "/Case-folder", AuthenticationInfo.ANONYMOUS); + + assertEquals( + "/Case-folder/note", + notebookRepo.list(AuthenticationInfo.ANONYMOUS).get(note.getId()).getPath()); + } + @Test void testUpdateSettings() throws IOException { List repoSettings = notebookRepo.getSettings(AuthenticationInfo.ANONYMOUS); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java index b6213cbc770..bd5c072de71 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java @@ -19,11 +19,14 @@ package org.apache.zeppelin.realm; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.realm.ldap.LdapContextFactory; import org.apache.shiro.session.Session; import org.apache.shiro.subject.SimplePrincipalCollection; @@ -43,6 +46,23 @@ import javax.naming.ldap.LdapContext; class LdapRealmTest { + private static class TestableLdapRealm extends LdapRealm { + AuthenticationInfo createAuthenticationInfo(UsernamePasswordToken token) + throws NamingException { + return super.createAuthenticationInfo(token, null, null, null); + } + } + + @Test + void generatedLdapCredentialsUseSupportedHash() throws NamingException { + TestableLdapRealm realm = new TestableLdapRealm(); + UsernamePasswordToken token = new UsernamePasswordToken("alice", "secret"); + + AuthenticationInfo authenticationInfo = realm.createAuthenticationInfo(token); + + assertTrue(realm.getCredentialsMatcher().doCredentialsMatch(token, authenticationInfo)); + } + @Test void testGetUserDn() { LdapRealm realm = new LdapRealm(); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/kerberos/KerberosRealmTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/kerberos/KerberosRealmTest.java new file mode 100644 index 00000000000..7e3a59f15c4 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/kerberos/KerberosRealmTest.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.zeppelin.realm.kerberos; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.shiro.subject.Subject; +import org.junit.jupiter.api.Test; + +class KerberosRealmTest { + + @Test + void sameAuthenticatedPrincipalDoesNotLoginAgainAndRotateTheSession() { + KerberosRealm realm = new KerberosRealm(); + Subject subject = mock(Subject.class); + when(subject.isAuthenticated()).thenReturn(true); + when(subject.getPrincipal()).thenReturn("user@example.com"); + KerberosToken token = new KerberosToken("user@example.com", "signed-token"); + + realm.loginIfNecessary(subject, token); + + verify(subject, never()).login(token); + } + + @Test + void unauthenticatedSubjectStillLogsIn() { + KerberosRealm realm = new KerberosRealm(); + Subject subject = mock(Subject.class); + KerberosToken token = new KerberosToken("user@example.com", "signed-token"); + + realm.loginIfNecessary(subject, token); + + verify(subject).login(token); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractRestApiTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractRestApiTest.java new file mode 100644 index 00000000000..957cd2cde47 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractRestApiTest.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import org.junit.jupiter.api.Test; +import java.util.Set; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticationService; +import org.apache.zeppelin.service.ServiceContext; + +class AbstractRestApiTest { + + @Test + void createsServiceContextFromOneIdentitySnapshot() { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", Set.of("reader"), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(identity); + + TestRestApi restApi = new TestRestApi(authenticationService); + ServiceContext context = restApi.exposeServiceContext(); + + assertEquals("user", context.getAutheInfo().getUser()); + assertEquals(Set.of("reader"), context.getAutheInfo().getRoles()); + assertEquals(Set.of("user", "reader"), context.getUserAndRoles()); + verify(authenticationService).getAuthenticatedIdentity(); + verifyNoMoreInteractions(authenticationService); + } + + private static class TestRestApi extends AbstractRestApi { + + TestRestApi(AuthenticationService authenticationService) { + super(authenticationService); + } + + ServiceContext exposeServiceContext() { + return getServiceContext(); + } + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractTestRestApi.java b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractTestRestApi.java index 6e3b4d8615a..c401858b5eb 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractTestRestApi.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/AbstractTestRestApi.java @@ -51,6 +51,7 @@ import java.lang.ref.WeakReference; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.List; import java.util.regex.Pattern; import org.apache.zeppelin.conf.ZeppelinConfiguration; @@ -70,6 +71,7 @@ public abstract class AbstractTestRestApi { "[main]\n" + "sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager\n" + "securityManager.sessionManager = $sessionManager\n" + + "securityManager.rememberMeManager = null\n" + "securityManager.sessionManager.globalSessionTimeout = 86400000\n" + "shiro.loginUrl = /api/login\n" + "[roles]\n" + @@ -80,6 +82,7 @@ public abstract class AbstractTestRestApi { "[urls]\n" + "/api/version = anon\n" + "/api/cluster/address = anon\n" + + "/ws = authc\n" + "/** = authc"; protected static final String ZEPPELIN_SHIRO_KNOX = @@ -94,6 +97,7 @@ public abstract class AbstractTestRestApi { "authc = org.apache.zeppelin.realm.jwt.KnoxAuthenticationFilter\n" + "sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager\n" + "securityManager.sessionManager = $sessionManager\n" + + "securityManager.rememberMeManager = null\n" + "securityManager.sessionManager.globalSessionTimeout = 86400000\n" + "shiro.loginUrl = /api/login\n" + "[roles]\n" + @@ -101,6 +105,7 @@ public abstract class AbstractTestRestApi { "[urls]\n" + "/api/version = anon\n" + "/api/cluster/address = anon\n" + + "/ws = authc\n" + "/** = authc"; protected static final String KNOW_SSO_PEM_CERTIFICATE = @@ -131,7 +136,19 @@ public static CloseableHttpClient getHttpClient() { } protected static String getUrlToTest(ZeppelinConfiguration zConf) { - return "http://localhost:" + zConf.getServerPort() + REST_API_URL; + return getOriginToTest(zConf) + REST_API_URL; + } + + protected static String getOriginToTest(ZeppelinConfiguration zConf) { + return "http://localhost:" + zConf.getServerPort(); + } + + protected static String getRequestOriginToTest(ZeppelinConfiguration zConf) { + List allowedOrigins = zConf.getAllowedOrigins(); + if (!allowedOrigins.isEmpty() && !allowedOrigins.contains("*")) { + return allowedOrigins.get(0); + } + return getOriginToTest(zConf); } public CloseableHttpResponse httpGet(String path) @@ -148,7 +165,7 @@ public CloseableHttpResponse httpGet(String path, String user, String pwd, Strin throws IOException { LOGGER.info("Connecting to {}", getUrlToTest(zConf) + path); HttpGet httpGet = new HttpGet(getUrlToTest(zConf) + path); - httpGet.addHeader("Origin", getUrlToTest(zConf)); + httpGet.addHeader("Origin", getRequestOriginToTest(zConf)); if (userAndPasswordAreNotBlank(user, pwd)) { httpGet.setHeader("Cookie", "JSESSIONID=" + getCookie(user, pwd)); } @@ -169,7 +186,7 @@ public CloseableHttpResponse httpDelete(String path, String user, String pwd) throws IOException { LOGGER.info("Connecting to {}", getUrlToTest(zConf) + path); HttpDelete httpDelete = new HttpDelete(getUrlToTest(zConf) + path); - httpDelete.addHeader("Origin", getUrlToTest(zConf)); + httpDelete.addHeader("Origin", getRequestOriginToTest(zConf)); if (userAndPasswordAreNotBlank(user, pwd)) { httpDelete.setHeader("Cookie", "JSESSIONID=" + getCookie(user, pwd)); } @@ -210,7 +227,7 @@ public CloseableHttpResponse httpPut(String path, String body, String user, Stri throws IOException { LOGGER.info("Connecting to {}", getUrlToTest(zConf) + path); HttpPut httpPut = new HttpPut(getUrlToTest(zConf) + path); - httpPut.addHeader("Origin", getUrlToTest(zConf)); + httpPut.addHeader("Origin", getRequestOriginToTest(zConf)); httpPut.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON)); if (userAndPasswordAreNotBlank(user, pwd)) { httpPut.setHeader("Cookie", "JSESSIONID=" + getCookie(user, pwd)); @@ -220,10 +237,10 @@ public CloseableHttpResponse httpPut(String path, String body, String user, Stri return response; } - private String getCookie(String user, String password) + protected String getCookie(String user, String password) throws IOException { HttpPost httpPost = new HttpPost(getUrlToTest(zConf) + "/login"); - httpPost.addHeader("Origin", getUrlToTest(zConf)); + httpPost.addHeader("Origin", getRequestOriginToTest(zConf)); ArrayList postParameters = new ArrayList(); postParameters.add(new BasicNameValuePair("password", password)); postParameters.add(new BasicNameValuePair("userName", user)); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/server/CorsFilterTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/server/CorsFilterTest.java index 1048c0ba258..ca3678578ae 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/server/CorsFilterTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/server/CorsFilterTest.java @@ -50,7 +50,7 @@ class CorsFilterTest { @Test void validCorsFilterTest() throws IOException, ServletException { - CorsFilter filter = new CorsFilter(ZeppelinConfiguration.load()); + CorsFilter filter = new CorsFilter(localConfiguration()); HttpServletResponse mockResponse = mock(HttpServletResponse.class); FilterChain mockedFilterChain = mock(FilterChain.class); HttpServletRequest mockRequest = mock(HttpServletRequest.class); @@ -112,11 +112,11 @@ void crossOriginPreflightBlocked() throws IOException, ServletException { @Test void allowedOriginPostPasses() throws IOException, ServletException { - CorsFilter filter = new CorsFilter(ZeppelinConfiguration.load()); + CorsFilter filter = new CorsFilter(localConfiguration()); HttpServletRequest mockRequest = mock(HttpServletRequest.class); HttpServletResponse mockResponse = mock(HttpServletResponse.class); FilterChain mockedFilterChain = mock(FilterChain.class); - when(mockRequest.getHeader("Origin")).thenReturn("http://localhost"); + when(mockRequest.getHeader("Origin")).thenReturn("http://localhost:8080"); when(mockRequest.getMethod()).thenReturn("POST"); Map setHeaders = recordSetHeaders(mockResponse); @@ -124,7 +124,7 @@ void allowedOriginPostPasses() throws IOException, ServletException { verify(mockResponse, never()).sendError(anyInt(), anyString()); verify(mockedFilterChain, times(1)).doFilter(mockRequest, mockResponse); - assertEquals("http://localhost", setHeaders.get("Access-Control-Allow-Origin")); + assertEquals("http://localhost:8080", setHeaders.get("Access-Control-Allow-Origin")); assertEquals("true", setHeaders.get("Access-Control-Allow-Credentials")); } @@ -185,4 +185,8 @@ private static Map recordSetHeaders(HttpServletResponse response }).when(response).setHeader(anyString(), anyString()); return recorded; } + + private static ZeppelinConfiguration localConfiguration() { + return ZeppelinConfiguration.load("no-configured-origins.xml"); + } } diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstallerTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstallerTest.java new file mode 100644 index 00000000000..0c6f6a7a7a1 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/server/JettyWebSocketUpgradeFilterInstallerTest.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.zeppelin.server; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.apache.shiro.web.servlet.ShiroFilter; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.FilterMapping; +import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter; +import org.junit.jupiter.api.Test; +import java.util.EnumSet; +import jakarta.servlet.DispatcherType; + +class JettyWebSocketUpgradeFilterInstallerTest { + + @Test + void shiroMappingsPrecedeTheReusableWebSocketUpgradeFilter() { + WebAppContext webApp = new WebAppContext(); + FilterHolder shiroFilter = + webApp.addFilter( + ShiroFilter.class, "/api/*", EnumSet.allOf(DispatcherType.class)); + + FilterHolder upgradeFilter = + JettyWebSocketUpgradeFilterInstaller.installAfterAuthenticationFilter( + webApp, shiroFilter); + FilterHolder[] filters = webApp.getServletHandler().getFilters(); + FilterMapping[] mappings = webApp.getServletHandler().getFilterMappings(); + + assertEquals(2, filters.length); + assertSame(shiroFilter, filters[0]); + assertSame(upgradeFilter, filters[1]); + assertEquals(WebSocketUpgradeFilter.class.getName(), upgradeFilter.getName()); + + assertEquals(3, mappings.length); + assertEquals(shiroFilter.getName(), mappings[0].getFilterName()); + assertArrayEquals(new String[] {"/api/*"}, mappings[0].getPathSpecs()); + assertEquals(shiroFilter.getName(), mappings[1].getFilterName()); + assertArrayEquals(new String[] {"/ws"}, mappings[1].getPathSpecs()); + assertEquals(upgradeFilter.getName(), mappings[2].getFilterName()); + assertArrayEquals(new String[] {"/*"}, mappings[2].getPathSpecs()); + assertEquals(EnumSet.of(DispatcherType.REQUEST), mappings[2].getDispatcherTypes()); + assertTrue(upgradeFilter.isAsyncSupported()); + + assertSame( + upgradeFilter, + WebSocketUpgradeFilter.ensureFilter(webApp.getServletContext()), + "Jetty's WebSocket initializer must reuse the explicitly ordered filter"); + assertEquals(3, webApp.getServletHandler().getFilterMappings().length); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedIdentityTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedIdentityTest.java new file mode 100644 index 00000000000..a03ce2d46ec --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedIdentityTest.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import java.util.HashSet; +import java.util.Set; + +class AuthenticatedIdentityTest { + + @Test + void copiesRolesAndExposesAnImmutableSnapshot() { + Set roles = new HashSet<>(); + roles.add("reader"); + + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", roles, true, "session-id"); + roles.add("admin"); + + assertEquals(Set.of("reader"), identity.getRoles()); + assertThrows(UnsupportedOperationException.class, () -> identity.getRoles().add("writer")); + assertEquals("session-id", identity.getSessionId().orElseThrow()); + assertTrue(identity.isAuthenticated()); + } + + @Test + void providesAnAnonymousIdentityWithoutASession() { + AuthenticatedIdentity identity = AuthenticatedIdentity.anonymous(); + + assertEquals(AuthenticatedIdentity.ANONYMOUS_PRINCIPAL, identity.getPrincipal()); + assertEquals(Set.of(), identity.getRoles()); + assertFalse(identity.isAuthenticated()); + assertTrue(identity.getSessionId().isEmpty()); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java new file mode 100644 index 00000000000..bc80e4c12e3 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.Subject; +import org.junit.jupiter.api.Test; + +class AuthenticatedSessionServiceTest { + + @Test + void noAuthenticationAlwaysUsesAnonymousIdentity() { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedSessionService service = + new AuthenticatedSessionService(authenticationService); + + AuthenticatedIdentity refreshed = + service.refresh(AuthenticatedIdentity.anonymous(), null, true); + + assertSame(AuthenticatedIdentity.anonymous(), refreshed); + verify(authenticationService, never()).getAuthenticatedIdentity(); + } + + @Test + void explicitAnonymousShiroRuleRemainsAnonymous() { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedSessionService service = + new AuthenticatedSessionService(authenticationService); + + AuthenticatedIdentity refreshed = + service.refresh(AuthenticatedIdentity.anonymous(), null, true); + + assertSame(AuthenticatedIdentity.anonymous(), refreshed); + verify(authenticationService, never()).getAuthenticatedIdentity(); + } + + @Test + void refreshesIdentityFromTheCapturedSessionAndTouchesRealOperations() throws Exception { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity captured = + new AuthenticatedIdentity("user1", Set.of("role1"), true, "session-id"); + AuthenticatedIdentity refreshed = + new AuthenticatedIdentity("user1", Set.of("role2"), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(refreshed); + when(authenticationService.getPrincipal()).thenReturn("user1"); + + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + Subject subject = mock(Subject.class); + when(subject.getSession(false)).thenReturn(session); + when(subject.isAuthenticated()).thenReturn(true); + when(subject.execute( + org.mockito.ArgumentMatchers.>any())) + .thenAnswer( + invocation -> { + Callable callable = invocation.getArgument(0); + return callable.call(); + }); + + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(subject).when(service).restoreSubject("session-id", securityManager); + + assertSame(refreshed, service.refresh(captured, securityManager, true)); + verify(session).touch(); + } + + @Test + void pingValidationDoesNotExtendTheIdleTimeout() throws Exception { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(identity); + when(authenticationService.getPrincipal()).thenReturn("user1"); + + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + Subject subject = mock(Subject.class); + when(subject.getSession(false)).thenReturn(session); + when(subject.isAuthenticated()).thenReturn(true); + when(subject.execute( + org.mockito.ArgumentMatchers.>any())) + .thenAnswer( + invocation -> { + Callable callable = invocation.getArgument(0); + return callable.call(); + }); + + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(subject).when(service).restoreSubject("session-id", securityManager); + + assertSame(identity, service.refresh(identity, securityManager, false)); + verify(session, never()).touch(); + } + + @Test + void rejectsAChangedSessionIdentity() throws Exception { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity captured = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn( + new AuthenticatedIdentity("user2", Set.of(), true, "session-id")); + when(authenticationService.getPrincipal()).thenReturn("user1"); + + Subject subject = mock(Subject.class); + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + when(subject.getSession(false)).thenReturn(session); + when(subject.isAuthenticated()).thenReturn(true); + when(subject.execute( + org.mockito.ArgumentMatchers.>any())) + .thenAnswer( + invocation -> { + Callable callable = invocation.getArgument(0); + return callable.call(); + }); + + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(subject).when(service).restoreSubject("session-id", securityManager); + + assertThrows(SessionAuthenticationException.class, + () -> service.refresh(captured, securityManager, true)); + } + + @Test + void rejectsAnExpiredOrMissingSession() { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity captured = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + + Subject subject = mock(Subject.class); + when(subject.getSession(false)).thenReturn(null); + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(subject).when(service).restoreSubject("session-id", securityManager); + + assertThrows(SessionAuthenticationException.class, + () -> service.refresh(captured, securityManager, false)); + } + + @Test + void outboundRefreshReusesRolesOnlyWithinTheConfiguredAge() throws Exception { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity captured = + new AuthenticatedIdentity("user1", Set.of("initial"), true, "session-id"); + AuthenticatedIdentity first = + new AuthenticatedIdentity("user1", Set.of("role1"), true, "session-id"); + AuthenticatedIdentity second = + new AuthenticatedIdentity("user1", Set.of("role2"), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(first, second); + when(authenticationService.getPrincipal()).thenReturn("user1"); + + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + retainRoleSnapshot(session); + Subject subject = authenticatedSubject(session); + Clock clock = mock(Clock.class); + when(clock.millis()).thenReturn(1_000L, 1_500L, 2_000L); + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService, clock)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(subject).when(service).restoreSubject("session-id", securityManager); + + assertSame(first, service.refresh(captured, securityManager, false)); + assertEquals( + Set.of("role1"), + service.refresh(captured, securityManager, false, 1_000L).getRoles()); + assertSame(second, service.refresh(captured, securityManager, false, 1_000L)); + + verify(authenticationService, times(2)).getAuthenticatedIdentity(); + verify(service, times(3)).restoreSubject("session-id", securityManager); + verify(session, never()).touch(); + } + + @Test + void strictRefreshAndZeroMaxAgeNeverReuseRoles() throws Exception { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity captured = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedIdentity first = + new AuthenticatedIdentity("user1", Set.of("role1"), true, "session-id"); + AuthenticatedIdentity second = + new AuthenticatedIdentity("user1", Set.of("role2"), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(first, second); + when(authenticationService.getPrincipal()).thenReturn("user1"); + + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + retainRoleSnapshot(session); + Subject subject = authenticatedSubject(session); + Clock clock = mock(Clock.class); + when(clock.millis()).thenReturn(1_000L, 1_001L); + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService, clock)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(subject).when(service).restoreSubject("session-id", securityManager); + + assertSame(first, service.refresh(captured, securityManager, false)); + assertSame(second, service.refresh(captured, securityManager, false, 0)); + + verify(authenticationService, times(2)).getAuthenticatedIdentity(); + } + + @Test + void cachedRolesNeverBypassSessionExpiry() throws Exception { + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of("role1"), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(identity); + when(authenticationService.getPrincipal()).thenReturn("user1"); + + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + retainRoleSnapshot(session); + Subject validSubject = authenticatedSubject(session); + Subject expiredSubject = mock(Subject.class); + when(expiredSubject.getSession(false)).thenReturn(null); + Clock clock = mock(Clock.class); + when(clock.millis()).thenReturn(1_000L, 1_001L); + AuthenticatedSessionService service = + spy(new AuthenticatedSessionService(authenticationService, clock)); + SecurityManager securityManager = mock(SecurityManager.class); + doReturn(validSubject, expiredSubject) + .when(service).restoreSubject("session-id", securityManager); + + assertSame(identity, service.refresh(identity, securityManager, false)); + assertThrows( + SessionAuthenticationException.class, + () -> service.refresh(identity, securityManager, false, 1_000L)); + + verify(authenticationService).getAuthenticatedIdentity(); + } + + private static Subject authenticatedSubject(Session session) throws Exception { + Subject subject = mock(Subject.class); + when(subject.getSession(false)).thenReturn(session); + when(subject.isAuthenticated()).thenReturn(true); + when(subject.execute( + org.mockito.ArgumentMatchers.>any())) + .thenAnswer( + invocation -> { + Callable callable = invocation.getArgument(0); + return callable.call(); + }); + return subject; + } + + private static void retainRoleSnapshot(Session session) { + AtomicReference roleSnapshot = new AtomicReference<>(); + when(session.getAttribute(anyString())).thenAnswer(unused -> roleSnapshot.get()); + doAnswer(invocation -> { + roleSnapshot.set(invocation.getArgument(1)); + return null; + }).when(session).setAttribute(anyString(), any()); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ConfigurationServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ConfigurationServiceTest.java index 5df2a72c3f1..c07b866d888 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ConfigurationServiceTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ConfigurationServiceTest.java @@ -32,6 +32,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; @@ -84,4 +85,13 @@ void testFetchConfiguration() throws IOException { assertTrue(entry.getKey().startsWith("zeppelin.server")); } } + + @Test + void websocketClientConfigurationUsesAnExplicitSafeAllowlist() { + Map properties = configurationService.getClientProperties(); + + assertEquals(Map.of( + ZeppelinConfiguration.ConfVars.ZEPPELIN_WEBSOCKET_MAX_TEXT_MESSAGE_SIZE.getVarName(), + zConf.getWebsocketMaxTextMessageSize()), properties); + } } diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/NoAuthenticationServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/NoAuthenticationServiceTest.java new file mode 100644 index 00000000000..9e7a741b923 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/NoAuthenticationServiceTest.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import java.util.Collections; + +class NoAuthenticationServiceTest { + + @Test + void capturesTheAnonymousIdentity() { + AuthenticatedIdentity identity = + new NoAuthenticationService().getAuthenticatedIdentity(); + + assertEquals(AuthenticatedIdentity.ANONYMOUS_PRINCIPAL, identity.getPrincipal()); + assertEquals(Collections.emptySet(), identity.getRoles()); + assertFalse(identity.isAuthenticated()); + assertTrue(identity.getSessionId().isEmpty()); + } +} 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 0a176ac8b40..1b8027abede 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 @@ -21,14 +21,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -40,6 +44,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -62,10 +67,12 @@ import org.apache.zeppelin.notebook.NoteParser; import org.apache.zeppelin.notebook.Notebook; import org.apache.zeppelin.notebook.Paragraph; +import org.apache.zeppelin.rest.exception.NoteNotFoundException; import org.apache.zeppelin.notebook.exception.NotePathAlreadyExistsException; import org.apache.zeppelin.notebook.repo.NotebookRepo; import org.apache.zeppelin.notebook.repo.VFSNotebookRepo; import org.apache.zeppelin.notebook.scheduler.QuartzSchedulerService; +import org.apache.zeppelin.notebook.scheduler.SchedulerService; import org.apache.zeppelin.search.LuceneSearch; import org.apache.zeppelin.search.SearchService; import org.apache.zeppelin.storage.ConfigStorage; @@ -88,6 +95,7 @@ class NotebookServiceTest { private File confDir; private SearchService searchService; private Notebook notebook; + private AuthorizationService authorizationService; private ServiceContext context = new ServiceContext(AuthenticationInfo.ANONYMOUS, new HashSet<>()); @@ -102,11 +110,12 @@ void setUp(TestInfo testInfo) throws Exception { ZeppelinConfiguration zConf = ZeppelinConfiguration.load(); zConf.setProperty(ZeppelinConfiguration.ConfVars.ZEPPELIN_NOTEBOOK_DIR.getVarName(), notebookDir.getAbsolutePath()); + confDir = Files.createTempDirectory("confDir").toAbsolutePath().toFile(); + zConf.setProperty( + ZeppelinConfiguration.ConfVars.ZEPPELIN_CONF_DIR.getVarName(), + confDir.getAbsolutePath()); // enable cron for testNoteUpdate method if ("testNoteUpdate()".equals(testInfo.getDisplayName())){ - confDir = Files.createTempDirectory("confDir").toAbsolutePath().toFile(); - zConf.setProperty(ZeppelinConfiguration.ConfVars.ZEPPELIN_CONF_DIR.getVarName(), - confDir.getAbsolutePath()); zConf.setProperty(ZeppelinConfiguration.ConfVars.ZEPPELIN_NOTEBOOK_CRON_ENABLE.getVarName(), "true"); String shiroPath = zConf.getAbsoluteDir(String.format("%s/shiro.ini", zConf.getConfDir())); Files.createFile(new File(shiroPath).toPath()); @@ -136,7 +145,7 @@ void setUp(TestInfo testInfo) throws Exception { when(mockInterpreterSetting.getStatus()).thenReturn(InterpreterSetting.Status.READY); Credentials credentials = new Credentials(); NoteManager noteManager = new NoteManager(notebookRepo, zConf); - AuthorizationService authorizationService = + authorizationService = new AuthorizationService(noteManager, zConf, storage); notebook = new Notebook( @@ -410,6 +419,157 @@ void testNoteOperations() throws IOException { assertEquals(0, notesInfo.size()); } + @Test + void missingNoteReachesNotFoundInsteadOfPermissionFailure() throws IOException { + notebookService.removeNote("missing-note", context, callback); + + ArgumentCaptor failure = ArgumentCaptor.forClass(Exception.class); + verify(callback).onFailure(failure.capture(), eq(context)); + assertTrue(failure.getValue() instanceof NoteNotFoundException); + } + + @Test + void folderRemovalRequiresOwnershipOfEveryDescendantNote() throws IOException { + String userOneNote = notebookService.createNote( + "/shared-folder/user-one-note", "test", true, context, callback); + String userTwoNote = notebookService.createNote( + "/shared-folder/user-two-note", "test", true, context, callback); + authorizationService.setOwners(userOneNote, Set.of("user1")); + authorizationService.setOwners(userTwoNote, Set.of("user2")); + ServiceContext userOneContext = new ServiceContext( + new AuthenticationInfo("user1"), new HashSet<>(Set.of("user1"))); + reset(callback); + + List result = notebookService.removeFolder( + "/shared-folder", userOneContext, callback); + + assertNull(result); + verify(callback).onFailure(any(Exception.class), eq(userOneContext)); + assertTrue(notebook.containsNoteById(userOneNote)); + assertTrue(notebook.containsNoteById(userTwoNote)); + } + + @Test + void folderMutationRejectsAclChangeAfterDescendantPreflight() throws IOException { + String noteId = notebookService.createNote( + "/acl-race/note", "test", true, context, callback); + AuthorizationService guardedAuthorization = spy(authorizationService); + NotebookService guardedService = new NotebookService( + notebook, + guardedAuthorization, + notebook.getConf(), + mock(SchedulerService.class)); + doAnswer(invocation -> { + guardedAuthorization.setOwners(noteId, Set.of("different-owner")); + // Simulate the ACL changing immediately after the preflight's final comparison. The + // guarded mutation must compare the captured generation again under the ACL monitor. + return true; + }).when(guardedAuthorization).isAuthorizationVersionCurrent(anyLong()); + reset(callback); + + List result = guardedService.removeFolder("/acl-race", context, callback); + + assertNull(result); + verify(callback).onFailure(any(IOException.class), eq(context)); + assertTrue(notebook.containsNoteById(noteId)); + } + + @Test + void restoreFolderDoesNotOverwriteExistingDestination() throws IOException { + String trashedNote = notebookService.createNote( + "/Backup/trashed-note", "test", true, context, callback); + notebookService.moveFolderToTrash("/Backup", context, callback); + String replacementNote = notebookService.createNote( + "/Backup/replacement-note", "test", true, context, callback); + reset(callback); + + notebookService.restoreFolder("/~Trash/Backup", context, callback); + + verify(callback).onFailure(any(IOException.class), eq(context)); + assertEquals( + "/~Trash/Backup/trashed-note", + notebook.getNoteManager().getNotesInfo().get(trashedNote)); + assertEquals( + "/Backup/replacement-note", + notebook.getNoteManager().getNotesInfo().get(replacementNote)); + } + + @Test + void restoreAllChecksEveryDestinationBeforeMovingAnything() throws IOException { + String collidingTrashedNote = notebookService.createNote( + "/Alpha/old-note", "test", true, context, callback); + notebookService.moveFolderToTrash("/Alpha", context, callback); + String replacementNote = notebookService.createNote( + "/Alpha/new-note", "test", true, context, callback); + String otherTrashedNote = notebookService.createNote( + "/Beta/old-note", "test", true, context, callback); + notebookService.moveFolderToTrash("/Beta", context, callback); + reset(callback); + + notebookService.restoreAll(context, callback); + + verify(callback).onFailure(any(IOException.class), eq(context)); + assertEquals( + "/~Trash/Alpha/old-note", + notebook.getNoteManager().getNotesInfo().get(collidingTrashedNote)); + assertEquals( + "/Alpha/new-note", + notebook.getNoteManager().getNotesInfo().get(replacementNote)); + assertEquals( + "/~Trash/Beta/old-note", + notebook.getNoteManager().getNotesInfo().get(otherTrashedNote)); + } + + @Test + void reservedTrashRootRequiresDedicatedOperations() throws IOException { + reset(callback); + + notebookService.restoreFolder("/~TrashEvil/folder", context, callback); + verify(callback).onFailure(any(IOException.class), eq(context)); + + reset(callback); + assertNull(notebookService.removeFolder("/~Trash", context, callback)); + verify(callback).onFailure(any(IOException.class), eq(context)); + } + + @Test + void unauthorizedReloadDoesNotReplaceSharedNoteCache() throws IOException { + String noteId = notebookService.createNote( + "/private-note", "test", true, context, callback); + authorizationService.setPermissions( + noteId, Set.of("owner"), Set.of("owner"), Set.of("owner"), Set.of("owner")); + notebook.processNote(noteId, note -> { + note.getInfo().put("unsaved-cache-marker", true); + return null; + }); + ServiceContext intruderContext = new ServiceContext( + new AuthenticationInfo("intruder"), new HashSet<>(Set.of("intruder"))); + reset(callback); + + assertNull(notebookService.getNote(noteId, true, intruderContext, callback, null)); + + verify(callback).onFailure(any(Exception.class), eq(intruderContext)); + notebook.processNote(noteId, note -> { + assertEquals(true, note.getInfo().get("unsaved-cache-marker")); + return null; + }); + } + + @Test + void revisionHistoryRequiresReadPermission() throws IOException { + String noteId = notebookService.createNote( + "/private-revisions", "test", true, context, callback); + authorizationService.setPermissions( + noteId, Set.of("owner"), Set.of("owner"), Set.of("owner"), Set.of("owner")); + ServiceContext intruderContext = new ServiceContext( + new AuthenticationInfo("intruder"), new HashSet<>(Set.of("intruder"))); + reset(callback); + + assertNull(notebookService.listRevisionHistory(noteId, intruderContext, callback)); + + verify(callback).onFailure(any(Exception.class), eq(intruderContext)); + } + @Test void testNoteUpdate() throws IOException { // create note @@ -594,6 +754,20 @@ void testNormalizeNotePath() throws IOException { assertEquals("/Untitled Note", notebookService.normalizeNotePath(null)); assertEquals("/my_note", notebookService.normalizeNotePath("my_note")); assertEquals("/my note", notebookService.normalizeNotePath("my\r\nnote")); + assertEquals( + "Empty path segments are not allowed", + assertThrows( + IOException.class, + () -> notebookService.normalizeNotePath("/victim//folder")).getMessage()); + assertEquals( + "Empty path segments are not allowed", + assertThrows( + IOException.class, + () -> notebookService.normalizeNotePath("/victim%2F%2Ffolder")).getMessage()); + assertTrue(assertThrows( + IOException.class, + () -> notebookService.normalizeNotePath("/victim/./folder")) + .getMessage().startsWith("Path traversal segments are not allowed")); try { String longNoteName = StringUtils.join( @@ -613,14 +787,14 @@ void testNormalizeNotePath() throws IOException { notebookService.normalizeNotePath("%2e%2e/%2e%2e/tmp/test222"); fail("Should fail"); } catch (IOException e) { - assertEquals("Note name can not contain '..'", e.getMessage()); + assertTrue(e.getMessage().startsWith("Path traversal segments are not allowed")); } try { // Double URL encoding of ".." notebookService.normalizeNotePath("%252e%252e/%252e%252e/tmp/test333"); fail("Should fail"); } catch (IOException e) { - assertEquals("Note name can not contain '..'", e.getMessage()); + assertTrue(e.getMessage().startsWith("Path traversal segments are not allowed")); } try { notebookService.normalizeNotePath("%25252525252e%25252525252e/tmp/test444"); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ServiceContextFactoryTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ServiceContextFactoryTest.java new file mode 100644 index 00000000000..a869a01a072 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ServiceContextFactoryTest.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import org.junit.jupiter.api.Test; +import java.util.Set; + +class ServiceContextFactoryTest { + + @Test + void createsAServiceContextFromTheCapturedIdentity() { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", Set.of("reader", "writer"), true, "session-id"); + + ServiceContext context = ServiceContextFactory.create(identity); + + assertEquals("user", context.getAutheInfo().getUser()); + assertEquals(Set.of("reader", "writer"), context.getAutheInfo().getRoles()); + assertNull(context.getAutheInfo().getTicket()); + assertEquals(Set.of("user", "reader", "writer"), context.getUserAndRoles()); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java index f82539e715d..b3e8d87ea5e 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java @@ -16,26 +16,37 @@ */ package org.apache.zeppelin.service; -import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.IOException; import java.security.Principal; import java.sql.Connection; import java.sql.Statement; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.authz.SimpleAuthorizationInfo; +import org.apache.shiro.lang.util.LifecycleUtils; import org.apache.shiro.mgt.DefaultSecurityManager; import org.apache.shiro.realm.jdbc.JdbcRealm; +import org.apache.shiro.session.Session; import org.apache.shiro.subject.Subject; -import org.apache.shiro.util.LifecycleUtils; import org.apache.shiro.util.ThreadContext; import org.apache.zeppelin.conf.ZeppelinConfiguration; +import org.apache.zeppelin.realm.ActiveDirectoryGroupRealm; import org.apache.zeppelin.realm.jwt.KnoxJwtRealm; import org.apache.zeppelin.service.shiro.AbstractShiroTest; import org.h2.jdbcx.JdbcDataSource; @@ -102,6 +113,47 @@ void testUsernameForceLowerCase() throws IOException, InterruptedException { assertEquals(expectedName.toLowerCase(), shiroSecurityService.getPrincipal()); } + @Test + void capturesPrincipalRolesAndSessionFromOneSubject() { + setupPrincipalName("TestUser"); + when(zConf.isUsernameForceLowerCase()).thenReturn(true); + + Session session = mock(Session.class); + when(session.getId()).thenReturn("session-id"); + when(subject.getSession(false)).thenReturn(session); + + KnoxJwtRealm realm = spy(new KnoxJwtRealm()); + LifecycleUtils.init(realm); + doReturn(Set.of("reader")).when(realm).mapGroupPrincipals("testuser"); + DefaultSecurityManager securityManager = new DefaultSecurityManager(realm); + ThreadContext.bind(securityManager); + + AuthenticatedIdentity identity = shiroSecurityService.getAuthenticatedIdentity(); + + assertEquals("testuser", identity.getPrincipal()); + assertEquals(Set.of("reader"), identity.getRoles()); + assertEquals("session-id", identity.getSessionId().orElseThrow()); + assertTrue(identity.isAuthenticated()); + verify(subject, times(1)).isAuthenticated(); + verify(subject, times(1)).getPrincipal(); + verify(subject, times(1)).getSession(false); + } + + @Test + void capturesAnonymousIdentityWithoutLookingUpRolesOrSession() { + when(subject.isAuthenticated()).thenReturn(false); + + AuthenticatedIdentity identity = shiroSecurityService.getAuthenticatedIdentity(); + + assertEquals(AuthenticatedIdentity.ANONYMOUS_PRINCIPAL, identity.getPrincipal()); + assertEquals(Collections.emptySet(), identity.getRoles()); + assertFalse(identity.isAuthenticated()); + assertTrue(identity.getSessionId().isEmpty()); + verify(subject, times(1)).isAuthenticated(); + verify(subject, times(0)).getPrincipal(); + verify(subject, times(0)).getSession(false); + } + @Test void testKnoxGetRoles() { setupPrincipalName("test"); @@ -121,6 +173,24 @@ void testKnoxGetRoles() { assertEquals(testRoles, roles); } + @Test + void capturesActiveDirectoryRolesWithOneAuthorizationQuery() throws Exception { + setupPrincipalName("test"); + + ActiveDirectoryGroupRealm realm = spy(new ActiveDirectoryGroupRealm()); + LifecycleUtils.init(realm); + doReturn(new SimpleAuthorizationInfo(Set.of("role1", "role2"))) + .when(realm).queryForAuthorizationInfo(any()); + DefaultSecurityManager securityManager = new DefaultSecurityManager(realm); + ThreadContext.bind(securityManager); + + Set roles = shiroSecurityService.getAssociatedRoles(); + + assertEquals(Set.of("role1", "role2"), roles); + verify(realm, times(1)).queryForAuthorizationInfo(any()); + verify(subject, never()).hasRole(any()); + } + @AfterEach public void tearDownSubject() { clearSubject(); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/shiro/AbstractShiroTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/shiro/AbstractShiroTest.java index e63d218ca2b..8ff7b274b09 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/service/shiro/AbstractShiroTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/shiro/AbstractShiroTest.java @@ -21,7 +21,7 @@ import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.support.SubjectThreadState; -import org.apache.shiro.util.LifecycleUtils; +import org.apache.shiro.lang.util.LifecycleUtils; import org.apache.shiro.util.ThreadState; import org.junit.jupiter.api.AfterAll; diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/AnonymousWebSocketAuthenticationTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/AnonymousWebSocketAuthenticationTest.java new file mode 100644 index 00000000000..b862d1d4b5f --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/AnonymousWebSocketAuthenticationTest.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.socket; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.TimeUnit; +import org.apache.zeppelin.MiniZeppelinServer; +import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class AnonymousWebSocketAuthenticationTest { + + private static final String ANONYMOUS_WEBSOCKET_SHIRO = + "[main]\n" + + "sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager\n" + + "securityManager.sessionManager = $sessionManager\n" + + "securityManager.rememberMeManager = null\n" + + "[urls]\n" + + "/api/version = anon\n" + + "/ws = anon\n" + + "/** = authc"; + + private static MiniZeppelinServer zepServer; + private static WebSocketClient webSocketClient; + + @BeforeAll + static void startServer() throws Exception { + zepServer = new MiniZeppelinServer(AnonymousWebSocketAuthenticationTest.class.getSimpleName()); + zepServer.addConfigFile("shiro.ini", ANONYMOUS_WEBSOCKET_SHIRO); + zepServer.start(); + zepServer.getZeppelinConfiguration().setProperty( + ConfVars.ZEPPELIN_ALLOWED_ORIGINS.getVarName(), validOrigin()); + webSocketClient = new WebSocketClient(); + webSocketClient.start(); + } + + @AfterAll + static void stopServer() throws Exception { + if (webSocketClient != null) { + webSocketClient.stop(); + } + if (zepServer != null) { + zepServer.destroy(); + } + } + + @Test + void explicitAnonymousRuleAllowsWebSocketWithoutRestSession() throws Exception { + WebSocketAdapter socket = new WebSocketAdapter(); + ClientUpgradeRequest request = new ClientUpgradeRequest(); + request.setHeader("Origin", validOrigin()); + + Session session = webSocketClient.connect(socket, websocketUri(), request) + .get(10, TimeUnit.SECONDS); + + assertTrue(session.isOpen()); + assertTrue(socket.isConnected()); + session.close(); + } + + private static URI websocketUri() { + return URI.create("ws://localhost:" + zepServer.getZeppelinConfiguration().getServerPort() + + "/ws"); + } + + private static String validOrigin() { + return "http://localhost:" + zepServer.getZeppelinConfiguration().getServerPort(); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java index 562d0658949..df3da83d924 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java @@ -22,13 +22,18 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Queue; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -36,13 +41,79 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import jakarta.websocket.CloseReason; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.zeppelin.common.Message; import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.apache.zeppelin.notebook.AuthorizationService; +import org.apache.zeppelin.service.AuthenticatedIdentity; import org.apache.zeppelin.util.WatcherSecurityKey; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; class ConnectionManagerTest { + @Test + void collaborativeStatusUsesTheAuthorizationAwareBroadcastHandler() { + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.isZeppelinNotebookCollaborativeModeEnable()).thenReturn(true); + ConnectionManager manager = new ConnectionManager(mock(AuthorizationService.class), zConf); + ConnectionManager.NoteBroadcastHandler handler = + mock(ConnectionManager.NoteBroadcastHandler.class); + manager.setNoteBroadcastHandler(handler); + NotebookSocket first = mock(NotebookSocket.class); + NotebookSocket second = mock(NotebookSocket.class); + when(first.getUser()).thenReturn("first"); + when(second.getUser()).thenReturn("second"); + + manager.addNoteConnection("note-id", first); + manager.addNoteConnection("note-id", second); + + ArgumentCaptor messages = ArgumentCaptor.forClass(Message.class); + verify(handler, times(2)).broadcast(eq("note-id"), messages.capture()); + Message collaborative = messages.getAllValues().get(1); + assertEquals(Message.OP.COLLABORATIVE_MODE_STATUS, collaborative.op); + assertTrue((Boolean) collaborative.get("status")); + assertEquals(Set.of("first", "second"), collaborative.get("users")); + } + + @Test + void closesOnlyConnectionsFromTheExactAuthenticatedSession() throws Exception { + ConnectionManager manager = new ConnectionManager( + mock(AuthorizationService.class), ZeppelinConfiguration.load()); + NotebookSocket firstSession = mock(NotebookSocket.class); + NotebookSocket watcherSession = mock(NotebookSocket.class); + NotebookSocket secondSession = mock(NotebookSocket.class); + NotebookSocket otherSecurityManager = mock(NotebookSocket.class); + SecurityManager securityManager = mock(SecurityManager.class); + SecurityManager anotherSecurityManager = mock(SecurityManager.class); + when(firstSession.getAuthenticatedIdentity()).thenReturn( + new AuthenticatedIdentity("user1", java.util.Set.of(), true, "session-1")); + when(firstSession.getAuthenticationSecurityManager()).thenReturn(securityManager); + when(watcherSession.getAuthenticatedIdentity()).thenReturn( + new AuthenticatedIdentity("user1", java.util.Set.of(), true, "session-1")); + when(watcherSession.getAuthenticationSecurityManager()).thenReturn(securityManager); + when(secondSession.getAuthenticatedIdentity()).thenReturn( + new AuthenticatedIdentity("user1", java.util.Set.of(), true, "session-2")); + when(secondSession.getAuthenticationSecurityManager()).thenReturn(securityManager); + when(otherSecurityManager.getAuthenticatedIdentity()).thenReturn( + new AuthenticatedIdentity("user1", java.util.Set.of(), true, "session-1")); + when(otherSecurityManager.getAuthenticationSecurityManager()).thenReturn(anotherSecurityManager); + manager.addConnection(firstSession); + manager.watcherSockets.add(watcherSession); + manager.addConnection(secondSession); + manager.addConnection(otherSecurityManager); + + assertEquals(2, manager.closeConnectionsForSession(securityManager, "session-1")); + + verify(firstSession).close(org.mockito.ArgumentMatchers.argThat( + reason -> reason.getCloseCode() == CloseReason.CloseCodes.VIOLATED_POLICY)); + verify(watcherSession).close(org.mockito.ArgumentMatchers.argThat( + reason -> reason.getCloseCode() == CloseReason.CloseCodes.VIOLATED_POLICY)); + verify(secondSession, never()).close(org.mockito.ArgumentMatchers.any()); + verify(otherSecurityManager, never()).close(org.mockito.ArgumentMatchers.any()); + } + @Test void checkMapGrow() { AuthorizationService authService = mock(AuthorizationService.class); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java new file mode 100644 index 00000000000..16183a3db39 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java @@ -0,0 +1,468 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.socket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; +import jakarta.inject.Provider; +import jakarta.websocket.CloseReason; +import jakarta.websocket.Session; +import jakarta.websocket.server.ServerEndpointConfig; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.zeppelin.common.Message; +import org.apache.zeppelin.common.Message.OP; +import org.apache.zeppelin.conf.ZeppelinConfiguration; +import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars; +import org.apache.zeppelin.notebook.AuthorizationService; +import org.apache.zeppelin.notebook.Note; +import org.apache.zeppelin.notebook.Notebook; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticatedSessionService; +import org.apache.zeppelin.service.NotebookService; +import org.apache.zeppelin.service.ServiceContext; +import org.apache.zeppelin.service.SessionAuthenticationException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class NotebookServerAuthenticationTest { + + @Test + void noteExportRequiresReaderPermissionBeforeAccessingNotebookData() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of("role1"), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + Provider notebookProvider = mock(Provider.class); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setNotebook(notebookProvider); + NotebookSocket socket = authenticatedSocket(identity, securityManager); + + server.onMessage(socket, + new Message(OP.CONVERT_NOTE_NBFORMAT).put("noteId", "private-note").toJson()); + + verify(authorizationService).isReader( + "private-note", Set.of("user1", "role1")); + verify(notebookProvider, never()).get(); + } + + @Test + void interpreterBindingChangesRequireWriterPermission() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + Provider notebookProvider = mock(Provider.class); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setNotebook(notebookProvider); + + server.onMessage(authenticatedSocket(identity, securityManager), + new Message(OP.SAVE_INTERPRETER_BINDINGS) + .put("noteId", "private-note") + .put("selectedSettingIds", "[]") + .toJson()); + + verify(authorizationService).isWriter("private-note", Set.of("user1")); + verify(notebookProvider, never()).get(); + } + + @Test + void angularObjectMutationRequiresRunnerPermission() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + Provider notebookProvider = mock(Provider.class); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setNotebook(notebookProvider); + + server.onMessage(authenticatedSocket(identity, securityManager), + new Message(OP.ANGULAR_OBJECT_CLIENT_BIND) + .put("noteId", "private-note") + .put("paragraphId", "paragraph-id") + .put("name", "value") + .toJson()); + + verify(authorizationService).isRunner("private-note", Set.of("user1")); + verify(notebookProvider, never()).get(); + } + + @Test + void logoutBetweenHandshakeValidationAndRegistrationStillClosesTheSocket() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(identity, securityManager, false)).thenReturn(identity); + org.mockito.Mockito.doThrow(new SessionAuthenticationException("logged out")) + .when(sessionService).validate(identity, securityManager); + NotebookServer server = server(sessionService, mock(NotebookService.class)); + Session session = mock(Session.class); + when(session.getId()).thenReturn("websocket-id"); + ServerEndpointConfig endpointConfig = ServerEndpointConfig.Builder + .create(NotebookServer.class, "/ws") + .build(); + endpointConfig.getUserProperties().put(SessionConfigurator.AUTHENTICATED_IDENTITY, identity); + endpointConfig.getUserProperties().put( + SessionConfigurator.AUTHENTICATION_SECURITY_MANAGER, securityManager); + + server.onOpen(session, endpointConfig); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session).close(closeReason.capture()); + assertEquals(CloseReason.CloseCodes.VIOLATED_POLICY, + closeReason.getValue().getCloseCode()); + } + + @Test + void clientSuppliedIdentityFieldsCannotOverrideTheAuthenticatedSession() throws Exception { + AuthenticatedIdentity connectionIdentity = + new AuthenticatedIdentity("user1", Set.of("role1"), true, "session-id"); + AuthenticatedIdentity refreshedIdentity = + new AuthenticatedIdentity("user1", Set.of("role2"), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(connectionIdentity, securityManager, true)) + .thenReturn(refreshedIdentity); + NotebookService notebookService = mock(NotebookService.class); + NotebookServer server = server(sessionService, notebookService); + NotebookSocket socket = mock(NotebookSocket.class); + when(socket.getAuthenticatedIdentity()).thenReturn(connectionIdentity); + when(socket.getAuthenticationSecurityManager()).thenReturn(securityManager); + when(socket.getUser()).thenReturn("user1"); + + Message message = new Message(OP.LIST_NOTES); + message.principal = "admin"; + message.roles = "[\"admin\"]"; + message.ticket = "forged-ticket"; + server.onMessage(socket, message.toJson()); + + ArgumentCaptor context = ArgumentCaptor.forClass(ServiceContext.class); + verify(notebookService).listNotesInfo(eq(false), context.capture(), any()); + assertEquals("user1", context.getValue().getAutheInfo().getUser()); + assertEquals(Set.of("role2"), context.getValue().getAutheInfo().getRoles()); + assertNull(context.getValue().getAutheInfo().getTicket()); + } + + @Test + void invalidSessionClosesTheSocketWithPolicyViolationBeforeDispatch() throws Exception { + AuthenticatedIdentity connectionIdentity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(connectionIdentity, securityManager, true)).thenThrow( + new SessionAuthenticationException("expired")); + NotebookService notebookService = mock(NotebookService.class); + NotebookServer server = server(sessionService, notebookService); + NotebookSocket socket = mock(NotebookSocket.class); + when(socket.getAuthenticatedIdentity()).thenReturn(connectionIdentity); + when(socket.getAuthenticationSecurityManager()).thenReturn(securityManager); + + server.onMessage(socket, new Message(OP.LIST_NOTES).toJson()); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(socket).close(closeReason.capture()); + assertEquals(CloseReason.CloseCodes.VIOLATED_POLICY, + closeReason.getValue().getCloseCode()); + verify(notebookService, never()).listNotesInfo(any(Boolean.class), any(), any()); + verify(socket, never()).send(any()); + } + + @Test + void pingRevalidatesWithoutTouchingTheSession() { + AuthenticatedIdentity connectionIdentity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + NotebookServer server = server(sessionService, mock(NotebookService.class)); + NotebookSocket socket = mock(NotebookSocket.class); + when(socket.getAuthenticatedIdentity()).thenReturn(connectionIdentity); + when(socket.getAuthenticationSecurityManager()).thenReturn(securityManager); + when(socket.getUser()).thenReturn("user1"); + + server.onMessage(socket, new Message(OP.PING).toJson()); + + verify(sessionService).validate(connectionIdentity, securityManager); + verify(sessionService, never()).refresh(any(), any(), anyBoolean()); + verify(sessionService, never()).refresh(any(), any(), anyBoolean(), anyLong()); + } + + @Test + void jobUpdatesAreSentOnlyToSubscribersWhoOwnTheNote() throws Exception { + AuthenticatedIdentity allowedIdentity = + new AuthenticatedIdentity("reader", Set.of("reader-role"), true, "reader-session"); + AuthenticatedIdentity deniedIdentity = + new AuthenticatedIdentity("other", Set.of(), true, "other-session"); + NotebookSocket allowed = mock(NotebookSocket.class); + NotebookSocket denied = mock(NotebookSocket.class); + SecurityManager allowedSecurityManager = mock(SecurityManager.class); + SecurityManager deniedSecurityManager = mock(SecurityManager.class); + when(allowed.getAuthenticatedIdentity()).thenReturn(allowedIdentity); + when(denied.getAuthenticatedIdentity()).thenReturn(deniedIdentity); + when(allowed.getAuthenticationSecurityManager()).thenReturn(allowedSecurityManager); + when(denied.getAuthenticationSecurityManager()).thenReturn(deniedSecurityManager); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(allowedIdentity, allowedSecurityManager, false, 1_000L)) + .thenReturn(allowedIdentity); + when(sessionService.refresh(deniedIdentity, deniedSecurityManager, false, 1_000L)) + .thenReturn(deniedIdentity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + when(authorizationService.isOwner(Set.of("reader", "reader-role"), "note-id")) + .thenReturn(true); + ConnectionManager connectionManager = mock(ConnectionManager.class); + when(connectionManager.getNoteConnections("JOB_MANAGER_PAGE")) + .thenReturn(List.of(allowed, denied)); + NotebookServer server = server( + sessionService, + mock(NotebookService.class), + authorizationService); + server.setConnectionManager(connectionManager); + Note note = mock(Note.class); + when(note.getId()).thenReturn("note-id"); + Message message = new Message(OP.LIST_UPDATE_NOTE_JOBS); + + server.broadcastJobUpdateToAuthorizedSubscribers(note, message); + + verify(allowed).send(server.serializeMessage(message)); + verify(denied, never()).send(any()); + } + + @Test + void noteBroadcastsStopImmediatelyAfterReadAccessIsRevoked() throws Exception { + AuthenticatedIdentity connectionIdentity = + new AuthenticatedIdentity("user", Set.of("former-reader"), true, "session-id"); + AuthenticatedIdentity refreshedIdentity = + new AuthenticatedIdentity("user", Set.of(), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + NotebookSocket connection = authenticatedSocket(connectionIdentity, securityManager); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(connectionIdentity, securityManager, false, 1_000L)) + .thenReturn(refreshedIdentity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + ConnectionManager connectionManager = mock(ConnectionManager.class); + when(connectionManager.getNoteConnections("note-id")) + .thenReturn(List.of(connection)); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setConnectionManager(connectionManager); + + server.broadcastToAuthorizedNoteSubscribers( + "note-id", new Message(OP.NOTE).put("note", "private-content")); + + verify(authorizationService).isReader("note-id", Set.of("user")); + verify(connectionManager).removeNoteConnection("note-id", connection); + verify(connection, never()).send(any()); + } + + @Test + void noteBroadcastsUseBoundedRoleSnapshotForAuthorization() throws Exception { + AuthenticatedIdentity connectionIdentity = + new AuthenticatedIdentity("user", Set.of(), true, "session-id"); + AuthenticatedIdentity refreshedIdentity = + new AuthenticatedIdentity("user", Set.of("new-reader"), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + NotebookSocket connection = authenticatedSocket(connectionIdentity, securityManager); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(connectionIdentity, securityManager, false, 1_000L)) + .thenReturn(refreshedIdentity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + when(authorizationService.isReader("note-id", Set.of("user", "new-reader"))) + .thenReturn(true); + ConnectionManager connectionManager = mock(ConnectionManager.class); + when(connectionManager.getNoteConnections("note-id")) + .thenReturn(List.of(connection)); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setConnectionManager(connectionManager); + Message message = new Message(OP.NOTE).put("note", "private-content"); + + server.broadcastToAuthorizedNoteSubscribers("note-id", message); + + verify(connection).send(server.serializeMessage(message)); + } + + @Test + void jobUpdatesUseBoundedRoleSnapshotAfterRoleRevocation() throws Exception { + AuthenticatedIdentity connectionIdentity = + new AuthenticatedIdentity("user", Set.of("owner-role"), true, "session-id"); + AuthenticatedIdentity refreshedIdentity = + new AuthenticatedIdentity("user", Set.of(), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + NotebookSocket connection = authenticatedSocket(connectionIdentity, securityManager); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(connectionIdentity, securityManager, false, 1_000L)) + .thenReturn(refreshedIdentity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + ConnectionManager connectionManager = mock(ConnectionManager.class); + when(connectionManager.getNoteConnections("JOB_MANAGER_PAGE")) + .thenReturn(List.of(connection)); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setConnectionManager(connectionManager); + Note note = mock(Note.class); + when(note.getId()).thenReturn("note-id"); + + server.broadcastJobUpdateToAuthorizedSubscribers( + note, new Message(OP.LIST_UPDATE_NOTE_JOBS)); + + verify(authorizationService).isOwner(Set.of("user"), "note-id"); + verify(connection, never()).send(any()); + } + + @Test + void invalidJobSubscriberIsClosedAndUnsubscribed() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", Set.of(), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + NotebookSocket connection = authenticatedSocket(identity, securityManager); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(identity, securityManager, false, 1_000L)) + .thenThrow(new SessionAuthenticationException("expired")); + ConnectionManager connectionManager = mock(ConnectionManager.class); + when(connectionManager.getNoteConnections("JOB_MANAGER_PAGE")) + .thenReturn(List.of(connection)); + NotebookServer server = server(sessionService, mock(NotebookService.class)); + server.setConnectionManager(connectionManager); + Note note = mock(Note.class); + when(note.getId()).thenReturn("note-id"); + + server.broadcastJobUpdateToAuthorizedSubscribers( + note, new Message(OP.LIST_UPDATE_NOTE_JOBS)); + + verify(connectionManager).removeNoteConnection("JOB_MANAGER_PAGE", connection); + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(connection).close(closeReason.capture()); + assertEquals( + CloseReason.CloseCodes.VIOLATED_POLICY, closeReason.getValue().getCloseCode()); + } + + @Test + void noteListBroadcastUsesBoundedRoleSnapshot() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", Set.of("reader-role"), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + NotebookSocket connection = authenticatedSocket(identity, securityManager); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(identity, securityManager, false, 1_000L)) + .thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + ConnectionManager connectionManager = mock(ConnectionManager.class); + when(connectionManager.getConnections()).thenReturn(List.of(connection)); + Notebook notebook = mock(Notebook.class); + when(notebook.getNotesInfo(any())).thenReturn(List.of()); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setConnectionManager(connectionManager); + server.setNotebook(() -> notebook); + + server.broadcastNoteListUpdate(); + + verify(sessionService).refresh(identity, securityManager, false, 1_000L); + verify(connectionManager).unicast(any(Message.class), eq(connection)); + } + + @Test + void repositoryReloadRequiresTheConfiguredAdministratorRole() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", Set.of("reader"), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + Provider notebookProvider = mock(Provider.class); + NotebookServer server = server(sessionService, mock(NotebookService.class)); + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.getString(ConfVars.ZEPPELIN_OWNER_ROLE)).thenReturn("admin"); + server.setZeppelinConfiguration(zConf); + server.setNotebook(notebookProvider); + + server.onMessage( + authenticatedSocket(identity, securityManager), + new Message(OP.RELOAD_NOTES_FROM_REPO).toJson()); + + verify(notebookProvider, never()).get(); + } + + @Test + void repositoryReloadAllowsTheConfiguredAdministratorRole() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user", Set.of("admin"), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + Notebook notebook = mock(Notebook.class); + NotebookServer server = server(sessionService, mock(NotebookService.class)); + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.getString(ConfVars.ZEPPELIN_OWNER_ROLE)).thenReturn("admin"); + server.setZeppelinConfiguration(zConf); + server.setNotebook(() -> notebook); + + server.onMessage( + authenticatedSocket(identity, securityManager), + new Message(OP.RELOAD_NOTES_FROM_REPO).toJson()); + + verify(notebook).reloadAllNotes(any()); + } + + private static NotebookServer server( + AuthenticatedSessionService sessionService, NotebookService notebookService) { + return server(sessionService, notebookService, mock(AuthorizationService.class)); + } + + private static NotebookServer server( + AuthenticatedSessionService sessionService, + NotebookService notebookService, + AuthorizationService authorizationService) { + NotebookServer server = new NotebookServer(); + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.getWebsocketAuthorizationRolesRefreshIntervalMs()).thenReturn(1_000L); + server.setZeppelinConfiguration(zConf); + server.setAuthenticatedSessionService(sessionService); + server.setNotebookService(() -> notebookService); + server.setConnectionManager(mock(ConnectionManager.class)); + server.setAuthorizationService(authorizationService); + return server; + } + + private static NotebookSocket authenticatedSocket( + AuthenticatedIdentity identity, SecurityManager securityManager) { + NotebookSocket socket = mock(NotebookSocket.class); + when(socket.getAuthenticatedIdentity()).thenReturn(identity); + when(socket.getAuthenticationSecurityManager()).thenReturn(securityManager); + when(socket.getUser()).thenReturn(identity.getPrincipal()); + return socket; + } +} 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 d982d46a33c..bf896de2a02 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 @@ -26,17 +26,15 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.awaitility.Awaitility.await; import java.io.IOException; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; @@ -71,6 +69,7 @@ import org.apache.zeppelin.scheduler.Job; import org.apache.zeppelin.scheduler.Job.Status; import org.apache.zeppelin.service.NotebookService; +import org.apache.zeppelin.service.AuthenticatedIdentity; import org.apache.zeppelin.service.ServiceContext; import org.apache.zeppelin.user.AuthenticationInfo; import org.junit.jupiter.api.AfterAll; @@ -119,8 +118,8 @@ void setUp() { } @Test - void checkOrigin() throws UnknownHostException { - String origin = "http://" + InetAddress.getLocalHost().getHostName() + ":8080"; + void checkOrigin() { + String origin = getRequestOriginToTest(zConf); assertTrue(notebookServer.checkOrigin(origin), "Origin " + origin + " is not allowed. Please check your hostname."); } @@ -155,7 +154,7 @@ void testBroadcastUpdateNoteJobInfo_whenJobManagerDisabled() { try { assertDoesNotThrow(() -> { - notebookServer.broadcastUpdateNoteJobInfo(mockNote, System.currentTimeMillis()); + notebookServer.broadcastUpdateNoteJobInfo(mockNote); }, "broadcastUpdateNoteJobInfo should not throw exception when job manager is disabled"); } finally { restoreJobManagerFlag(originalFlag); @@ -274,24 +273,24 @@ void testCollaborativeEditing() throws IOException { int sock1SendCount = 0; int sock2SendCount = 0; - reset(sock1); - reset(sock2); - patchParagraph(sock1, paragraphId, patches[0]); + clearInvocations(sock1); + clearInvocations(sock2); + patchParagraph(sock1, createdNoteInfo.getId(), paragraphId, patches[0]); assertEquals("ABC", paragraph.getText()); verify(sock1, times(sock1SendCount)).send(anyString()); verify(sock2, times(++sock2SendCount)).send(anyString()); - patchParagraph(sock2, paragraphId, patches[1]); + patchParagraph(sock2, createdNoteInfo.getId(), paragraphId, patches[1]); assertEquals("ABC\n", paragraph.getText()); verify(sock1, times(++sock1SendCount)).send(anyString()); verify(sock2, times(sock2SendCount)).send(anyString()); - patchParagraph(sock1, paragraphId, patches[2]); + patchParagraph(sock1, createdNoteInfo.getId(), paragraphId, patches[2]); assertEquals("ABC\nabc", paragraph.getText()); verify(sock1, times(sock1SendCount)).send(anyString()); verify(sock2, times(++sock2SendCount)).send(anyString()); - patchParagraph(sock2, paragraphId, patches[3]); + patchParagraph(sock2, createdNoteInfo.getId(), paragraphId, patches[3]); assertEquals("ABC ssss\nabc ssss", paragraph.getText()); verify(sock1, times(++sock1SendCount)).send(anyString()); verify(sock2, times(sock2SendCount)).send(anyString()); @@ -299,10 +298,12 @@ void testCollaborativeEditing() throws IOException { notebook.removeNote(createdNoteInfo.getId(), anonymous); } - private void patchParagraph(NotebookSocket noteSocket, String paragraphId, String patch) { + private void patchParagraph( + NotebookSocket noteSocket, String noteId, String paragraphId, String patch) { Message message = new Message(OP.PATCH_PARAGRAPH); message.put("patch", patch); message.put("id", paragraphId); + message.put("noteId", noteId); notebookServer.onMessage(noteSocket, message.toJson()); } @@ -363,8 +364,8 @@ void testMakeSureNoAngularObjectBroadcastToWebsocketWhoFireTheEvent() notebookServer.onMessage(sock1, new Message(OP.GET_NOTE).put("id", note1Id).toJson()); notebookServer.onMessage(sock2, new Message(OP.GET_NOTE).put("id", note1Id).toJson()); - reset(sock1); - reset(sock2); + clearInvocations(sock1); + clearInvocations(sock2); // update object from sock1 notebookServer.onMessage(sock1, @@ -433,7 +434,7 @@ void testAngularObjectSaveToNote() // open the same notebook from sockets notebookServer.onMessage(sock1, new Message(OP.GET_NOTE).put("id", note1Id).toJson()); - reset(sock1); + clearInvocations(sock1); // bind object from sock1 notebookServer.onMessage(sock1, @@ -654,6 +655,7 @@ void bindAngularObjectToRemoteForParagraphs() throws Exception { .put("value", value) .put("paragraphId", "paragraphId"); + authorizationService.createNoteAuth("noteId", anonymous); try { final Notebook notebook = mock(Notebook.class); notebookServer.setNotebook(() -> notebook); @@ -676,8 +678,8 @@ void bindAngularObjectToRemoteForParagraphs() throws Exception { when(mdRegistry.addAndNotifyRemoteProcess(varName, value, "noteId", "paragraphId")) .thenReturn(ao1); - NotebookSocket conn = mock(NotebookSocket.class); - NotebookSocket otherConn = mock(NotebookSocket.class); + NotebookSocket conn = createWebSocket(); + NotebookSocket otherConn = createWebSocket(); final String mdMsg1 = notebookServer.serializeMessage(new Message(OP.ANGULAR_OBJECT_UPDATE) .put("angularObject", ao1) @@ -691,7 +693,10 @@ void bindAngularObjectToRemoteForParagraphs() throws Exception { notebookServer.getConnectionManager().noteSocketMap.put("noteId", sockets); // When - notebookServer.angularObjectClientBind(conn, messageReceived); + notebookServer.angularObjectClientBind( + conn, + new ServiceContext(AuthenticationInfo.ANONYMOUS, Set.of("anonymous")), + messageReceived); // Then verify(mdRegistry, never()).addAndNotifyRemoteProcess(varName, value, "noteId", null); @@ -699,6 +704,8 @@ void bindAngularObjectToRemoteForParagraphs() throws Exception { verify(otherConn).send(mdMsg1); } finally { // reset these so that it won't affect other tests + notebookServer.getConnectionManager().noteSocketMap.remove("noteId"); + authorizationService.removeNoteAuth("noteId"); notebookServer.setNotebook(() -> NotebookServerTest.notebook); notebookServer.setNotebookService(() -> NotebookServerTest.notebookService); } @@ -714,6 +721,7 @@ void unbindAngularObjectFromRemoteForParagraphs() throws Exception { .put("name", varName) .put("paragraphId", "paragraphId"); + authorizationService.createNoteAuth("noteId", anonymous); try { final Notebook notebook = mock(Notebook.class); notebookServer.setNotebook(() -> notebook); @@ -732,8 +740,8 @@ void unbindAngularObjectFromRemoteForParagraphs() throws Exception { final AngularObject ao1 = AngularObjectBuilder.build(varName, value, "noteId", "paragraphId"); when(mdRegistry.removeAndNotifyRemoteProcess(varName, "noteId", "paragraphId")).thenReturn(ao1); - NotebookSocket conn = mock(NotebookSocket.class); - NotebookSocket otherConn = mock(NotebookSocket.class); + NotebookSocket conn = createWebSocket(); + NotebookSocket otherConn = createWebSocket(); final String mdMsg1 = notebookServer.serializeMessage(new Message(OP.ANGULAR_OBJECT_REMOVE) .put("angularObject", ao1) @@ -747,7 +755,10 @@ void unbindAngularObjectFromRemoteForParagraphs() throws Exception { notebookServer.getConnectionManager().noteSocketMap.put("noteId", sockets); // When - notebookServer.angularObjectClientUnbind(conn, messageReceived); + notebookServer.angularObjectClientUnbind( + conn, + new ServiceContext(AuthenticationInfo.ANONYMOUS, Set.of("anonymous")), + messageReceived); // Then verify(mdRegistry, never()).removeAndNotifyRemoteProcess(varName, "noteId", null); @@ -755,6 +766,8 @@ void unbindAngularObjectFromRemoteForParagraphs() throws Exception { verify(otherConn).send(mdMsg1); } finally { // reset these so that it won't affect other tests + notebookServer.getConnectionManager().noteSocketMap.remove("noteId"); + authorizationService.removeNoteAuth("noteId"); notebookServer.setNotebook(() -> NotebookServerTest.notebook); notebookServer.setNotebookService(() -> NotebookServerTest.notebookService); } @@ -966,6 +979,7 @@ void testNoteRevision() throws IOException { private NotebookSocket createWebSocket() { NotebookSocket sock = mock(NotebookSocket.class); + when(sock.getAuthenticatedIdentity()).thenReturn(AuthenticatedIdentity.anonymous()); return sock; } diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookSocketTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookSocketTest.java new file mode 100644 index 00000000000..abae3a43433 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookSocketTest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.socket; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import jakarta.websocket.CloseReason; +import jakarta.websocket.Session; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticatedSessionService; +import org.apache.zeppelin.service.SessionAuthenticationException; +import org.junit.jupiter.api.Test; + +class NotebookSocketTest { + + @Test + void outboundMessagesCloseAnExpiredSessionBeforeSending() throws Exception { + Session session = mock(Session.class); + when(session.getId()).thenReturn("websocket-id"); + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + SecurityManager securityManager = mock(SecurityManager.class); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + org.mockito.Mockito.doThrow(new SessionAuthenticationException("expired")) + .when(sessionService).validate(identity, securityManager); + NotebookSocket socket = + new NotebookSocket(session, Map.of(), identity, securityManager, sessionService); + + assertThrows(IOException.class, () -> socket.send("message")); + + verify(session).close(any(CloseReason.class)); + verify(session, never()).getAsyncRemote(); + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java new file mode 100644 index 00000000000..e9ad358995e --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.socket; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import jakarta.websocket.HandshakeResponse; +import jakarta.websocket.server.HandshakeRequest; +import jakarta.websocket.server.ServerEndpointConfig; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.util.ThreadContext; +import org.apache.zeppelin.conf.ZeppelinConfiguration; +import org.apache.zeppelin.service.AuthenticatedIdentity; +import org.apache.zeppelin.service.AuthenticationService; +import org.apache.zeppelin.util.WatcherSecurityKey; +import org.apache.zeppelin.utils.CorsUtils; +import org.glassfish.hk2.api.ServiceLocator; +import org.junit.jupiter.api.Test; + +class SessionConfiguratorTest { + + @Test + void rejectsInvalidOriginsBeforeTheEndpointIsOpened() { + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.getAllowedOrigins()).thenReturn(List.of("https://trusted.example")); + ServiceLocator serviceLocator = serviceLocator(zConf, mock(AuthenticationService.class)); + SessionConfigurator configurator = new SessionConfigurator(serviceLocator); + + assertTrue(configurator.checkOrigin("https://trusted.example")); + assertFalse(configurator.checkOrigin("https://evil.example")); + assertFalse(configurator.checkOrigin("not a uri")); + } + + @Test + void defaultLocalOriginMustMatchTheServerSchemeAndPort() { + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + when(zConf.getAllowedOrigins()).thenReturn(List.of()); + when(zConf.getServerPort()).thenReturn(8080); + ServiceLocator serviceLocator = serviceLocator(zConf, mock(AuthenticationService.class)); + SessionConfigurator configurator = new SessionConfigurator(serviceLocator); + + assertTrue(configurator.checkOrigin("http://localhost:8080")); + assertFalse(configurator.checkOrigin("http://localhost:8081")); + assertFalse(configurator.checkOrigin("https://localhost:8080")); + } + + @Test + void capturesTheServerAuthenticatedIdentityInPerHandshakeProperties() { + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", java.util.Set.of("role1"), true, "session-id"); + when(authenticationService.getAuthenticatedIdentity()).thenReturn(identity); + SessionConfigurator configurator = + new SessionConfigurator(serviceLocator(zConf, authenticationService)); + ServerEndpointConfig endpointConfig = ServerEndpointConfig.Builder + .create(NotebookServer.class, "/ws") + .build(); + HandshakeRequest request = mock(HandshakeRequest.class); + when(request.getHeaders()).thenReturn(Map.of( + WatcherSecurityKey.HTTP_HEADER, List.of("watcher-key"), + CorsUtils.HEADER_ORIGIN, List.of("https://trusted.example"))); + SecurityManager securityManager = mock(SecurityManager.class); + + ThreadContext.bind(securityManager); + try { + configurator.modifyHandshake(endpointConfig, request, mock(HandshakeResponse.class)); + } finally { + ThreadContext.unbindSecurityManager(); + } + + assertSame(identity, + endpointConfig.getUserProperties().get(SessionConfigurator.AUTHENTICATED_IDENTITY)); + assertSame(securityManager, + endpointConfig.getUserProperties().get( + SessionConfigurator.AUTHENTICATION_SECURITY_MANAGER)); + } + + private static ServiceLocator serviceLocator( + ZeppelinConfiguration zConf, AuthenticationService authenticationService) { + ServiceLocator serviceLocator = mock(ServiceLocator.class); + when(serviceLocator.getService(ZeppelinConfiguration.class)).thenReturn(zConf); + when(serviceLocator.getService(AuthenticationService.class)).thenReturn(authenticationService); + return serviceLocator; + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/WebSocketAuthenticationTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/WebSocketAuthenticationTest.java new file mode 100644 index 00000000000..5ca9a7cd5e8 --- /dev/null +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/WebSocketAuthenticationTest.java @@ -0,0 +1,291 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.socket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.gson.Gson; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.http.Header; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicNameValuePair; +import org.apache.zeppelin.MiniZeppelinServer; +import org.apache.zeppelin.common.Message; +import org.apache.zeppelin.common.Message.OP; +import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars; +import org.apache.zeppelin.notebook.AuthorizationService; +import org.apache.zeppelin.rest.AbstractTestRestApi; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; +import org.eclipse.jetty.websocket.api.exceptions.UpgradeException; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class WebSocketAuthenticationTest extends AbstractTestRestApi { + + private static final Gson GSON = new Gson(); + private static MiniZeppelinServer zepServer; + private static WebSocketClient webSocketClient; + + @BeforeAll + static void startServer() throws Exception { + zepServer = new MiniZeppelinServer(WebSocketAuthenticationTest.class.getSimpleName()); + zepServer.addConfigFile("shiro.ini", ZEPPELIN_SHIRO); + zepServer.start(); + zepServer.getZeppelinConfiguration().setProperty( + ConfVars.ZEPPELIN_ALLOWED_ORIGINS.getVarName(), + "http://localhost:" + zepServer.getZeppelinConfiguration().getServerPort()); + webSocketClient = new WebSocketClient(); + webSocketClient.getHttpClient().setFollowRedirects(false); + webSocketClient.start(); + } + + @AfterAll + static void stopServer() throws Exception { + if (webSocketClient != null) { + webSocketClient.stop(); + } + if (zepServer != null) { + zepServer.destroy(); + } + } + + @BeforeEach + void setUpConfiguration() { + zConf = zepServer.getZeppelinConfiguration(); + } + + @Test + void handshakeWithoutAnAuthenticatedRestSessionIsRejected() throws Exception { + HttpGet upgrade = new HttpGet(websocketUri().toString().replaceFirst("^ws", "http")); + upgrade.setHeader("Connection", "Upgrade"); + upgrade.setHeader("Upgrade", "websocket"); + upgrade.setHeader("Sec-WebSocket-Version", "13"); + upgrade.setHeader("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="); + upgrade.setHeader("Origin", validOrigin()); + + try (CloseableHttpClient client = HttpClients.custom().disableRedirectHandling().build(); + CloseableHttpResponse response = client.execute(upgrade)) { + assertEquals(302, response.getStatusLine().getStatusCode()); + Header location = response.getFirstHeader("Location"); + assertNotNull(location); + assertEquals("/api/login", URI.create(location.getValue()).getPath()); + } + } + + @Test + void invalidOriginIsRejectedBeforeWebSocketOpen() throws Exception { + String cookie = getCookie("user1", "password2"); + TestSocket socket = new TestSocket(); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> connect(socket, cookie, "https://evil.example").get(10, TimeUnit.SECONDS)); + + UpgradeException upgradeException = findUpgradeException(failure); + assertNotNull(upgradeException); + assertEquals(403, upgradeException.getResponseStatusCode()); + assertFalse(socket.isConnected()); + } + + @Test + void websocketUsesTheRestSessionAndIgnoresForgedMessageIdentity() throws Exception { + String cookie = getCookie("user1", "password2"); + TestSocket socket = new TestSocket(); + Session session = connect(socket, cookie, validOrigin()).get(10, TimeUnit.SECONDS); + String noteName = "/websocket-auth-" + System.nanoTime(); + Message createNote = new Message(OP.NEW_NOTE).put("name", noteName); + createNote.principal = "user2"; + createNote.roles = "[\"admin\"]"; + createNote.ticket = "forged-ticket"; + + session.getRemote().sendString(createNote.toJson()); + Message response = socket.awaitMessage(OP.NEW_NOTE); + @SuppressWarnings("unchecked") + String noteId = String.valueOf(((Map) response.get("note")).get("id")); + + AuthorizationService authorizationService = zepServer.getService(AuthorizationService.class); + assertEquals(Set.of("user1"), authorizationService.getOwners(noteId)); + session.close(); + } + + @Test + void classicWebAppUsesTheSecurityManagerThatAuthenticatedItsRestSession() throws Exception { + String cookie = getCookieFromContext("/classic/api", "user1", "password2"); + TestSocket socket = new TestSocket(); + Session session = connect( + socket, + cookie, + validOrigin(), + URI.create("ws://localhost:" + zConf.getServerPort() + "/classic/ws")) + .get(10, TimeUnit.SECONDS); + + session.getRemote().sendString(new Message(OP.LIST_NOTES).toJson()); + socket.awaitMessage(OP.NOTES_INFO); + session.close(); + } + + @Test + void restLogoutClosesTheWebSocketForThatSession() throws Exception { + String cookie = getCookie("user1", "password2"); + TestSocket socket = new TestSocket(); + connect(socket, cookie, validOrigin()).get(10, TimeUnit.SECONDS); + + HttpPost logout = new HttpPost(getUrlToTest(zConf) + "/login/logout"); + logout.setHeader("Origin", getOriginToTest(zConf)); + logout.setHeader("Cookie", "JSESSIONID=" + cookie); + try (CloseableHttpResponse ignored = getHttpClient().execute(logout)) { + assertEquals(1008, socket.awaitCloseCode()); + } + } + + @Test + void reloginClosesTheWebSocketForThePreviousSession() throws Exception { + String cookie = getCookie("user1", "password2"); + TestSocket socket = new TestSocket(); + connect(socket, cookie, validOrigin()).get(10, TimeUnit.SECONDS); + + HttpPost login = new HttpPost(getUrlToTest(zConf) + "/login"); + login.setHeader("Origin", getOriginToTest(zConf)); + login.setHeader("Cookie", "JSESSIONID=" + cookie); + login.setEntity(new UrlEncodedFormEntity(List.of( + new BasicNameValuePair("userName", "user2"), + new BasicNameValuePair("password", "password3")), StandardCharsets.UTF_8)); + try (CloseableHttpResponse response = getHttpClient().execute(login)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + assertEquals(1008, socket.awaitCloseCode()); + } + } + + private CompletableFuture connect( + TestSocket socket, String sessionCookie, String origin) throws Exception { + return connect(socket, sessionCookie, origin, websocketUri()); + } + + private CompletableFuture connect( + TestSocket socket, String sessionCookie, String origin, URI uri) throws Exception { + ClientUpgradeRequest request = new ClientUpgradeRequest(); + request.setHeader("Origin", origin); + if (sessionCookie != null) { + request.setHeader("Cookie", "JSESSIONID=" + sessionCookie); + } + return webSocketClient.connect(socket, uri, request); + } + + private String getCookieFromContext( + String apiPath, String userName, String password) throws Exception { + HttpPost login = new HttpPost( + "http://localhost:" + zConf.getServerPort() + apiPath + "/login"); + login.setHeader("Origin", validOrigin()); + login.setEntity(new UrlEncodedFormEntity(List.of( + new BasicNameValuePair("userName", userName), + new BasicNameValuePair("password", password)), StandardCharsets.UTF_8)); + try (CloseableHttpResponse response = getHttpClient().execute(login)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + Pattern sessionCookie = Pattern.compile("JSESSIONID=([a-zA-Z0-9-]+)"); + String finalSessionCookie = null; + for (Header header : response.getHeaders("Set-Cookie")) { + Matcher matcher = sessionCookie.matcher(header.getValue()); + if (matcher.find()) { + finalSessionCookie = matcher.group(1); + } + } + if (finalSessionCookie != null) { + return finalSessionCookie; + } + } + throw new AssertionError("Login did not issue a JSESSIONID cookie for " + apiPath); + } + + private URI websocketUri() { + return URI.create("ws://localhost:" + zConf.getServerPort() + "/ws"); + } + + private String validOrigin() { + return "http://localhost:" + zConf.getServerPort(); + } + + private static UpgradeException findUpgradeException(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof UpgradeException) { + return (UpgradeException) current; + } + current = current.getCause(); + } + return null; + } + + private static final class TestSocket extends WebSocketAdapter { + private final BlockingQueue messages = new LinkedBlockingQueue<>(); + private final CompletableFuture closeCode = new CompletableFuture<>(); + + @Override + public void onWebSocketText(String message) { + messages.add(message); + } + + @Override + public void onWebSocketClose(int statusCode, String reason) { + closeCode.complete(statusCode); + super.onWebSocketClose(statusCode, reason); + } + + Message awaitMessage(OP expectedOperation) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + long remaining = deadline - System.nanoTime(); + String serialized = messages.poll(remaining, TimeUnit.NANOSECONDS); + if (serialized == null) { + break; + } + Message message = GSON.fromJson(serialized, Message.class); + if (message.op == expectedOperation) { + return message; + } + } + throw new AssertionError("Did not receive WebSocket operation " + expectedOperation); + } + + int awaitCloseCode() throws Exception { + return closeCode.get(10, TimeUnit.SECONDS); + } + } +} diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/utils/CorsUtilsTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/utils/CorsUtilsTest.java index 3d6718f2997..b4e0581d852 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/utils/CorsUtilsTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/utils/CorsUtilsTest.java @@ -42,13 +42,21 @@ void isInvalidFromConfig() @Test void isLocalhost() throws URISyntaxException, UnknownHostException { - assertTrue(CorsUtils.isValidOrigin("http://localhost", ZeppelinConfiguration.load())); + ZeppelinConfiguration zConf = localConfiguration(); + assertTrue(CorsUtils.isValidOrigin("http://localhost:8080", zConf)); + assertFalse(CorsUtils.isValidOrigin("http://localhost:8081", zConf)); + assertFalse(CorsUtils.isValidOrigin("https://localhost:8080", zConf)); + } + + @Test + void isIpv6Loopback() throws URISyntaxException, UnknownHostException { + assertTrue(CorsUtils.isValidOrigin("http://[::1]:8080", localConfiguration())); } @Test void isLocalMachine() throws URISyntaxException, UnknownHostException { - String origin = "http://" + InetAddress.getLocalHost().getHostName(); - assertTrue(CorsUtils.isValidOrigin(origin, ZeppelinConfiguration.load()), + String origin = "http://" + InetAddress.getLocalHost().getHostName() + ":8080"; + assertTrue(CorsUtils.isValidOrigin(origin, localConfiguration()), "Origin " + origin + " is not allowed. Please check your hostname."); } @@ -76,7 +84,7 @@ void nullOrigin() @Test void nullOriginWithStar() throws URISyntaxException, UnknownHostException { - assertTrue(CorsUtils.isValidOrigin(null, + assertFalse(CorsUtils.isValidOrigin(null, ZeppelinConfiguration.load("zeppelin-site-star.xml"))); } @@ -93,4 +101,8 @@ void notAURIOrigin() assertFalse(CorsUtils.isValidOrigin("test123", ZeppelinConfiguration.load("zeppelin-site.xml"))); } + + private static ZeppelinConfiguration localConfiguration() { + return ZeppelinConfiguration.load("no-configured-origins.xml"); + } } diff --git a/zeppelin-web-angular/e2e/tests/authentication/websocket-authentication.spec.ts b/zeppelin-web-angular/e2e/tests/authentication/websocket-authentication.spec.ts new file mode 100644 index 00000000000..d631cf319b8 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/authentication/websocket-authentication.spec.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from '@playwright/test'; +import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from '../../utils'; + +type ClientMessage = Record; + +test.describe('WebSocket authentication boundary', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.MAIN); + + test('Given ticket metadata is loaded When a message is sent Then identity fields stay server-side', async ({ + page + }) => { + const sentMessages: ClientMessage[] = []; + + await test.step('Given the WebSocket transport is observed before navigation', async () => { + await page.routeWebSocket('**/ws', webSocket => { + webSocket.onMessage(message => { + sentMessages.push(JSON.parse(message.toString()) as ClientMessage); + }); + }); + }); + + await test.step('When the workspace loads its ticket metadata and opens WebSocket', async () => { + const ticketResponse = page.waitForResponse(response => /\/api\/security\/ticket(?:\?|$)/.test(response.url())); + await page.goto('/#/'); + await waitForZeppelinReady(page); + expect((await ticketResponse).ok()).toBe(true); + await expect.poll(() => sentMessages.length).toBeGreaterThan(0); + }); + + await test.step('Then outbound messages contain no client-asserted identity', async () => { + for (const message of sentMessages) { + expect(message).not.toHaveProperty('principal'); + expect(message).not.toHaveProperty('roles'); + expect(message).not.toHaveProperty('ticket'); + } + }); + }); + + test('Given a policy violation close When the session ends Then the user returns to login without reconnect', async ({ + page + }) => { + let connectionCount = 0; + let policyCloseSent = false; + let ticketRequestCount = 0; + + await test.step('Given the WebSocket closes the first active connection with code 1008', async () => { + await page.clock.install(); + await page.route('**/api/security/ticket', async route => { + ticketRequestCount += 1; + if (ticketRequestCount === 1) { + await route.continue(); + } else { + await route.fulfill({ status: 401 }); + } + }); + await page.routeWebSocket('**/ws', webSocket => { + connectionCount += 1; + webSocket.onMessage(async () => { + if (!policyCloseSent) { + policyCloseSent = true; + await webSocket.close({ code: 1008, reason: 'Session expired' }); + } + }); + }); + }); + + await test.step('When the workspace establishes its WebSocket', async () => { + await page.goto('/#/'); + await expect.poll(() => policyCloseSent).toBe(true); + }); + + await test.step('Then the expired UI returns to login and does not reconnect', async () => { + await page.waitForURL(/#\/login(?:\?|$)/); + await expect(page.locator('zeppelin-login')).toBeVisible(); + await page.clock.fastForward(4500); + expect(connectionCount).toBe(1); + }); + }); +}); diff --git a/zeppelin-web-angular/e2e/tests/classic/classic-websocket-authentication.spec.ts b/zeppelin-web-angular/e2e/tests/classic/classic-websocket-authentication.spec.ts new file mode 100644 index 00000000000..1019459d234 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/classic/classic-websocket-authentication.spec.ts @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from '@playwright/test'; + +type ClientMessage = Record; + +test.describe('Classic WebSocket authentication boundary', () => { + test('Given ticket metadata is loaded When a message is sent Then identity fields stay server-side', async ({ + page + }) => { + const sentMessages: ClientMessage[] = []; + + await test.step('Given the WebSocket transport is observed before navigation', async () => { + await page.routeWebSocket('**/ws', webSocket => { + webSocket.onMessage(message => { + sentMessages.push(JSON.parse(message.toString()) as ClientMessage); + }); + }); + }); + + await test.step('When the classic UI loads its ticket metadata and opens WebSocket', async () => { + const ticketResponse = page.waitForResponse(response => /\/api\/security\/ticket(?:\?|$)/.test(response.url())); + await page.goto('/classic', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('#welcome')).toHaveText('Welcome to Zeppelin!', { timeout: 30000 }); + expect((await ticketResponse).ok()).toBe(true); + await expect.poll(() => sentMessages.length).toBeGreaterThan(0); + }); + + await test.step('Then outbound messages contain no client-asserted identity', async () => { + for (const message of sentMessages) { + expect(message).not.toHaveProperty('principal'); + expect(message).not.toHaveProperty('roles'); + expect(message).not.toHaveProperty('ticket'); + } + }); + }); + + test('Given a policy violation close When the session ends Then login is requested without reconnect', async ({ + page + }) => { + let connectionCount = 0; + let policyCloseSent = false; + + await test.step('Given the WebSocket closes the first active connection with code 1008', async () => { + await page.clock.install(); + await page.route('**/api/security/ticket', async route => { + const response = await route.fetch(); + const ticket = (await response.json()) as { body: Record }; + ticket.body.principal = 'test-user'; + ticket.body.ticket = 'test-session'; + await route.fulfill({ response, json: ticket }); + }); + await page.routeWebSocket('**/ws', webSocket => { + connectionCount += 1; + webSocket.onMessage(async () => { + if (!policyCloseSent) { + policyCloseSent = true; + await webSocket.close({ code: 1008, reason: 'Session expired' }); + } + }); + }); + }); + + await test.step('When the classic UI establishes its WebSocket', async () => { + await page.goto('/classic', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('#welcome')).toHaveText('Welcome to Zeppelin!', { timeout: 30000 }); + await expect.poll(() => policyCloseSent).toBe(true); + }); + + await test.step('Then the login dialog explains the ended session without reconnecting', async () => { + await page.clock.fastForward(2500); + await expect(page.locator('#loginModal')).toBeVisible(); + await expect(page.locator('#loginModal .alert-danger')).toHaveText('Session expired'); + expect(connectionCount).toBe(1); + }); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/websocket-message.interface.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/websocket-message.interface.ts index 6b5075db792..a12633c1886 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/websocket-message.interface.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/websocket-message.interface.ts @@ -20,8 +20,5 @@ export interface WebSocketMessage< > { op: Op; data?: T[Op]; - ticket?: string; // default 'anonymous' - principal?: string; // default 'anonymous' - roles?: string; // default '[]' msgId?: string; } diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts index 42821062eb3..1271176a67b 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts @@ -14,7 +14,6 @@ import { interval, Observable, Subject, Subscription } from 'rxjs'; import { delay, filter, map, mergeMap, retryWhen, take } from 'rxjs/operators'; import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; -import { Ticket } from './interfaces/message-common.interface'; import { MessageDataTypeMap, MessageReceiveDataTypeMap, @@ -50,11 +49,11 @@ export class Message { private received$ = new Subject>(); private pingIntervalSubscription = new Subscription(); private wsUrl?: string; - private ticket?: Ticket; private uniqueClientId = Math.random().toString(36).substring(2, 7); // TODO: Clean up this variable with `msgId` in server-side. See ZEPPELIN-6419, ZEPPELIN-4985 private lastMsgIdSeqSent = 0; private readonly normalCloseCode = 1000; + private readonly policyViolationCloseCode = 1008; constructor() { this.open$.subscribe(() => { @@ -68,15 +67,14 @@ export class Message { this.connectedStatus$.next(this.connectedStatus); this.pingIntervalSubscription.unsubscribe(); - if (event.code !== this.normalCloseCode) { + if (this.shouldReconnect(event.code)) { console.log('WebSocket closed unexpectedly. Reconnecting...'); this.connect(); } }); } - bootstrap(ticket: Ticket, wsUrl: string) { - this.setTicket(ticket); + bootstrap(wsUrl: string) { this.setWsUrl(wsUrl); this.connect(); } @@ -89,10 +87,6 @@ export class Message { this.wsUrl = wsUrl; } - setTicket(ticket: Ticket): void { - this.ticket = ticket; - } - interceptReceived(data: WebSocketMessage): WebSocketMessage { return data; } @@ -123,7 +117,17 @@ export class Message { this.wsSubscription = this.ws .pipe( // reconnect - retryWhen(errors => errors.pipe(mergeMap(() => this.close$.pipe(take(1), delay(4000))))) + retryWhen(errors => + errors.pipe( + mergeMap(() => + this.close$.pipe( + filter(event => this.shouldReconnect(event.code)), + take(1), + delay(4000) + ) + ) + ) + ) ) .subscribe(e => { console.log('Receive:', e); @@ -163,8 +167,7 @@ export class Message { const message = { op, msgId: `${this.uniqueClientId}-${++this.lastMsgIdSeqSent}`, - data, - ...this.ticket + data }; console.log('Send:', message); @@ -552,4 +555,8 @@ export class Message { formName }); } + + private shouldReconnect(closeCode: number): boolean { + return closeCode !== this.normalCloseCode && closeCode !== this.policyViolationCloseCode; + } } diff --git a/zeppelin-web-angular/src/app/services/message.service.ts b/zeppelin-web-angular/src/app/services/message.service.ts index 9b86a7d42d6..c6af9804cb6 100644 --- a/zeppelin-web-angular/src/app/services/message.service.ts +++ b/zeppelin-web-angular/src/app/services/message.service.ts @@ -11,8 +11,9 @@ */ import { Inject, Injectable, OnDestroy, Optional } from '@angular/core'; -import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; import { MessageInterceptor, MESSAGE_INTERCEPTOR } from '@zeppelin/interfaces'; import { @@ -41,13 +42,25 @@ import { TicketService } from './ticket.service'; }) export class MessageService extends Message implements OnDestroy { private readonly localAddFocusMsgIds = new Set(); + private readonly authenticatedSessionEndedSubscription: Subscription; constructor( private baseUrlService: BaseUrlService, private ticketService: TicketService, + private router: Router, @Optional() @Inject(MESSAGE_INTERCEPTOR) private messageInterceptor: MessageInterceptor ) { super(); + this.authenticatedSessionEndedSubscription = super + .closed() + .pipe(filter(event => event.code === 1008)) + .subscribe(() => { + const returnUrl = this.router.url; + this.ticketService.clearTicket(); + if (!returnUrl.startsWith('/login')) { + this.router.navigate(['/login'], { queryParams: { returnUrl } }).then(); + } + }); } interceptReceived(data: WebSocketMessage): WebSocketMessage { @@ -59,7 +72,7 @@ export class MessageService extends Message implements OnDestroy { } bootstrap(): void { - super.bootstrap(this.ticketService.originTicket, this.baseUrlService.getWebsocketUrl()); + super.bootstrap(this.baseUrlService.getWebsocketUrl()); } ping() { @@ -115,6 +128,7 @@ export class MessageService extends Message implements OnDestroy { } ngOnDestroy(): void { + this.authenticatedSessionEndedSubscription.unsubscribe(); super.destroy(); } diff --git a/zeppelin-web/src/components/login/login.controller.js b/zeppelin-web/src/components/login/login.controller.js index 797b53f325e..9d96b5bfd2c 100644 --- a/zeppelin-web/src/components/login/login.controller.js +++ b/zeppelin-web/src/components/login/login.controller.js @@ -14,7 +14,8 @@ angular.module('zeppelinWebApp').controller('LoginCtrl', LoginCtrl); -function LoginCtrl($scope, $rootScope, $http, $httpParamSerializer, baseUrlSrv, $location, $timeout) { +function LoginCtrl($scope, $rootScope, $http, $httpParamSerializer, baseUrlSrv, $location, $timeout, + websocketEvents) { 'ngInject'; $scope.SigningIn = false; @@ -34,10 +35,15 @@ function LoginCtrl($scope, $rootScope, $http, $httpParamSerializer, baseUrlSrv, }).then(function successCallback(response) { $rootScope.ticket = response.data.body; angular.element('#loginModal').modal('toggle'); - $rootScope.$broadcast('loginSuccess', true); $rootScope.userName = $scope.loginParams.userName; $scope.SigningIn = false; + // The authenticated identity is fixed during the WebSocket handshake. Reconnect after + // login so the new Shiro session cookie is authenticated before privileged messages run. + websocketEvents.reconnect(function() { + $rootScope.$broadcast('loginSuccess', true); + }); + // redirect to the page from where the user originally was if ($location.search() && $location.search()['ref']) { $timeout(function() { @@ -62,7 +68,7 @@ function LoginCtrl($scope, $rootScope, $http, $httpParamSerializer, baseUrlSrv, // handle session logout message received from WebSocket $rootScope.$on('session_logout', function(event, data) { - if ($rootScope.userName !== '') { + if ($rootScope.ticket && $rootScope.ticket.principal !== 'anonymous') { $rootScope.userName = ''; $rootScope.ticket = undefined; diff --git a/zeppelin-web/src/components/websocket/websocket-event.factory.js b/zeppelin-web/src/components/websocket/websocket-event.factory.js index 36e94231e52..5bb3a31beb7 100644 --- a/zeppelin-web/src/components/websocket/websocket-event.factory.js +++ b/zeppelin-web/src/components/websocket/websocket-event.factory.js @@ -19,6 +19,7 @@ function WebsocketEventFactory($rootScope, $websocket, $location, baseUrlSrv, sa let websocketCalls = {}; let pingIntervalId; + let reconnectOnOpenCallbacks = []; const uniqueClientId = Math.random().toString(36).substring(2, 7); let lastMsgIdSeqSent = 0; @@ -28,24 +29,27 @@ function WebsocketEventFactory($rootScope, $websocket, $location, baseUrlSrv, sa websocketCalls.ws.onOpen(function() { console.log('Websocket created'); $rootScope.$broadcast('setConnectedStatus', true); + let callbacks = reconnectOnOpenCallbacks; + reconnectOnOpenCallbacks = []; + callbacks.forEach(function(callback) { + callback(); + }); pingIntervalId = setInterval(function() { websocketCalls.sendNewEvent({op: 'PING'}); }, 10000); }); - websocketCalls.sendNewEvent = function(data) { - if ($rootScope.ticket !== undefined) { - data.principal = $rootScope.ticket.principal; - data.ticket = $rootScope.ticket.ticket; - data.roles = $rootScope.ticket.roles; - } else { - data.principal = ''; - data.ticket = ''; - data.roles = ''; + websocketCalls.reconnect = function(onOpen) { + if (onOpen) { + reconnectOnOpenCallbacks.push(onOpen); } + websocketCalls.ws.reconnectIfNotNormalClose = true; + websocketCalls.ws.reconnect(); + }; + websocketCalls.sendNewEvent = function(data) { data.msgId = uniqueClientId + '-' + ++lastMsgIdSeqSent; - console.log('Send >> %o, %o, %o, %o, %o', data.op, data.principal, data.ticket, data.roles, data); + console.log('Send >> %o, %o', data.op, data); return websocketCalls.ws.send(JSON.stringify(data)); }; @@ -223,6 +227,12 @@ function WebsocketEventFactory($rootScope, $websocket, $location, baseUrlSrv, sa websocketCalls.ws.onClose(function(event) { console.log('close message: ', event); + if (event.code === 1008) { + websocketCalls.ws.reconnectIfNotNormalClose = false; + $rootScope.$broadcast('session_logout', { + info: event.reason || 'Authenticated session is no longer valid', + }); + } if (pingIntervalId !== undefined) { clearInterval(pingIntervalId); pingIntervalId = undefined; diff --git a/zeppelin-web/src/components/websocket/websocket-event.factory.test.js b/zeppelin-web/src/components/websocket/websocket-event.factory.test.js new file mode 100644 index 00000000000..d84ab2e6643 --- /dev/null +++ b/zeppelin-web/src/components/websocket/websocket-event.factory.test.js @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Factory: WebsocketEvent', function() { + let openHandler; + let closeHandler; + let mockSocket; + let rootScope; + let websocketEvents; + + beforeEach(function() { + mockSocket = { + socket: {readyState: 1}, + reconnectIfNotNormalClose: false, + onOpen: jasmine.createSpy('onOpen').and.callFake(function(handler) { + openHandler = handler; + }), + onMessage: jasmine.createSpy('onMessage').and.returnValue(null), + onError: jasmine.createSpy('onError').and.returnValue(null), + onClose: jasmine.createSpy('onClose').and.callFake(function(handler) { + closeHandler = handler; + }), + reconnect: jasmine.createSpy('reconnect'), + send: jasmine.createSpy('send'), + }; + + angular.mock.module('zeppelinWebApp', function($provide) { + $provide.value('$websocket', jasmine.createSpy('$websocket').and.returnValue(mockSocket)); + }); + + angular.mock.inject(function(_websocketEvents_, _$rootScope_) { + websocketEvents = _websocketEvents_; + rootScope = _$rootScope_; + }); + }); + + it('should send only operation data and message metadata', function() { + websocketEvents.sendNewEvent({op: 'PING'}); + + const message = JSON.parse(mockSocket.send.calls.mostRecent().args[0]); + expect(message.op).toBe('PING'); + expect(message.msgId).toBeDefined(); + expect(message.principal).toBeUndefined(); + expect(message.roles).toBeUndefined(); + expect(message.ticket).toBeUndefined(); + }); + + it('should disable reconnect after a policy violation close', function() { + spyOn(rootScope, '$broadcast'); + expect(mockSocket.reconnectIfNotNormalClose).toBe(true); + + closeHandler({code: 1008, reason: 'Session expired'}); + + expect(mockSocket.reconnectIfNotNormalClose).toBe(false); + expect(rootScope.$broadcast).toHaveBeenCalledWith('session_logout', { + info: 'Session expired', + }); + }); + + it('should retain normal and retryable close behavior for other codes', function() { + closeHandler({code: 1000}); + expect(mockSocket.reconnectIfNotNormalClose).toBe(true); + + closeHandler({code: 1006}); + expect(mockSocket.reconnectIfNotNormalClose).toBe(true); + }); + + it('should wait for a new authenticated connection before continuing after login', function() { + const callback = jasmine.createSpy('authenticatedReconnect'); + mockSocket.reconnectIfNotNormalClose = false; + + websocketEvents.reconnect(callback); + + expect(mockSocket.reconnectIfNotNormalClose).toBe(true); + expect(mockSocket.reconnect).toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + + openHandler(); + + expect(callback).toHaveBeenCalled(); + }); +}); From b6ee85a59c4452e13812dc8b10cb588f3bd9b8d2 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 4 Aug 2026 03:41:57 +0900 Subject: [PATCH 2/5] [ZEPPELIN-4495] Update brace-expansion to 5.0.9 --- .../projects/zeppelin-react/package-lock.json | 6 +++--- zeppelin-web-angular/projects/zeppelin-react/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json index 4fa79386ebe..9738488903c 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json +++ b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json @@ -3283,9 +3283,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/zeppelin-web-angular/projects/zeppelin-react/package.json b/zeppelin-web-angular/projects/zeppelin-react/package.json index 3cca715db9c..8b30d927830 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/package.json +++ b/zeppelin-web-angular/projects/zeppelin-react/package.json @@ -54,6 +54,6 @@ "overrides": { "linkify-it": "^5.0.2", "minimatch": "^10.2.4", - "brace-expansion": "^5.0.8" + "brace-expansion": "^5.0.9" } } From 66459bf1347b4beaf35eaa20746b7bc316229f09 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 4 Aug 2026 05:48:53 +0900 Subject: [PATCH 3/5] [ZEPPELIN-4495] Fix authentication integration regressions --- .../apache/zeppelin/AbstractZeppelinIT.java | 2 + .../org/apache/zeppelin/WebDriverManager.java | 48 ++++-- .../integration/AuthenticationIT.java | 2 +- .../zeppelin/integration/InterpreterIT.java | 2 +- .../integration/InterpreterModeActionsIT.java | 2 +- .../integration/ParagraphActionsIT.java | 2 +- .../integration/PersonalizeActionsIT.java | 2 +- .../integration/SparkParagraphIT.java | 2 +- .../zeppelin/integration/ZeppelinIT.java | 2 +- .../service/AuthenticatedSessionService.java | 25 +++- .../service/ShiroAuthenticationService.java | 3 +- .../zeppelin/socket/NotebookServer.java | 33 +++-- .../zeppelin/socket/SessionConfigurator.java | 4 +- .../AuthenticatedSessionServiceTest.java | 14 ++ .../ShiroAuthenticationServiceTest.java | 10 ++ .../NotebookServerAuthenticationTest.java | 140 ++++++++++++++---- .../socket/SessionConfiguratorTest.java | 22 +++ .../app/interpreter/interpreter.controller.js | 2 +- .../interpreter.controller.test.js | 65 ++++++++ .../src/app/notebook/notebook.controller.js | 16 +- .../app/notebook/notebook.controller.test.js | 45 +++++- 21 files changed, 375 insertions(+), 68 deletions(-) create mode 100644 zeppelin-web/src/app/interpreter/interpreter.controller.test.js diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/AbstractZeppelinIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/AbstractZeppelinIT.java index bc433bcb6fe..91d750b8b10 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/AbstractZeppelinIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/AbstractZeppelinIT.java @@ -98,6 +98,7 @@ protected void authenticationUser(String userName, String password) { visibilityWait( By.xpath("//div[contains(@class, 'navbar-collapse')]//li//button[contains(@class, 'nav-btn dropdown-toggle ng-scope')]"), MAX_BROWSER_TIMEOUT_SEC); + manager.waitForWebSocketConnected(); try { ((JavascriptExecutor) manager.getWebDriver()).executeScript( "$('.modal-backdrop').remove(); $('#loginModal').modal('hide');"); @@ -141,6 +142,7 @@ protected void authenticationUserViaRest(String userName, String password) { } manager.getWebDriver().navigate().refresh(); visibilityWait(loggedInUserMenuLocator(), MAX_BROWSER_TIMEOUT_SEC); + manager.waitForWebSocketConnected(); } // Shared locator for the logged-in navbar user menu button. Uses a class-order-agnostic diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/WebDriverManager.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/WebDriverManager.java index a6aa0341a2b..d4287a6b7d8 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/WebDriverManager.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/WebDriverManager.java @@ -29,6 +29,7 @@ import java.util.stream.Stream; import org.apache.commons.lang3.SystemUtils; +import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.openqa.selenium.By; import org.openqa.selenium.Dimension; import org.openqa.selenium.TimeoutException; @@ -41,7 +42,6 @@ import org.openqa.selenium.firefox.FirefoxProfile; import org.openqa.selenium.firefox.GeckoDriverService; import org.openqa.selenium.safari.SafariDriver; -import org.openqa.selenium.support.ui.ExpectedCondition; import org.openqa.selenium.support.ui.WebDriverWait; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,6 +60,9 @@ public class WebDriverManager implements Closeable { public final static Logger LOG = LoggerFactory.getLogger(WebDriverManager.class); + private static final By WEBSOCKET_CONNECTED = + By.xpath("//i[@uib-tooltip='WebSocket Connected']"); + private static final By LOGIN_BUTTON = By.cssSelector(".nav-login-btn"); final boolean deleteTempFiles; final Path logDir; @@ -77,6 +80,15 @@ public WebDriverManager(int port) throws IOException { this(true, port); } + public WebDriverManager(boolean deleteTempFiles, ZeppelinConfiguration zConf) + throws IOException { + this(deleteTempFiles, configureBrowserOrigin(zConf)); + } + + public WebDriverManager(ZeppelinConfiguration zConf) throws IOException { + this(true, zConf); + } + public WebDriver getWebDriver() { return this.driver; } @@ -151,20 +163,17 @@ private WebDriver constructWebDriver(int port) { long start = System.currentTimeMillis(); boolean loaded = false; - driver.manage().timeouts() - .implicitlyWait(Duration.ofSeconds(AbstractZeppelinIT.MAX_IMPLICIT_WAIT)); + // Explicit readiness polling must not inherit the normal implicit wait. Otherwise an + // authenticated page spends the full implicit timeout looking for the deliberately absent + // WebSocket indicator before it can notice that the login UI is ready. + driver.manage().timeouts().implicitlyWait(Duration.ZERO); driver.get(url); while (System.currentTimeMillis() - start < 60 * 1000) { // wait for page load try { - (new WebDriverWait(driver, Duration.ofSeconds(60))).until(new ExpectedCondition() { - @Override - public Boolean apply(WebDriver d) { - return d.findElement(By.xpath("//i[@uib-tooltip='WebSocket Connected']")) - .isDisplayed(); - } - }); + (new WebDriverWait(driver, Duration.ofSeconds(60))) + .until(d -> isDisplayed(d, WEBSOCKET_CONNECTED) || isDisplayed(d, LOGIN_BUTTON)); loaded = true; break; } catch (TimeoutException e) { @@ -174,6 +183,8 @@ public Boolean apply(WebDriver d) { } assertTrue(loaded); + driver.manage().timeouts() + .implicitlyWait(Duration.ofSeconds(AbstractZeppelinIT.MAX_IMPLICIT_WAIT)); try { // Manually setting fixed window size since `maximize()` crashes for Chrome/Edge driver on linux with xvfb. @@ -185,6 +196,23 @@ public Boolean apply(WebDriver d) { return driver; } + public void waitForWebSocketConnected() { + (new WebDriverWait(driver, Duration.ofSeconds(60))) + .until(d -> isDisplayed(d, WEBSOCKET_CONNECTED)); + } + + private static boolean isDisplayed(WebDriver driver, By locator) { + return driver.findElements(locator).stream().anyMatch(element -> element.isDisplayed()); + } + + private static int configureBrowserOrigin(ZeppelinConfiguration zConf) { + int port = zConf.getServerPort(); + zConf.setProperty( + ZeppelinConfiguration.ConfVars.ZEPPELIN_ALLOWED_ORIGINS.getVarName(), + "http://localhost:" + port); + return port; + } + private WebDriver getFirefoxDriver() { FirefoxProfile profile = new FirefoxProfile(); diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/AuthenticationIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/AuthenticationIT.java index fe77c65da7e..39183bd8de6 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/AuthenticationIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/AuthenticationIT.java @@ -77,7 +77,7 @@ static void init() throws Exception { @BeforeEach public void startUpManager() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); } @AfterEach diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterIT.java index 5fe0ff0291d..0f12ac8b5e2 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterIT.java @@ -45,7 +45,7 @@ static void init() throws Exception { @BeforeEach public void startUp() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); } @AfterEach diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterModeActionsIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterModeActionsIT.java index da204bf98e2..d5bd6547dbd 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterModeActionsIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/InterpreterModeActionsIT.java @@ -84,7 +84,7 @@ static void init() throws Exception { @BeforeEach public void startUp() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); } @AfterEach diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ParagraphActionsIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ParagraphActionsIT.java index 7027d5591b9..7a8ef7efe3c 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ParagraphActionsIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ParagraphActionsIT.java @@ -62,7 +62,7 @@ static void init() throws Exception { @BeforeEach public void startUp() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); } @AfterEach diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/PersonalizeActionsIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/PersonalizeActionsIT.java index 8abe1ca9a12..ca9de83b0ff 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/PersonalizeActionsIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/PersonalizeActionsIT.java @@ -76,7 +76,7 @@ static void init() throws Exception { @BeforeEach public void startUp() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); } @AfterEach diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/SparkParagraphIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/SparkParagraphIT.java index a81ab893ddc..61942ec3fd7 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/SparkParagraphIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/SparkParagraphIT.java @@ -58,7 +58,7 @@ static void init() throws Exception { @BeforeEach public void startUp() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); createNewNote(); waitForParagraph(1, "READY"); } diff --git a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinIT.java b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinIT.java index ff45f123540..3469cd9e503 100644 --- a/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinIT.java +++ b/zeppelin-integration/src/test/java/org/apache/zeppelin/integration/ZeppelinIT.java @@ -75,7 +75,7 @@ static void init() throws Exception { @BeforeEach public void startUp() throws IOException { - manager = new WebDriverManager(zepServer.getZeppelinConfiguration().getServerPort()); + manager = new WebDriverManager(zepServer.getZeppelinConfiguration()); } @AfterEach diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java index 01d341811c2..70249e43ea9 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/AuthenticatedSessionService.java @@ -24,6 +24,7 @@ import java.util.Objects; import java.util.Set; import jakarta.inject.Inject; +import jakarta.inject.Provider; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.session.Session; import org.apache.shiro.subject.Subject; @@ -40,16 +41,26 @@ public class AuthenticatedSessionService { private static final String ROLE_SNAPSHOT_SESSION_ATTRIBUTE = AuthenticatedSessionService.class.getName() + ".roleSnapshot"; - private final AuthenticationService authenticationService; + private final Provider authenticationServiceProvider; private final Clock clock; @Inject - public AuthenticatedSessionService(AuthenticationService authenticationService) { - this(authenticationService, Clock.systemUTC()); + public AuthenticatedSessionService( + Provider authenticationServiceProvider) { + this(authenticationServiceProvider, Clock.systemUTC()); + } + + AuthenticatedSessionService(AuthenticationService authenticationService) { + this(() -> authenticationService, Clock.systemUTC()); } AuthenticatedSessionService(AuthenticationService authenticationService, Clock clock) { - this.authenticationService = authenticationService; + this(() -> authenticationService, clock); + } + + private AuthenticatedSessionService( + Provider authenticationServiceProvider, Clock clock) { + this.authenticationServiceProvider = authenticationServiceProvider; this.clock = clock; } @@ -126,7 +137,8 @@ public AuthenticatedIdentity refresh( } AuthenticatedIdentity refreshed = - subject.execute(authenticationService::getAuthenticatedIdentity); + subject.execute( + () -> authenticationServiceProvider.get().getAuthenticatedIdentity()); Serializable sessionId = connectionIdentity.getSessionId().orElseThrow( () -> new SessionAuthenticationException("Authenticated session is unavailable")); if (!refreshed.isAuthenticated() @@ -171,7 +183,8 @@ private Subject restoreValidatedSubject( if (session == null || !subject.isAuthenticated() || !sessionId.equals(session.getId())) { throw new SessionAuthenticationException("Authenticated session is no longer valid"); } - String principal = subject.execute(authenticationService::getPrincipal); + String principal = + subject.execute(() -> authenticationServiceProvider.get().getPrincipal()); if (!connectionIdentity.getPrincipal().equals(principal)) { throw new SessionAuthenticationException("Authenticated session identity changed"); } diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java index 9b0a69a38b1..6c392e04bec 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java @@ -89,7 +89,8 @@ public ShiroAuthenticationService(ZeppelinConfiguration zConf) throws Exception Collection realms = ((DefaultSecurityManager) org.apache.shiro.SecurityUtils.getSecurityManager()) .getRealms(); - if (realms.size() > 1) { + // Realm-less Shiro configurations can still expose anonymous filter chains. + if (realms != null && realms.size() > 1) { boolean isIniRealmEnabled = false; for (Realm realm : realms) { if (realm instanceof IniRealm && ((IniRealm) realm).getIni().get("users") != null) { 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 4375a673d15..97d0a050ec4 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 @@ -337,7 +337,7 @@ public void onMessage(NotebookSocket conn, String msg) { ServiceContext context = ServiceContextFactory.create(identity); if (Message.isDisabledForRunningNotes(receivedMessage.op)) { - String noteId = (String) receivedMessage.get("noteId"); + String noteId = getNoteIdForRunningNoteCheck(conn, receivedMessage); if (!authorizationService.isReader(noteId, context.getUserAndRoles())) { throw new ForbiddenException("Insufficient privileges to read note"); } @@ -886,22 +886,33 @@ public void onSuccess(List notesInfo, ServiceContext context) throws I public void broadcastReloadedNoteList(ServiceContext context) throws IOException { - requireGlobalNotebookAdministration(context, OP.RELOAD_NOTES_FROM_REPO); getNotebook().reloadAllNotes(context.getAutheInfo()); broadcastNoteListUpdate(); } - private void requireGlobalNotebookAdministration(ServiceContext context, OP operation) { - if (zConf.isAnonymousAllowed()) { - return; + private String getNoteIdForRunningNoteCheck(NotebookSocket conn, Message message) { + String noteId; + switch (message.op) { + case MOVE_NOTE_TO_TRASH: + case DEL_NOTE: + case PARAGRAPH_CLEAR_ALL_OUTPUT: + noteId = message.getType("id"); + break; + case RUN_ALL_PARAGRAPHS: + noteId = message.getType("noteId"); + break; + default: + noteId = connectionManager.getAssociatedNoteId(conn); + if (noteId == null + && (message.op == OP.COMMIT_PARAGRAPH || message.op == OP.PATCH_PARAGRAPH)) { + noteId = message.getType("noteId"); + } + break; } - String administratorRole = zConf.getString( - ZeppelinConfiguration.ConfVars.ZEPPELIN_OWNER_ROLE); - if (StringUtils.isBlank(administratorRole) - || !context.getUserAndRoles().contains(administratorRole)) { - throw new ForbiddenException( - "Administrator role is required for " + operation); + if (StringUtils.isBlank(noteId)) { + throw new IllegalArgumentException("No note specified for " + message.op); } + return noteId; } void permissionError(NotebookSocket conn, String op, String userName, Set userAndRoles, diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java index ca0650c7d7c..c536d76d722 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/SessionConfigurator.java @@ -45,12 +45,10 @@ public class SessionConfigurator extends Configurator { private final ServiceLocator serviceLocator; private final ZeppelinConfiguration zConf; - private final AuthenticationService authenticationService; public SessionConfigurator(ServiceLocator serviceLocator) { this.serviceLocator = serviceLocator; this.zConf = serviceLocator.getService(ZeppelinConfiguration.class); - this.authenticationService = serviceLocator.getService(AuthenticationService.class); } @Override @@ -72,6 +70,8 @@ public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, holder = request.getHeaders().get(CorsUtils.HEADER_ORIGIN); sec.getUserProperties().put(CorsUtils.HEADER_ORIGIN, null != holder && !holder.isEmpty() ? holder.get(0) : null); + AuthenticationService authenticationService = + serviceLocator.getService(AuthenticationService.class); AuthenticatedIdentity identity = authenticationService.getAuthenticatedIdentity(); sec.getUserProperties().put(AUTHENTICATED_IDENTITY, identity); sec.getUserProperties().put( diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java index bc80e4c12e3..3f097cc1a35 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/AuthenticatedSessionServiceTest.java @@ -35,6 +35,7 @@ import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; +import jakarta.inject.Provider; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.session.Session; import org.apache.shiro.subject.Subject; @@ -42,6 +43,19 @@ class AuthenticatedSessionServiceTest { + @Test + void providerIsNotResolvedUntilAnAuthenticatedSessionNeedsIt() { + Provider authenticationServiceProvider = mock(Provider.class); + AuthenticatedSessionService service = + new AuthenticatedSessionService(authenticationServiceProvider); + + assertSame( + AuthenticatedIdentity.anonymous(), + service.refresh(AuthenticatedIdentity.anonymous(), null, true)); + + verify(authenticationServiceProvider, never()).get(); + } + @Test void noAuthenticationAlwaysUsesAnonymousIdentity() { AuthenticationService authenticationService = mock(AuthenticationService.class); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java index b3e8d87ea5e..beb8e388567 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceTest.java @@ -16,6 +16,7 @@ */ package org.apache.zeppelin.service; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -69,6 +70,15 @@ void setup() throws Exception { shiroSecurityService = new ShiroAuthenticationService(zConf); } + @Test + void canInitializeWithoutConfiguredRealms() { + ZeppelinConfiguration configuration = mock(ZeppelinConfiguration.class); + when(configuration.getShiroPath()).thenReturn("shiro.ini"); + ThreadContext.bind(new DefaultSecurityManager()); + + assertDoesNotThrow(() -> new ShiroAuthenticationService(configuration)); + } + @Test void testGetMatchedUsersWithJdbcRealm() throws Exception { diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java index 16183a3db39..71eb5c7aa2a 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerAuthenticationTest.java @@ -25,6 +25,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,7 +39,6 @@ import org.apache.zeppelin.common.Message; import org.apache.zeppelin.common.Message.OP; import org.apache.zeppelin.conf.ZeppelinConfiguration; -import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars; import org.apache.zeppelin.notebook.AuthorizationService; import org.apache.zeppelin.notebook.Note; import org.apache.zeppelin.notebook.Notebook; @@ -47,6 +47,7 @@ import org.apache.zeppelin.service.NotebookService; import org.apache.zeppelin.service.ServiceContext; import org.apache.zeppelin.service.SessionAuthenticationException; +import org.apache.zeppelin.user.AuthenticationInfo; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -121,6 +122,111 @@ void angularObjectMutationRequiresRunnerPermission() throws Exception { verify(notebookProvider, never()).get(); } + @Test + void runningNoteCheckUsesAssociatedNoteForParagraphMessages() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + when(authorizationService.isReader("note-id", Set.of("user1"))).thenReturn(true); + Notebook notebook = mock(Notebook.class); + when(notebook.processNote(eq("note-id"), any())).thenReturn(false); + ConnectionManager connectionManager = mock(ConnectionManager.class); + NotebookSocket socket = authenticatedSocket(identity, securityManager); + when(connectionManager.getAssociatedNoteId(socket)).thenReturn("note-id"); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setConnectionManager(connectionManager); + server.setNotebook(() -> notebook); + + server.onMessage(socket, + new Message(OP.RUN_PARAGRAPH) + .put("id", "paragraph-id") + .put("noteId", "untrusted-note-id") + .toJson()); + + verify(authorizationService).isReader("note-id", Set.of("user1")); + verify(notebook, times(2)).processNote(eq("note-id"), any()); + } + + @Test + void runningNoteCheckUsesMessageIdForNoteMessages() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + when(authorizationService.isReader("note-id", Set.of("user1"))).thenReturn(true); + Notebook notebook = mock(Notebook.class); + when(notebook.processNote(eq("note-id"), any())).thenReturn(false); + NotebookService notebookService = mock(NotebookService.class); + NotebookServer server = server(sessionService, notebookService, authorizationService); + server.setNotebook(() -> notebook); + + server.onMessage(authenticatedSocket(identity, securityManager), + new Message(OP.MOVE_NOTE_TO_TRASH) + .put("id", "note-id") + .put("noteId", "untrusted-note-id") + .toJson()); + + verify(authorizationService).isReader("note-id", Set.of("user1")); + verify(notebook).processNote(eq("note-id"), any()); + verify(notebookService).moveNoteToTrash(eq("note-id"), any(), any()); + } + + @Test + void runningNoteCheckUsesNoteIdForBatchMessages() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + when(authorizationService.isReader("note-id", Set.of("user1"))).thenReturn(true); + Notebook notebook = mock(Notebook.class); + when(notebook.processNote(eq("note-id"), any())).thenReturn(true); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setNotebook(() -> notebook); + + server.onMessage(authenticatedSocket(identity, securityManager), + new Message(OP.RUN_ALL_PARAGRAPHS) + .put("noteId", "note-id") + .put("id", "untrusted-note-id") + .toJson()); + + verify(authorizationService).isReader("note-id", Set.of("user1")); + verify(notebook).processNote(eq("note-id"), any()); + } + + @Test + void runningNoteCheckFallsBackToMessageNoteIdForUnassociatedEdits() throws Exception { + AuthenticatedIdentity identity = + new AuthenticatedIdentity("user1", Set.of(), true, "session-id"); + AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); + SecurityManager securityManager = mock(SecurityManager.class); + when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); + AuthorizationService authorizationService = mock(AuthorizationService.class); + when(authorizationService.isReader("note-id", Set.of("user1"))).thenReturn(true); + Notebook notebook = mock(Notebook.class); + when(notebook.processNote(eq("note-id"), any())).thenReturn(true); + NotebookServer server = server( + sessionService, mock(NotebookService.class), authorizationService); + server.setNotebook(() -> notebook); + + server.onMessage(authenticatedSocket(identity, securityManager), + new Message(OP.COMMIT_PARAGRAPH) + .put("noteId", "note-id") + .put("id", "paragraph-id") + .toJson()); + + verify(authorizationService).isReader("note-id", Set.of("user1")); + verify(notebook).processNote(eq("note-id"), any()); + } + @Test void logoutBetweenHandshakeValidationAndRegistrationStillClosesTheSocket() throws Exception { AuthenticatedIdentity identity = @@ -396,45 +502,25 @@ void noteListBroadcastUsesBoundedRoleSnapshot() throws Exception { } @Test - void repositoryReloadRequiresTheConfiguredAdministratorRole() throws Exception { + void repositoryReloadUsesTheAuthenticatedSessionIdentity() throws Exception { AuthenticatedIdentity identity = new AuthenticatedIdentity("user", Set.of("reader"), true, "session-id"); SecurityManager securityManager = mock(SecurityManager.class); AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); - Provider notebookProvider = mock(Provider.class); - NotebookServer server = server(sessionService, mock(NotebookService.class)); - ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); - when(zConf.getString(ConfVars.ZEPPELIN_OWNER_ROLE)).thenReturn("admin"); - server.setZeppelinConfiguration(zConf); - server.setNotebook(notebookProvider); - - server.onMessage( - authenticatedSocket(identity, securityManager), - new Message(OP.RELOAD_NOTES_FROM_REPO).toJson()); - - verify(notebookProvider, never()).get(); - } - - @Test - void repositoryReloadAllowsTheConfiguredAdministratorRole() throws Exception { - AuthenticatedIdentity identity = - new AuthenticatedIdentity("user", Set.of("admin"), true, "session-id"); - SecurityManager securityManager = mock(SecurityManager.class); - AuthenticatedSessionService sessionService = mock(AuthenticatedSessionService.class); - when(sessionService.refresh(identity, securityManager, true)).thenReturn(identity); Notebook notebook = mock(Notebook.class); NotebookServer server = server(sessionService, mock(NotebookService.class)); - ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); - when(zConf.getString(ConfVars.ZEPPELIN_OWNER_ROLE)).thenReturn("admin"); - server.setZeppelinConfiguration(zConf); server.setNotebook(() -> notebook); server.onMessage( authenticatedSocket(identity, securityManager), new Message(OP.RELOAD_NOTES_FROM_REPO).toJson()); - verify(notebook).reloadAllNotes(any()); + ArgumentCaptor authenticationInfo = + ArgumentCaptor.forClass(AuthenticationInfo.class); + verify(notebook).reloadAllNotes(authenticationInfo.capture()); + assertEquals("user", authenticationInfo.getValue().getUser()); + assertEquals(Set.of("reader"), authenticationInfo.getValue().getRoles()); } private static NotebookServer server( diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java index e9ad358995e..3b995821360 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/SessionConfiguratorTest.java @@ -21,6 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; @@ -97,6 +99,26 @@ void capturesTheServerAuthenticatedIdentityInPerHandshakeProperties() { SessionConfigurator.AUTHENTICATION_SECURITY_MANAGER)); } + @Test + void resolvesAuthenticationOnlyWhenTheShiroFilteredHandshakeRuns() { + ZeppelinConfiguration zConf = mock(ZeppelinConfiguration.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + ServiceLocator serviceLocator = serviceLocator(zConf, authenticationService); + + SessionConfigurator configurator = new SessionConfigurator(serviceLocator); + + verify(serviceLocator, never()).getService(AuthenticationService.class); + + ServerEndpointConfig endpointConfig = ServerEndpointConfig.Builder + .create(NotebookServer.class, "/ws") + .build(); + HandshakeRequest request = mock(HandshakeRequest.class); + when(request.getHeaders()).thenReturn(Map.of()); + configurator.modifyHandshake(endpointConfig, request, mock(HandshakeResponse.class)); + + verify(serviceLocator).getService(AuthenticationService.class); + } + private static ServiceLocator serviceLocator( ZeppelinConfiguration zConf, AuthenticationService authenticationService) { ServiceLocator serviceLocator = mock(ServiceLocator.class); diff --git a/zeppelin-web/src/app/interpreter/interpreter.controller.js b/zeppelin-web/src/app/interpreter/interpreter.controller.js index dddae0022cf..63db4d15250 100644 --- a/zeppelin-web/src/app/interpreter/interpreter.controller.js +++ b/zeppelin-web/src/app/interpreter/interpreter.controller.js @@ -114,7 +114,7 @@ function InterpreterCtrl($rootScope, $scope, $http, baseUrlSrv, ngToast, $timeou $scope.interpreterSettings = res.data.body; checkDownloadingDependencies(); }).catch(function(res) { - if (res.status === 401) { + if (res.status === 401 || res.status === 403) { ngToast.danger({ content: 'You don\'t have permission on this page', verticalPosition: 'bottom', diff --git a/zeppelin-web/src/app/interpreter/interpreter.controller.test.js b/zeppelin-web/src/app/interpreter/interpreter.controller.test.js new file mode 100644 index 00000000000..0d02db793f2 --- /dev/null +++ b/zeppelin-web/src/app/interpreter/interpreter.controller.test.js @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Controller: Interpreter', function() { + beforeEach(angular.mock.module('zeppelinWebApp')); + + const baseUrlSrvMock = { + getRestApiBase: () => '', + getBase: () => '/', + }; + + let $controller; + let $httpBackend; + let $scope; + let ngToast; + + beforeEach(inject((_$controller_, _$httpBackend_, _$rootScope_, _ngToast_) => { + $controller = _$controller_; + $httpBackend = _$httpBackend_; + $scope = _$rootScope_.$new(); + ngToast = _ngToast_; + })); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + [401, 403].forEach((status) => { + it(`should display the permission-denied toast when loading settings returns ${status}`, () => { + spyOn(ngToast, 'danger'); + spyOn(window, 'setTimeout'); + + $controller('InterpreterCtrl', {$scope: $scope, baseUrlSrv: baseUrlSrvMock}); + + $httpBackend.expectGET('/interpreter/property/types').respond(200, {body: []}); + $httpBackend.expectGET('/interpreter/setting').respond(status, {}); + $httpBackend.expectGET('/interpreter').respond(200, {body: {}}); + $httpBackend.expectGET('/interpreter/repository').respond(200, {body: []}); + $httpBackend.whenGET('app/home/home.html').respond(200, ''); + $httpBackend.flush(); + + expect(ngToast.danger).toHaveBeenCalledWith({ + content: 'You don\'t have permission on this page', + verticalPosition: 'bottom', + timeout: '3000', + }); + expect(window.setTimeout).toHaveBeenCalled(); + }); + }); +}); diff --git a/zeppelin-web/src/app/notebook/notebook.controller.js b/zeppelin-web/src/app/notebook/notebook.controller.js index 2a987f8a674..a1317800684 100644 --- a/zeppelin-web/src/app/notebook/notebook.controller.js +++ b/zeppelin-web/src/app/notebook/notebook.controller.js @@ -71,6 +71,7 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope, $scope.paragraphWarningDialog = {}; let connectedOnce = false; + let revisionHistoryRequestPending = false; let isRevisionPath = function(path) { let pattern = new RegExp('^.*\/notebook\/[a-zA-Z0-9_]*\/revision\/[a-zA-Z0-9_]*'); return pattern.test(path); @@ -189,12 +190,12 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope, const initNotebook = function() { noteVarShareService.clear(); initializeRevisionSupported(); + revisionHistoryRequestPending = true; if ($routeParams.revisionId) { websocketMsgSrv.getNoteByRevision($routeParams.noteId, $routeParams.revisionId); } else { websocketMsgSrv.getNote($routeParams.noteId); } - websocketMsgSrv.listRevisionHistory($routeParams.noteId); let currentRoute = $route.current; if (currentRoute) { setTimeout( @@ -213,6 +214,14 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope, } }; + const requestRevisionHistoryAfterNoteAccess = function() { + if (!revisionHistoryRequestPending) { + return; + } + revisionHistoryRequestPending = false; + websocketMsgSrv.listRevisionHistory($routeParams.noteId); + }; + initNotebook(); $scope.focusParagraphOnClick = function(clickEvent) { @@ -380,6 +389,7 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope, console.log('received note revision %o', data); if (data.note) { $scope.note = data.note; + requestRevisionHistoryAfterNoteAccess(); initializeLookAndFeel(); } else { $location.path('/'); @@ -1536,11 +1546,13 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope, }); $scope.$on('setNoteContent', function(event, note) { - if (note === undefined) { + if (!note) { $location.path('/'); + return; } $scope.note = note; + requestRevisionHistoryAfterNoteAccess(); $scope.paragraphUrl = $routeParams.paragraphId; $scope.asIframe = $routeParams.asIframe; diff --git a/zeppelin-web/src/app/notebook/notebook.controller.test.js b/zeppelin-web/src/app/notebook/notebook.controller.test.js index e3769daaa9c..ab6034db631 100644 --- a/zeppelin-web/src/app/notebook/notebook.controller.test.js +++ b/zeppelin-web/src/app/notebook/notebook.controller.test.js @@ -2,9 +2,12 @@ describe('Controller: NotebookCtrl', function() { beforeEach(angular.mock.module('zeppelinWebApp')); let scope; + let controller; + let rootScope; let websocketMsgSrvMock = { getNote: function() {}, + getNoteByRevision: function() {}, listRevisionHistory: function() {}, getInterpreterBindings: function() {}, updateNote: function() {}, @@ -25,9 +28,13 @@ describe('Controller: NotebookCtrl', function() { }; beforeEach(inject(function($controller, $rootScope) { + spyOn(websocketMsgSrvMock, 'listRevisionHistory'); + controller = $controller; + rootScope = $rootScope; scope = $rootScope.$new(); $controller('NotebookCtrl', { $scope: scope, + $routeParams: {noteId: noteMock.id}, websocketMsgSrv: websocketMsgSrvMock, baseUrlSrv: baseUrlSrvMock, }); @@ -87,6 +94,43 @@ describe('Controller: NotebookCtrl', function() { expect(scope.isNoteDirty).toEqual(null); }); + it('should request revision history only after note access succeeds', function() { + expect(websocketMsgSrvMock.listRevisionHistory).not.toHaveBeenCalled(); + + scope.$broadcast('setNoteContent', noteMock); + scope.$broadcast('setNoteContent', noteMock); + + expect(websocketMsgSrvMock.listRevisionHistory).toHaveBeenCalledWith(noteMock.id); + expect(websocketMsgSrvMock.listRevisionHistory.calls.count()).toEqual(1); + }); + + it('should not request revision history without a successful note response', function() { + scope.$broadcast('setNoteContent', undefined); + + expect(websocketMsgSrvMock.listRevisionHistory).not.toHaveBeenCalled(); + }); + + it('should request revision history only after revision note access succeeds', function() { + let revisionScope = rootScope.$new(); + let revisionId = 'revision-1'; + spyOn(websocketMsgSrvMock, 'getNoteByRevision'); + websocketMsgSrvMock.listRevisionHistory.calls.reset(); + + controller('NotebookCtrl', { + $scope: revisionScope, + $routeParams: {noteId: noteMock.id, revisionId: revisionId}, + websocketMsgSrv: websocketMsgSrvMock, + baseUrlSrv: baseUrlSrvMock, + }); + + expect(websocketMsgSrvMock.getNoteByRevision).toHaveBeenCalledWith(noteMock.id, revisionId); + expect(websocketMsgSrvMock.listRevisionHistory).not.toHaveBeenCalled(); + + revisionScope.$broadcast('noteRevision', {note: noteMock}); + + expect(websocketMsgSrvMock.listRevisionHistory).toHaveBeenCalledWith(noteMock.id); + }); + it('should first call killSaveTimer() when calling startSaveTimer()', function() { expect(scope.saveTimer).toEqual(null); spyOn(scope, 'killSaveTimer'); @@ -125,7 +169,6 @@ describe('Controller: NotebookCtrl', function() { it('should reload note info once per one "setNoteMenu" event', function() { spyOn(websocketMsgSrvMock, 'getNote'); - spyOn(websocketMsgSrvMock, 'listRevisionHistory'); scope.$broadcast('setNoteMenu'); expect(websocketMsgSrvMock.getNote.calls.count()).toEqual(0); From 0d3be39f97cb1875df482517c7e09dfa9035b5b1 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 4 Aug 2026 05:53:41 +0900 Subject: [PATCH 4/5] [ZEPPELIN-4495] Update frontend audit dependencies --- .../projects/zeppelin-react/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json index 9738488903c..6029f386117 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json +++ b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json @@ -4958,9 +4958,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -10222,9 +10222,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { From 942660b18922c232d2451333caeafc0f03a245b8 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 4 Aug 2026 07:42:26 +0900 Subject: [PATCH 5/5] [ZEPPELIN-4495] Preserve safe folder merge semantics --- .../apache/zeppelin/notebook/NoteManager.java | 289 +++++++++++++- .../apache/zeppelin/notebook/Notebook.java | 16 +- .../zeppelin/service/NotebookService.java | 3 +- .../zeppelin/notebook/NoteManagerTest.java | 376 +++++++++++++++++- .../notebook/repo/VFSNotebookRepoTest.java | 48 +++ 5 files changed, 717 insertions(+), 15 deletions(-) diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java index d1be895ec91..dad67301ac8 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java @@ -21,10 +21,12 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.Lock; import java.util.stream.Collectors; @@ -71,6 +73,7 @@ public class NoteManager { */ private volatile NoteTree noteTree; private long metadataVersion; + private volatile Throwable metadataUnavailableCause; @Inject public NoteManager(NotebookRepo notebookRepo, ZeppelinConfiguration zConf) throws IOException { @@ -104,11 +107,13 @@ private NoteTree buildNoteTree() throws IOException { } public Map getNotesInfo() { + assertMetadataAvailableUnchecked(); return this.noteTree.notesInfo; } /** Capture one immutable generation of the note-id/path index for authorization preflight. */ - public synchronized NoteMetadataSnapshot getNotesInfoSnapshot() { + public synchronized NoteMetadataSnapshot getNotesInfoSnapshot() throws IOException { + assertMetadataAvailable(); return new NoteMetadataSnapshot( metadataVersion, Collections.unmodifiableMap(new LinkedHashMap<>(noteTree.notesInfo))); @@ -123,7 +128,9 @@ public synchronized NoteMetadataSnapshot getNotesInfoSnapshot() { * @throws IOException */ public synchronized void reloadNotes() throws IOException { - this.noteTree = buildNoteTree(); + NoteTree reloadedTree = buildNoteTree(); + this.noteTree = reloadedTree; + metadataUnavailableCause = null; metadataVersion++; } @@ -162,6 +169,7 @@ private void addOrUpdateNoteNode(NoteTree tree, NoteInfo noteInfo, boolean check * @return */ public boolean containsNote(String notePath) { + assertMetadataAvailableUnchecked(); try { getNoteNode(notePath); return true; @@ -177,6 +185,7 @@ public boolean containsNote(String notePath) { * @return */ public boolean containsFolder(String folderPath) { + assertMetadataAvailableUnchecked(); try { getFolder(folderPath); return true; @@ -194,6 +203,7 @@ public boolean containsFolder(String folderPath) { * @throws IOException */ public synchronized void saveNote(Note note, AuthenticationInfo subject) throws IOException { + assertMetadataAvailable(); if (note.isRemoved()) { LOGGER.warn("Try to save note: {} when it is removed", note.getId()); } else { @@ -210,6 +220,7 @@ public synchronized void saveNote(Note note, AuthenticationInfo subject) throws } public synchronized void addNote(Note note, AuthenticationInfo subject) throws IOException { + assertMetadataAvailable(); addOrUpdateNoteNode(this.noteTree, new NoteInfo(note), true); noteCache.putNote(note); metadataVersion++; @@ -234,6 +245,7 @@ public synchronized Note setNoteRevision( String notePath, String revisionId, AuthenticationInfo subject) throws IOException { + assertMetadataAvailable(); String currentPath = noteTree.notesInfo.get(noteId); if (currentPath == null) { throw new IOException("No metadata found for this note: " + noteId); @@ -258,6 +270,7 @@ public synchronized Note setNoteRevision( * @throws IOException */ public synchronized void removeNote(String noteId, AuthenticationInfo subject) throws IOException { + assertMetadataAvailable(); NoteTree tree = this.noteTree; String notePath = tree.notesInfo.remove(noteId); Folder folder = getOrCreateFolder(tree, getFolderName(notePath)); @@ -276,6 +289,7 @@ public void moveNote(String noteId, String notePath; synchronized (this) { + assertMetadataAvailable(); NoteTree tree = this.noteTree; if (!isNotePathAvailable(tree, newNotePath)) { throw new NotePathAlreadyExistsException("Note '" + newNotePath + "' existed"); @@ -334,28 +348,57 @@ public synchronized void moveFolder( String newFolderPath, AuthenticationInfo subject, long expectedMetadataVersion) throws IOException { + moveFolder(folderPath, newFolderPath, subject, expectedMetadataVersion, true); + } + + public synchronized void moveFolder( + String folderPath, + String newFolderPath, + AuthenticationInfo subject, + long expectedMetadataVersion, + boolean mergeExistingDestination) throws IOException { assertMetadataVersion(expectedMetadataVersion); NoteTree tree = this.noteTree; Folder folder = getFolder(tree, folderPath); - if (StringUtils.equals(folderPath, newFolderPath)) { + String sourceFolderPath = folder.getPath(); + String destinationFolderPath = normalizeFolderPath(newFolderPath); + if (StringUtils.equals(sourceFolderPath, destinationFolderPath)) { return; } - if (newFolderPath.startsWith(folderPath + "/")) { + if (folder == tree.root) { + throw new IOException("Can not move the root folder"); + } + if (destinationFolderPath.startsWith(sourceFolderPath + "/")) { throw new IOException( - "Can not move folder '" + folderPath + "' into its own descendant"); + "Can not move folder '" + sourceFolderPath + "' into its own descendant"); + } + if (containsNote(destinationFolderPath)) { + throw new NotePathAlreadyExistsException( + "Path '" + destinationFolderPath + "' existed"); } - if (containsNote(newFolderPath) || containsFolder(newFolderPath)) { - throw new NotePathAlreadyExistsException("Path '" + newFolderPath + "' existed"); + + if (containsFolder(destinationFolderPath)) { + if (!mergeExistingDestination) { + throw new NotePathAlreadyExistsException( + "Path '" + destinationFolderPath + "' existed"); + } + Folder destinationFolder = getFolder(tree, destinationFolderPath); + if (isSameOrDescendantFolder(destinationFolder, folder)) { + throw new IOException( + "Can not move folder '" + sourceFolderPath + "' into its own descendant"); + } + mergeFolder(tree, folder, destinationFolder, subject); + return; } // update notebookrepo - this.notebookRepo.move(folderPath, newFolderPath, subject); + this.notebookRepo.move(sourceFolderPath, destinationFolderPath, subject); // update filesystem tree folder.getParent().removeFolder(folder.getName(), subject); - Folder newFolder = getOrCreateFolder(tree, newFolderPath); + Folder newFolder = getOrCreateFolder(tree, destinationFolderPath); newFolder.getParent().addFolder(newFolder.getName(), folder); // update notesInfo @@ -366,6 +409,194 @@ public synchronized void moveFolder( metadataVersion++; } + private static String normalizeFolderPath(String folderPath) { + StringBuilder normalized = new StringBuilder(); + for (String token : folderPath.split("/")) { + if (!StringUtils.isBlank(token)) { + normalized.append('/').append(token); + } + } + return normalized.length() == 0 ? "/" : normalized.toString(); + } + + private static boolean isSameOrDescendantFolder(Folder folder, Folder possibleAncestor) { + for (Folder current = folder; current != null; current = current.parent) { + if (current == possibleAncestor) { + return true; + } + } + return false; + } + + private void mergeFolder( + NoteTree tree, + Folder sourceFolder, + Folder destinationFolder, + AuthenticationInfo subject) throws IOException { + String sourceFolderPath = sourceFolder.getPath(); + String destinationFolderPath = destinationFolder.getPath(); + List noteMoves = new ArrayList<>(); + for (NoteInfo noteInfo : sourceFolder.getNoteInfoRecursively()) { + noteMoves.add( + new FolderNoteMove( + noteInfo.getId(), + noteInfo.getPath(), + rebasePath(noteInfo.getPath(), sourceFolderPath, destinationFolderPath))); + } + noteMoves.sort((first, second) -> first.sourcePath.compareTo(second.sourcePath)); + + assertFolderMergeDoesNotOverwrite(tree, sourceFolder, noteMoves, destinationFolderPath); + moveFolderNotesWithRollback(noteMoves, subject); + + // Publish the in-memory change only after every durable note move succeeds. + sourceFolder.getParent().getFolders().remove(sourceFolder.getName()); + mergeFolderTrees(sourceFolder, destinationFolder); + for (FolderNoteMove noteMove : noteMoves) { + tree.notesInfo.put(noteMove.noteId, noteMove.destinationPath); + updateCachedNotePath(noteMove.noteId, noteMove.destinationPath); + } + metadataVersion++; + } + + private void assertFolderMergeDoesNotOverwrite( + NoteTree tree, + Folder sourceFolder, + List noteMoves, + String destinationFolderPath) throws IOException { + Set sourceNoteIds = new HashSet<>(); + for (FolderNoteMove noteMove : noteMoves) { + sourceNoteIds.add(noteMove.noteId); + } + Set remainingNotePaths = new HashSet<>(); + for (Map.Entry entry : tree.notesInfo.entrySet()) { + if (!sourceNoteIds.contains(entry.getKey())) { + remainingNotePaths.add(entry.getValue()); + } + } + + Set remainingFolderPaths = new HashSet<>(); + collectFolderPathsExcept(tree.root, sourceFolder, remainingFolderPaths); + + Set destinationFolderPaths = new HashSet<>(); + collectRebasedFolderPaths( + sourceFolder, sourceFolder.getPath(), destinationFolderPath, destinationFolderPaths); + for (String folderPath : destinationFolderPaths) { + if (remainingNotePaths.contains(folderPath)) { + throw new NotePathAlreadyExistsException("Path '" + folderPath + "' existed"); + } + } + + Set destinationNotePaths = new HashSet<>(); + for (FolderNoteMove noteMove : noteMoves) { + if (!destinationNotePaths.add(noteMove.destinationPath) + || remainingNotePaths.contains(noteMove.destinationPath) + || remainingFolderPaths.contains(noteMove.destinationPath) + || destinationFolderPaths.contains(noteMove.destinationPath)) { + throw new NotePathAlreadyExistsException( + "Path '" + noteMove.destinationPath + "' existed"); + } + } + } + + private static void collectFolderPathsExcept( + Folder folder, Folder excludedFolder, Set folderPaths) { + if (folder == excludedFolder) { + return; + } + folderPaths.add(folder.getPath()); + for (Folder child : folder.getFolders().values()) { + collectFolderPathsExcept(child, excludedFolder, folderPaths); + } + } + + private static void collectRebasedFolderPaths( + Folder folder, + String sourceFolderPath, + String destinationFolderPath, + Set folderPaths) { + folderPaths.add(rebasePath(folder.getPath(), sourceFolderPath, destinationFolderPath)); + for (Folder child : folder.getFolders().values()) { + collectRebasedFolderPaths( + child, sourceFolderPath, destinationFolderPath, folderPaths); + } + } + + private static String rebasePath( + String path, String sourceFolderPath, String destinationFolderPath) { + String relativePath = path.substring(sourceFolderPath.length()); + if ("/".equals(destinationFolderPath)) { + return relativePath.isEmpty() ? "/" : relativePath; + } + return destinationFolderPath + relativePath; + } + + private void moveFolderNotesWithRollback( + List noteMoves, AuthenticationInfo subject) throws IOException { + List attemptedMoves = new ArrayList<>(); + try { + for (FolderNoteMove noteMove : noteMoves) { + // A repository move may copy or update part of its state before reporting failure. + // Record the attempt first so compensation also covers that ambiguous current move. + attemptedMoves.add(noteMove); + notebookRepo.move( + noteMove.noteId, noteMove.sourcePath, noteMove.destinationPath, subject); + } + } catch (IOException | RuntimeException failure) { + boolean rollbackFailed = false; + for (int i = attemptedMoves.size() - 1; i >= 0; i--) { + FolderNoteMove attemptedMove = attemptedMoves.get(i); + try { + notebookRepo.move( + attemptedMove.noteId, + attemptedMove.destinationPath, + attemptedMove.sourcePath, + subject); + } catch (IOException | RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + rollbackFailed = true; + } + } + + if (rollbackFailed) { + // A failed compensation means the durable paths are no longer known. Poison metadata + // before attempting a reload so lock-free readers cannot use the stale tree meanwhile. + metadataUnavailableCause = failure; + for (FolderNoteMove noteMove : noteMoves) { + noteCache.removeNote(noteMove.noteId); + } + try { + reloadNotes(); + } catch (IOException | RuntimeException reloadFailure) { + failure.addSuppressed(reloadFailure); + } + } + throw failure; + } + } + + private static void mergeFolderTrees(Folder sourceFolder, Folder destinationFolder) { + for (Map.Entry entry : sourceFolder.getNotes().entrySet()) { + NoteNode noteNode = entry.getValue(); + destinationFolder.getNotes().put(entry.getKey(), noteNode); + noteNode.setParent(destinationFolder); + noteNode.updateNotePath(); + } + + for (Map.Entry entry : sourceFolder.getFolders().entrySet()) { + Folder sourceChild = entry.getValue(); + Folder destinationChild = destinationFolder.getFolder(entry.getKey()); + if (destinationChild == null) { + destinationFolder.getFolders().put(entry.getKey(), sourceChild); + sourceChild.setParent(destinationFolder); + for (NoteNode noteNode : sourceChild.getNoteNodeRecursively()) { + noteNode.updateNotePath(); + } + } else { + mergeFolderTrees(sourceChild, destinationChild); + } + } + } + /** * Returns the NoteInfo of all notes under the given folder, without removing them. * @@ -374,6 +605,7 @@ public synchronized void moveFolder( * @throws IOException */ public List getNoteInfoRecursively(String folderPath) throws IOException { + assertMetadataAvailable(); return getFolder(folderPath).getNoteInfoRecursively(); } @@ -519,11 +751,28 @@ private void checkRestoreDestination( } private void assertMetadataVersion(long expectedMetadataVersion) throws IOException { + assertMetadataAvailable(); if (expectedMetadataVersion >= 0 && metadataVersion != expectedMetadataVersion) { throw new IOException("Notebook metadata changed while authorizing the folder operation"); } } + private void assertMetadataAvailable() throws IOException { + Throwable cause = metadataUnavailableCause; + if (cause != null) { + throw new IOException( + "Notebook metadata is unavailable after repository recovery failed", cause); + } + } + + private void assertMetadataAvailableUnchecked() { + Throwable cause = metadataUnavailableCause; + if (cause != null) { + throw new IllegalStateException( + "Notebook metadata is unavailable after repository recovery failed", cause); + } + } + private void updateCachedNotePath(String noteId, String notePath) { Note note = noteCache.getNote(noteId); if (note != null) { @@ -542,6 +791,7 @@ private void updateCachedNotePath(String noteId, String notePath) { */ public T processNote(String noteId, boolean reload, NoteProcessor noteProcessor) throws IOException { + assertMetadataAvailable(); // Read the tree once, so that the mapping lookup below and the tree traversal that // follows it are both resolved against the same generation of the metadata. NoteTree tree = this.noteTree; @@ -550,6 +800,9 @@ public T processNote(String noteId, boolean reload, NoteProcessor notePro } String notePath = tree.notesInfo.get(noteId); NoteNode noteNode = getNoteNode(tree, notePath); + if (!StringUtils.equals(noteId, noteNode.getNoteId())) { + throw new IOException("Note metadata changed while resolving note: " + noteId); + } return noteNode.loadAndProcessNote(reload, noteProcessor); } @@ -571,6 +824,7 @@ public T processNote(String noteId, NoteProcessor noteProcessor) throws I * @return */ public Folder getOrCreateFolder(String folderName) { + assertMetadataAvailableUnchecked(); return getOrCreateFolder(this.noteTree, folderName); } @@ -591,6 +845,9 @@ private NoteNode getNoteNode(String notePath) throws IOException { private static NoteNode getNoteNode(NoteTree tree, String notePath) throws IOException { String[] tokens = notePath.split("/"); + if (tokens.length == 0) { + throw new IOException("Can not find note: " + notePath); + } Folder curFolder = tree.root; for (int i = 0; i < tokens.length - 1; ++i) { if (!StringUtils.isBlank(tokens[i])) { @@ -626,6 +883,7 @@ private static Folder getFolder(NoteTree tree, String folderPath) throws IOExcep } public Folder getTrashFolder() { + assertMetadataAvailableUnchecked(); return this.noteTree.trash; } @@ -658,6 +916,7 @@ private static boolean isNotePathAvailable(NoteTree tree, String notePath) { } public String getNoteIdByPath(String notePath) throws IOException { + assertMetadataAvailable(); NoteNode noteNode = getNoteNode(notePath); return noteNode.getNoteId(); } @@ -700,6 +959,18 @@ private static class NoteTree { } } + private static final class FolderNoteMove { + private final String noteId; + private final String sourcePath; + private final String destinationPath; + + private FolderNoteMove(String noteId, String sourcePath, String destinationPath) { + this.noteId = noteId; + this.sourcePath = sourcePath; + this.destinationPath = destinationPath; + } + } + /** * Represent one folder that could contains sub folders and note files. */ diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java index d88428419cf..b56591bba9c 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/Notebook.java @@ -559,8 +559,22 @@ public void moveFolder( String newFolderPath, AuthenticationInfo subject, long expectedMetadataVersion) throws IOException { + moveFolder(folderPath, newFolderPath, subject, expectedMetadataVersion, true); + } + + public void moveFolder( + String folderPath, + String newFolderPath, + AuthenticationInfo subject, + long expectedMetadataVersion, + boolean mergeExistingDestination) throws IOException { LOGGER.info("Move folder from {} to {}", folderPath, newFolderPath); - noteManager.moveFolder(folderPath, newFolderPath, subject, expectedMetadataVersion); + noteManager.moveFolder( + folderPath, + newFolderPath, + subject, + expectedMetadataVersion, + mergeExistingDestination); } public void removeFolder(String folderPath, AuthenticationInfo subject) throws IOException { 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 d3d6a3ca3ce..f99fed621de 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 @@ -745,7 +745,8 @@ public void restoreFolder(String folderPath, normalizedFolderPath, destFolderPath, context.getAutheInfo(), - authorization.getMetadata().getVersion()); + authorization.getMetadata().getVersion(), + false); return null; }); callback.onSuccess(null, context); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java index 0f8ca28cea6..5861ba6bba4 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java @@ -148,19 +148,317 @@ void failedNoteMoveKeepsSourceMetadataAndCachedPath() throws IOException { } @Test - void testMoveFolderRejectsExistingDestination() throws IOException { + void testMoveFolderMergesExistingDestination() throws IOException { + Note source = createNote("/source/source-note"); + Note destination = createNote("/destination/destination-note"); + noteManager.saveNote(source); + noteManager.saveNote(destination); + long versionBeforeMove = noteManager.getNotesInfoSnapshot().getVersion(); + + noteManager.moveFolder("/source", "/destination", AuthenticationInfo.ANONYMOUS); + + assertEquals("/destination/source-note", noteManager.getNotesInfo().get(source.getId())); + assertEquals( + "/destination/destination-note", noteManager.getNotesInfo().get(destination.getId())); + assertEquals("/destination/source-note", source.getPath()); + assertEquals("/destination/destination-note", destination.getPath()); + assertFalse(noteManager.containsFolder("/source")); + assertTrue(noteManager.containsNote("/destination/source-note")); + assertTrue(noteManager.containsNote("/destination/destination-note")); + assertEquals(versionBeforeMove + 1, noteManager.getNotesInfoSnapshot().getVersion()); + } + + @Test + void testMoveFolderRejectsExistingNoteInDestination() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(0, 0); + NoteManager manager = new NoteManager(repo, zConf); Note source = createNote("/source/note"); Note destination = createNote("/destination/note"); + manager.saveNote(source); + manager.saveNote(destination); + long versionBeforeMove = manager.getNotesInfoSnapshot().getVersion(); + + assertThrows( + NotePathAlreadyExistsException.class, + () -> manager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals(0, repo.moveAttempts); + assertEquals(versionBeforeMove, manager.getNotesInfoSnapshot().getVersion()); + assertEquals("/source/note", manager.getNotesInfo().get(source.getId())); + assertEquals("/destination/note", manager.getNotesInfo().get(destination.getId())); + } + + @Test + void testMoveFolderRecursivelyMergesExistingDestinationFolders() throws IOException { + Note source = createNote("/source/shared/source-note"); + Note destination = createNote("/destination/shared/destination-note"); noteManager.saveNote(source); noteManager.saveNote(destination); + noteManager.moveFolder("/source", "/destination", AuthenticationInfo.ANONYMOUS); + + assertEquals( + "/destination/shared/source-note", noteManager.getNotesInfo().get(source.getId())); + assertEquals( + "/destination/shared/destination-note", + noteManager.getNotesInfo().get(destination.getId())); + assertEquals("/destination/shared/source-note", source.getPath()); + assertEquals("/destination/shared/destination-note", destination.getPath()); + assertFalse(noteManager.containsFolder("/source")); + } + + @Test + void testMoveFolderRejectsNoteFolderTypeCollisionsBeforeMovingNotes() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(0, 0); + NoteManager manager = new NoteManager(repo, zConf); + Note sourceNote = createNote("/source/shared"); + Note destinationNote = createNote("/destination/shared/destination-note"); + manager.saveNote(sourceNote); + manager.saveNote(destinationNote); + assertThrows( NotePathAlreadyExistsException.class, - () -> noteManager.moveFolder( + () -> manager.moveFolder( "/source", "/destination", AuthenticationInfo.ANONYMOUS)); - assertEquals("/source/note", noteManager.getNotesInfo().get(source.getId())); - assertEquals("/destination/note", noteManager.getNotesInfo().get(destination.getId())); + assertEquals(0, repo.moveAttempts); + assertEquals("/source/shared", manager.getNotesInfo().get(sourceNote.getId())); + assertEquals( + "/destination/shared/destination-note", + manager.getNotesInfo().get(destinationNote.getId())); + } + + @Test + void testMoveFolderRejectsFolderNoteTypeCollisionsBeforeMovingNotes() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(0, 0); + NoteManager manager = new NoteManager(repo, zConf); + Note sourceNote = createNote("/source/shared/source-note"); + Note destinationNote = createNote("/destination/shared"); + manager.saveNote(sourceNote); + manager.saveNote(destinationNote); + + assertThrows( + NotePathAlreadyExistsException.class, + () -> manager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals(0, repo.moveAttempts); + assertEquals("/source/shared/source-note", manager.getNotesInfo().get(sourceNote.getId())); + assertEquals("/destination/shared", manager.getNotesInfo().get(destinationNote.getId())); + } + + @Test + void failedFolderMergeRollsBackDurableMovesAndKeepsMetadataUnchanged() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(2, 0); + NoteManager manager = new NoteManager(repo, zConf); + Note firstSource = createNote("/source/a-note"); + Note secondSource = createNote("/source/b-note"); + Note destination = createNote("/destination/destination-note"); + manager.saveNote(firstSource); + manager.saveNote(secondSource); + manager.saveNote(destination); + long versionBeforeMove = manager.getNotesInfoSnapshot().getVersion(); + + assertThrows( + IllegalStateException.class, + () -> manager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals(4, repo.moveAttempts); + assertEquals( + Set.of("/source/a-note", "/source/b-note", "/destination/destination-note"), + repo.persistedPaths); + assertEquals(versionBeforeMove, manager.getNotesInfoSnapshot().getVersion()); + assertEquals("/source/a-note", manager.getNotesInfo().get(firstSource.getId())); + assertEquals("/source/b-note", manager.getNotesInfo().get(secondSource.getId())); + assertEquals("/source/a-note", firstSource.getPath()); + assertEquals("/source/b-note", secondSource.getPath()); + assertTrue(manager.containsFolder("/source")); + assertFalse(manager.containsNote("/destination/a-note")); + } + + @Test + void failedFolderMergeReconcilesMetadataWhenCompensationFails() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(2, 4); + NoteManager manager = new NoteManager(repo, zConf); + Note firstSource = createNote("/source/a-note"); + Note secondSource = createNote("/source/b-note"); + Note destination = createNote("/destination/destination-note"); + manager.saveNote(firstSource); + manager.saveNote(secondSource); + manager.saveNote(destination); + long versionBeforeMove = manager.getNotesInfoSnapshot().getVersion(); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> manager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals(1, failure.getSuppressed().length); + assertEquals(4, repo.moveAttempts); + assertEquals( + Set.of("/destination/a-note", "/source/b-note", "/destination/destination-note"), + repo.persistedPaths); + assertTrue(manager.getNotesInfoSnapshot().getVersion() > versionBeforeMove); + assertEquals("/destination/a-note", manager.getNotesInfo().get(firstSource.getId())); + assertEquals("/source/b-note", manager.getNotesInfo().get(secondSource.getId())); + assertEquals( + firstSource.getId(), + manager.processNote(firstSource.getId(), note -> note.getId())); + } + + @Test + void failedFolderMergeFailsClosedUntilMetadataCanBeReloaded() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(2, 4, true); + NoteManager manager = new NoteManager(repo, zConf); + Note firstSource = createNote("/source/a-note"); + Note secondSource = createNote("/source/b-note"); + Note destination = createNote("/destination/destination-note"); + manager.saveNote(firstSource); + manager.saveNote(secondSource); + manager.saveNote(destination); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> manager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals(2, failure.getSuppressed().length); + assertEquals(4, repo.moveAttempts); + IOException unavailable = assertThrows(IOException.class, manager::getNotesInfoSnapshot); + assertEquals( + "Notebook metadata is unavailable after repository recovery failed", + unavailable.getMessage()); + assertThrows( + IOException.class, + () -> manager.processNote(firstSource.getId(), note -> note)); + assertThrows( + IOException.class, + () -> manager.moveFolder( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + assertEquals(4, repo.moveAttempts); + assertThrows(IllegalStateException.class, manager::getNotesInfo); + + repo.allowList(); + manager.reloadNotes(); + + assertEquals( + Set.of("/destination/a-note", "/source/b-note", "/destination/destination-note"), + repo.persistedPaths); + assertEquals("/destination/a-note", manager.getNotesInfo().get(firstSource.getId())); + assertEquals("/source/b-note", manager.getNotesInfo().get(secondSource.getId())); + assertEquals( + firstSource.getId(), + manager.processNote(firstSource.getId(), note -> note.getId())); + } + + @Test + void testMoveFolderMergeUsesCanonicalFolderPaths() throws IOException { + Note source = createNote("/source/note"); + Note destination = createNote("/destination/destination-note"); + noteManager.saveNote(source); + noteManager.saveNote(destination); + + noteManager.moveFolder("/source/", "/destination", AuthenticationInfo.ANONYMOUS); + + assertEquals("/destination/note", noteManager.getNotesInfo().get(source.getId())); + assertEquals("/destination/note", source.getPath()); + assertFalse(noteManager.containsFolder("/source")); + assertTrue(noteManager.containsNote("/destination/note")); + } + + @Test + void testMoveFolderRejectsOwnDescendantWithTrailingSlashAlias() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(0, 0); + NoteManager manager = new NoteManager(repo, zConf); + Note source = createNote("/source/source-note"); + Note child = createNote("/source/child/child-note"); + manager.saveNote(source); + manager.saveNote(child); + + IOException existingChildFailure = assertThrows( + IOException.class, + () -> manager.moveFolder( + "/source/", "/source/child", AuthenticationInfo.ANONYMOUS)); + assertEquals( + "Can not move folder '/source' into its own descendant", + existingChildFailure.getMessage()); + + assertThrows( + IOException.class, + () -> manager.moveFolder( + "/source/", "/source/new-child", AuthenticationInfo.ANONYMOUS)); + assertEquals(0, repo.moveAttempts); + assertEquals("/source/source-note", manager.getNotesInfo().get(source.getId())); + assertEquals("/source/child/child-note", manager.getNotesInfo().get(child.getId())); + assertTrue(manager.containsFolder("/source/child")); + } + + @Test + void testMoveFolderRejectsMovingTheRootBeforeRepositoryMutation() throws IOException { + TrackingFolderMergeRepo repo = new TrackingFolderMergeRepo(0, 0); + NoteManager manager = new NoteManager(repo, zConf); + Note rootNote = createNote("/root-note"); + manager.saveNote(rootNote); + long versionBeforeMove = manager.getNotesInfoSnapshot().getVersion(); + + IOException failure = assertThrows( + IOException.class, + () -> manager.moveFolder("/", "/destination", AuthenticationInfo.ANONYMOUS)); + + assertEquals("Can not move the root folder", failure.getMessage()); + assertEquals(0, repo.moveAttempts); + assertEquals(versionBeforeMove, manager.getNotesInfoSnapshot().getVersion()); + assertEquals("/root-note", manager.getNotesInfo().get(rootNote.getId())); + } + + @Test + void testMoveFolderIntoAncestorPreservesNoteIdentityWhenAPathIsReused() throws IOException { + Note first = createNote("/a/b/x"); + Note second = createNote("/a/b/b/x"); + noteManager.saveNote(first); + noteManager.saveNote(second); + + noteManager.moveFolder("/a/b", "/a", AuthenticationInfo.ANONYMOUS); + + assertEquals("/a/x", noteManager.getNotesInfo().get(first.getId())); + assertEquals("/a/b/x", noteManager.getNotesInfo().get(second.getId())); + assertEquals(first.getId(), noteManager.processNote(first.getId(), note -> note.getId())); + assertEquals(second.getId(), noteManager.processNote(second.getId(), note -> note.getId())); + } + + @Test + void testMoveFolderMergesIntoTheRootWithoutCreatingDoubleSlashPaths() throws IOException { + Note source = createNote("/source/source-note"); + Note destination = createNote("/destination-note"); + noteManager.saveNote(source); + noteManager.saveNote(destination); + + noteManager.moveFolder("/source", "/", AuthenticationInfo.ANONYMOUS); + + assertEquals("/source-note", noteManager.getNotesInfo().get(source.getId())); + assertEquals("/destination-note", noteManager.getNotesInfo().get(destination.getId())); + assertEquals("/source-note", source.getPath()); + assertFalse(noteManager.containsFolder("/source")); + assertTrue(noteManager.containsNote("/source-note")); + } + + @Test + void processNoteFailsClosedWhenPathMetadataResolvesToAnotherNote() throws IOException { + Note first = createNote("/shared/path"); + Note second = createNote("/shared/path"); + noteManager.saveNote(first); + noteManager.saveNote(second); + + IOException failure = assertThrows( + IOException.class, + () -> noteManager.processNote(first.getId(), note -> note)); + + assertEquals( + "Note metadata changed while resolving note: " + first.getId(), + failure.getMessage()); + assertEquals(second, noteManager.processNote(second.getId(), note -> note)); } @Test @@ -394,6 +692,76 @@ public void move( } } + private static final class TrackingFolderMergeRepo extends InMemoryNotebookRepo { + private final int failAfterMutationAttempt; + private final int failBeforeMutationAttempt; + private boolean failListAfterMove; + private final Map persistedPathsById = new ConcurrentHashMap<>(); + private final Set persistedPaths = ConcurrentHashMap.newKeySet(); + private int moveAttempts; + + private TrackingFolderMergeRepo( + int failAfterMutationAttempt, int failBeforeMutationAttempt) { + this(failAfterMutationAttempt, failBeforeMutationAttempt, false); + } + + private TrackingFolderMergeRepo( + int failAfterMutationAttempt, + int failBeforeMutationAttempt, + boolean failListAfterMove) { + this.failAfterMutationAttempt = failAfterMutationAttempt; + this.failBeforeMutationAttempt = failBeforeMutationAttempt; + this.failListAfterMove = failListAfterMove; + } + + @Override + public void save(Note note, AuthenticationInfo subject) throws IOException { + super.save(note, subject); + persistedPathsById.put(note.getId(), note.getPath()); + persistedPaths.add(note.getPath()); + } + + @Override + public Map list(AuthenticationInfo subject) throws IOException { + if (failListAfterMove && moveAttempts > 0) { + throw new IOException("Failed to reload notebook metadata"); + } + Map noteInfos = super.list(subject); + for (Map.Entry entry : persistedPathsById.entrySet()) { + noteInfos.put(entry.getKey(), new NoteInfo(entry.getKey(), entry.getValue())); + } + return noteInfos; + } + + private void allowList() { + failListAfterMove = false; + } + + @Override + public void move( + String noteId, + String notePath, + String newNotePath, + AuthenticationInfo subject) { + moveAttempts++; + if (failBeforeMutationAttempt == moveAttempts) { + throw new IllegalStateException("Failed to move note " + noteId); + } + if (!persistedPaths.remove(notePath)) { + throw new IllegalStateException("Missing source note at " + notePath); + } + if (!persistedPaths.add(newNotePath)) { + persistedPaths.add(notePath); + throw new IllegalStateException("Destination note exists at " + newNotePath); + } + persistedPathsById.put(noteId, newNotePath); + super.move(noteId, notePath, newNotePath, subject); + if (failAfterMutationAttempt == moveAttempts) { + throw new IllegalStateException("Failed after moving note " + noteId); + } + } + } + private static final class BlockingVersionedRepo extends InMemoryNotebookRepo implements NotebookRepoWithVersionControl { private final CountDownLatch revisionStarted = new CountDownLatch(1); diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java index 730dc1e4dab..841069fe973 100644 --- a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java +++ b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/repo/VFSNotebookRepoTest.java @@ -39,6 +39,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class VFSNotebookRepoTest { @@ -160,6 +161,53 @@ void testCaseOnlyFolderRename() throws IOException { notebookRepo.list(AuthenticationInfo.ANONYMOUS).get(note.getId()).getPath()); } + @Test + void testNoteMoveIntoExistingFolderPreservesBothNotes() throws IOException { + Note source = new Note(); + source.setPath("/source/source-note"); + source.setNoteParser(noteParser); + notebookRepo.save(source, AuthenticationInfo.ANONYMOUS); + + Note destination = new Note(); + destination.setPath("/destination/destination-note"); + destination.setNoteParser(noteParser); + notebookRepo.save(destination, AuthenticationInfo.ANONYMOUS); + + notebookRepo.move( + source.getId(), + source.getPath(), + "/destination/source-note", + AuthenticationInfo.ANONYMOUS); + + Map noteInfos = notebookRepo.list(AuthenticationInfo.ANONYMOUS); + assertEquals(2, noteInfos.size()); + assertEquals("/destination/source-note", noteInfos.get(source.getId()).getPath()); + assertEquals("/destination/destination-note", noteInfos.get(destination.getId()).getPath()); + } + + @Test + void testFolderMoveRejectsExistingDestinationWithoutDeletingNotes() throws IOException { + Note source = new Note(); + source.setPath("/source/source-note"); + source.setNoteParser(noteParser); + notebookRepo.save(source, AuthenticationInfo.ANONYMOUS); + + Note destination = new Note(); + destination.setPath("/destination/destination-note"); + destination.setNoteParser(noteParser); + notebookRepo.save(destination, AuthenticationInfo.ANONYMOUS); + + assertThrows( + IOException.class, + () -> notebookRepo.move( + "/source", "/destination", AuthenticationInfo.ANONYMOUS)); + + Map noteInfos = notebookRepo.list(AuthenticationInfo.ANONYMOUS); + assertEquals(2, noteInfos.size()); + assertEquals("/source/source-note", noteInfos.get(source.getId()).getPath()); + assertEquals("/destination/destination-note", noteInfos.get(destination.getId()).getPath()); + } + @Test void testUpdateSettings() throws IOException { List repoSettings = notebookRepo.getSettings(AuthenticationInfo.ANONYMOUS);